978 lines
46 KiB
Python
978 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
fw_pack — GD32E230 双固件打包公共库 (CLI 与 FlashTool GUI 共用)
|
||
================================================================
|
||
输入编译好的 APP bin, 按 layout profile 补页 / 算 CRC / 写记录, 产出:
|
||
*_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {pages16|crc16})
|
||
*_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志)
|
||
*_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电判定链
|
||
锚点擦除态 → A 向量非法 → 落 B 记录校验 → 跳出厂固件)
|
||
*.md5.txt 产物 MD5 清单 + 布局摘要
|
||
|
||
规则与双固件 bootloader 侧逐字一致:
|
||
- CRC-16/XMODEM (poly 0x1021, init 0x0000, 不反转, 无 xorout),
|
||
自检向量 b"123456789" -> 0x31C3;
|
||
- A 包: 补 0xFF 至整 N 页; body 恰整页或 余量 < 记录长 R -> 向后扩 1 页;
|
||
CRC 范围 = N 页内容去掉末 R 字节;
|
||
- B 包 (fixed_slot): 恰好填满槽, 记录固定在槽末 record 区, CRC 范围 =
|
||
槽内容去掉记录区; 固件 > 槽-R 直接报错 (不可扩展);
|
||
- 槽尾锚点 = A 槽末 4B, 与 trailer 同值 (BL 收包后独立复算交叉验证)。
|
||
|
||
纯标准库, Windows 兼容。
|
||
"""
|
||
|
||
import datetime
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import struct
|
||
|
||
__all__ = [
|
||
'FwPackError', 'crc16_xmodem', 'self_test', 'parse_num',
|
||
'BUILTIN_PROFILES', 'load_profile', 'Profile',
|
||
'detect_slot', 'read_vector', 'validate_app_bin',
|
||
'build_a_pack', 'build_b_pack', 'build_full', 'build_factory',
|
||
'verify_a_blob', 'verify_b_blob', 'verify_full_blob', 'verify_factory_blob',
|
||
'pack_all', 'suggest_full_name', 'suggest_factory_name',
|
||
]
|
||
|
||
TOOL_VERSION = '2.1'
|
||
|
||
# GD32E230 家族 SRAM: F8 最大 8K, SP 必须落在其中 (F4/F6 的 4K 也在范围内)
|
||
SRAM_LO, SRAM_HI = 0x20000000, 0x20002000
|
||
|
||
|
||
class FwPackError(Exception):
|
||
"""打包过程中的所有可预期错误(参数/校验/尺寸), 消息可直接展示给用户"""
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# CRC-16/XMODEM
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def crc16_xmodem(data: bytes) -> int:
|
||
"""CRC-16/XMODEM: poly 0x1021, init 0x0000, 不反转, 无 xorout"""
|
||
crc = 0
|
||
for b in data:
|
||
crc ^= b << 8
|
||
for _ in range(8):
|
||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||
return crc
|
||
|
||
|
||
def self_test():
|
||
"""启动自检: CRC 实现必须命中已知测试向量, 否则拒绝工作"""
|
||
if crc16_xmodem(b'123456789') != 0x31C3:
|
||
raise FwPackError('CRC-16/XMODEM 自检失败 (期望 b"123456789" -> 0x31C3)')
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 数值 / 表达式解析
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def parse_num(text, what='数值'):
|
||
"""解析 '0x08002000' / '64K' / '19k' / '1M' / '1024'; 非法抛 FwPackError"""
|
||
if isinstance(text, bool):
|
||
raise FwPackError(f'{what}: {text!r} 不是数值')
|
||
if isinstance(text, int):
|
||
return text
|
||
s = str(text).strip().lower()
|
||
if not s:
|
||
raise FwPackError(f'{what}: 空值')
|
||
mult = 1
|
||
if s.endswith('k'):
|
||
mult, s = 1024, s[:-1]
|
||
elif s.endswith('m'):
|
||
mult, s = 1024 * 1024, s[:-1]
|
||
try:
|
||
if s.startswith('0x') or s.startswith('-0x'):
|
||
return int(s, 16) * mult
|
||
return int(s, 10) * mult
|
||
except ValueError:
|
||
raise FwPackError(f'{what}: 无法解析 {text!r}') from None
|
||
|
||
|
||
def _parse_addr_expr(text, slot_base, slot_end, what):
|
||
"""地址表达式: 绝对地址, 或 'slot_end-4' / 'slot_start+8'"""
|
||
s = str(text).strip().lower().replace(' ', '')
|
||
m = re.fullmatch(r'(slot_end|slot_start)([+-]\d+)?', s)
|
||
if m:
|
||
base = slot_end if m.group(1) == 'slot_end' else slot_base
|
||
return base + int(m.group(2) or 0)
|
||
return parse_num(s, what)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 记录(record)规格: "{pages16@+0, crc16@+2}"
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
_RECORD_FIELD_RE = re.compile(
|
||
r'^(?P<name>magic|pages|crc)(?P<bits>16|32)?@(?P<off>\+\d+)'
|
||
r'(?:=(?P<val>0x[0-9a-fA-F]+|\d+))?$')
|
||
_KNOWN_FIELDS = ('magic', 'pages', 'crc')
|
||
|
||
|
||
class RecordField:
|
||
def __init__(self, name, bits, offset, fixed):
|
||
self.name, self.bits, self.offset, self.fixed = name, bits, offset, fixed
|
||
|
||
|
||
class RecordSpec:
|
||
"""从 "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}" 解析出的记录布局"""
|
||
|
||
def __init__(self, fields):
|
||
self.fields = fields
|
||
|
||
@classmethod
|
||
def parse(cls, text, what='record'):
|
||
s = str(text).strip()
|
||
if not (s.startswith('{') and s.endswith('}')):
|
||
raise FwPackError(f'{what}: 必须形如 "{{pages16@+0, crc16@+2}}", 得到 {text!r}')
|
||
fields = []
|
||
for part in s[1:-1].split(','):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
m = _RECORD_FIELD_RE.match(part)
|
||
if not m:
|
||
raise FwPackError(f'{what}: 无法解析字段 {part!r} '
|
||
f'(支持 magic16/pages16/crc16@+偏移[=固定值])')
|
||
fields.append(RecordField(m.group('name'), int(m.group('bits') or 16),
|
||
int(m.group('off')[1:]),
|
||
int(m.group('val'), 0) if m.group('val') else None))
|
||
if not fields:
|
||
raise FwPackError(f'{what}: 至少需要一个字段')
|
||
names = [f.name for f in fields]
|
||
for need in ('pages', 'crc'):
|
||
if need not in names:
|
||
raise FwPackError(f'{what}: 缺少 {need}16 字段')
|
||
return cls(fields)
|
||
|
||
@property
|
||
def length(self):
|
||
"""记录本体字节数(不含区内尾部保留字节)"""
|
||
return max(f.offset + f.bits // 8 for f in self.fields)
|
||
|
||
def pack(self, pages, crc, what='record'):
|
||
"""按布局写出记录字节; pages/crc 字段自动填入, 固定值字段校验存在"""
|
||
buf = bytearray(b'\xFF' * self.length)
|
||
for f in self.fields:
|
||
val = f.fixed if f.name == 'magic' else (
|
||
pages if f.name == 'pages' else crc if f.name == 'crc' else None)
|
||
if val is None:
|
||
raise FwPackError(f'{what}: 未知字段 {f.name}')
|
||
if f.name == 'crc':
|
||
val &= (1 << f.bits) - 1
|
||
if f.bits == 16:
|
||
struct.pack_into('<H', buf, f.offset, val & 0xFFFF)
|
||
else:
|
||
struct.pack_into('<I', buf, f.offset, val & 0xFFFFFFFF)
|
||
return bytes(buf)
|
||
|
||
|
||
class SlotSpec:
|
||
def __init__(self, name, addr, size, fixed_slot, record, record_addr, anchor_addr):
|
||
self.name = name
|
||
self.addr, self.size = addr, size
|
||
self.fixed_slot = fixed_slot # True: 固定满槽(B), 不可扩展
|
||
self.record = record # RecordSpec | None
|
||
self.record_addr = record_addr # 固定记录区地址(fixed_slot), None=记录随包尾
|
||
self.anchor_addr = anchor_addr # FULL 镜像槽尾锚点地址, None=无
|
||
|
||
@property
|
||
def end(self):
|
||
return self.addr + self.size
|
||
|
||
def record_area_len(self, page_size):
|
||
"""记录区长度: 固定槽取 [record_addr, slot_end); 动态槽取记录本体长"""
|
||
if self.fixed_slot and self.record_addr is not None:
|
||
return self.end - self.record_addr
|
||
return self.record.length
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 内置 profile
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
BUILTIN_PROFILES = {
|
||
# CeramixNano-MB 双固件 bootloader 的布局
|
||
'CeramixNano_MB_Dual_FW': {
|
||
'profile': 'CeramixNano_MB_Dual_FW',
|
||
'chip': 'GD32E230F8',
|
||
'flash_base': '0x08000000',
|
||
'flash_size': '64K',
|
||
'page_size': 1024,
|
||
'bl': {'addr': '0x08000000', 'size': '8K'},
|
||
'slot_a': {'addr': '0x08002000', 'size': '36K',
|
||
'anchor': 'slot_end-4',
|
||
'record': '{pages16@+0, crc16@+2}'},
|
||
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
|
||
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
|
||
'record_addr': 'slot_end-8'},
|
||
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
|
||
'crc': 'xmodem',
|
||
},
|
||
# CeramixNano EDDY (LDC1612) 双固件布局——Flash 分配与 MB 完全相同;
|
||
# BL 用专属分支 bootloader_CeramixNano_EDDY 的 bin(LED=PB1,与 MB 分支并列、独立编号)
|
||
'CeramixNano_EDDY_Dual_FW': {
|
||
'profile': 'CeramixNano_EDDY_Dual_FW',
|
||
'chip': 'GD32E230F8',
|
||
'flash_base': '0x08000000',
|
||
'flash_size': '64K',
|
||
'page_size': 1024,
|
||
'bl': {'addr': '0x08000000', 'size': '8K'},
|
||
'slot_a': {'addr': '0x08002000', 'size': '36K',
|
||
'anchor': 'slot_end-4',
|
||
'record': '{pages16@+0, crc16@+2}'},
|
||
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
|
||
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
|
||
'record_addr': 'slot_end-8'},
|
||
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
|
||
'crc': 'xmodem',
|
||
},
|
||
# 无保底槽的单槽布局 (旧 BL 升级过渡 / 无 B 槽工程)
|
||
'f8_single_slot': {
|
||
'profile': 'f8_single_slot',
|
||
'chip': 'GD32E230F8',
|
||
'flash_base': '0x08000000',
|
||
'flash_size': '64K',
|
||
'page_size': 1024,
|
||
'bl': {'addr': '0x08000000', 'size': '8K'},
|
||
'slot_a': {'addr': '0x08002000', 'size': '55K',
|
||
'anchor': 'slot_end-4',
|
||
'record': '{pages16@+0, crc16@+2}'},
|
||
'slot_b': None,
|
||
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
|
||
'crc': 'xmodem',
|
||
},
|
||
}
|
||
|
||
|
||
class Profile:
|
||
"""解析并校验后的 Flash 布局"""
|
||
|
||
def __init__(self, raw):
|
||
if not isinstance(raw, dict):
|
||
raise FwPackError('profile 必须是 JSON 对象')
|
||
self.name = str(raw.get('profile') or '(未命名)')
|
||
self.chip = str(raw.get('chip') or 'GD32E230')
|
||
self.flash_base = parse_num(raw.get('flash_base', '0x08000000'), 'flash_base')
|
||
self.flash_size = parse_num(raw.get('flash_size'), 'flash_size')
|
||
self.page_size = parse_num(raw.get('page_size', 1024), 'page_size')
|
||
crc_algo = str(raw.get('crc', 'xmodem')).lower()
|
||
if crc_algo != 'xmodem':
|
||
raise FwPackError(f'crc 算法仅支持 xmodem, profile 声明 {crc_algo!r}')
|
||
|
||
bl = raw.get('bl') or {}
|
||
self.bl_addr = parse_num(bl.get('addr', self.flash_base), 'bl.addr')
|
||
self.bl_size = parse_num(bl.get('size', 0), 'bl.size')
|
||
self.bl_end = self.bl_addr + self.bl_size
|
||
|
||
sa = raw.get('slot_a') or {}
|
||
self.slot_a = SlotSpec(
|
||
'slot_a', parse_num(sa.get('addr'), 'slot_a.addr'),
|
||
parse_num(sa.get('size'), 'slot_a.size'),
|
||
bool(sa.get('fixed_slot', False)),
|
||
RecordSpec.parse(sa.get('record', '{pages16@+0, crc16@+2}'), 'slot_a.record'),
|
||
None, None)
|
||
if sa.get('record_addr'):
|
||
self.slot_a.record_addr = _parse_addr_expr(
|
||
sa['record_addr'], self.slot_a.addr, self.slot_a.end, 'slot_a.record_addr')
|
||
if sa.get('anchor'):
|
||
self.slot_a.anchor_addr = _parse_addr_expr(
|
||
sa['anchor'], self.slot_a.addr, self.slot_a.end, 'slot_a.anchor')
|
||
|
||
sb = raw.get('slot_b')
|
||
self.slot_b = None
|
||
if sb:
|
||
self.slot_b = SlotSpec(
|
||
'slot_b', parse_num(sb.get('addr'), 'slot_b.addr'),
|
||
parse_num(sb.get('size'), 'slot_b.size'),
|
||
bool(sb.get('fixed_slot', False)),
|
||
RecordSpec.parse(sb.get('record', '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}'),
|
||
'slot_b.record'),
|
||
None, None)
|
||
if sb.get('record_addr'):
|
||
self.slot_b.record_addr = _parse_addr_expr(
|
||
sb['record_addr'], self.slot_b.addr, self.slot_b.end, 'slot_b.record_addr')
|
||
elif self.slot_b.fixed_slot:
|
||
raise FwPackError('slot_b.fixed_slot 需要 record_addr (如 "slot_end-8")')
|
||
|
||
fl = raw.get('flag') or {}
|
||
self.flag_value = parse_num(fl.get('value', '0xEEEE'), 'flag.value') & 0xFFFFFFFF
|
||
self.flag_page = self.flash_base + self.flash_size - self.page_size # page:last
|
||
self.flag_addr = parse_num(fl.get('addr', self.flash_base + self.flash_size - 4),
|
||
'flag.addr')
|
||
|
||
self.validate()
|
||
|
||
# -- 校验: 页对齐 / 不重叠 / 恰好铺满 / 记录区合法 --
|
||
def validate(self):
|
||
pg, end = self.page_size, self.flash_base + self.flash_size
|
||
if self.page_size <= 0 or self.page_size & (self.page_size - 1):
|
||
raise FwPackError(f'page_size 必须是 2 的幂, 得到 {self.page_size}')
|
||
if self.flash_size <= 0 or self.flash_size % pg:
|
||
raise FwPackError(f'flash_size {self.flash_size} 不是页({pg})整数倍')
|
||
parts = [('BL', self.bl_addr, self.bl_end), ('slot_a', self.slot_a.addr, self.slot_a.end)]
|
||
if self.bl_addr != self.flash_base:
|
||
raise FwPackError(f'bl.addr 0x{self.bl_addr:08X} 必须等于 flash_base '
|
||
f'0x{self.flash_base:08X}')
|
||
for name, lo, hi in parts:
|
||
if lo % pg or hi % pg:
|
||
raise FwPackError(f'{name} 区间 [0x{lo:08X},0x{hi:08X}) 未页对齐({pg})')
|
||
if hi <= lo:
|
||
raise FwPackError(f'{name} 区间为空')
|
||
if self.slot_b:
|
||
parts.append(('slot_b', self.slot_b.addr, self.slot_b.end))
|
||
parts.append(('flag页', self.flag_page, end))
|
||
parts.sort(key=lambda p: p[1])
|
||
for (n1, l1, h1), (n2, l2, _h2) in zip(parts, parts[1:]):
|
||
if l2 < h1:
|
||
raise FwPackError(f'{n1} [0x{l1:08X},0x{h1:08X}) 与 {n2} '
|
||
f'[0x{l2:08X},...) 重叠')
|
||
if parts[0][1] != self.flash_base:
|
||
raise FwPackError(f'flash 起始 0x{self.flash_base:08X} 未被 BL 覆盖')
|
||
total = sum(hi - lo for _n, lo, hi in parts)
|
||
if total != self.flash_size:
|
||
missing = self.flash_size - total
|
||
raise FwPackError(f'分区总和 {total} != flash_size {self.flash_size} '
|
||
f'(缺口 {missing}B, 请调整槽大小补齐; 校准页等保留区'
|
||
f'需并入某个槽或单独声明)')
|
||
for i in range(len(parts) - 1):
|
||
if parts[i][2] != parts[i + 1][1]:
|
||
gap = parts[i + 1][1] - parts[i][2]
|
||
raise FwPackError(f'{parts[i][0]} 与 {parts[i + 1][0]} 之间有 {gap}B '
|
||
f'未分配空隙 (要求分区连续铺满)')
|
||
# flag
|
||
if not (self.flag_page <= self.flag_addr and self.flag_addr + 4 <= end):
|
||
raise FwPackError(f'flag.addr 0x{self.flag_addr:08X} 不在末页 '
|
||
f'[0x{self.flag_page:08X},0x{end:08X}) 内')
|
||
# 记录区
|
||
for slot in (self.slot_a, self.slot_b):
|
||
if slot is None or slot.record is None:
|
||
continue
|
||
area = slot.record_area_len(pg)
|
||
if area <= 0 or area > pg:
|
||
raise FwPackError(f'{slot.name}.record 记录区 {area}B 非法 (须 1~{pg}B)')
|
||
if slot.record.length > area:
|
||
raise FwPackError(f'{slot.name}.record 本体 {slot.record.length}B '
|
||
f'超过记录区 {area}B')
|
||
if slot.record_addr is not None and slot.record_addr + area != slot.end:
|
||
raise FwPackError(f'{slot.name}.record_addr+记录区 必须等于槽末'
|
||
f' (BL 约定记录位于槽尾)')
|
||
if slot.anchor_addr is not None and not (slot.addr <= slot.anchor_addr
|
||
and slot.anchor_addr + 4 <= slot.end):
|
||
raise FwPackError(f'{slot.name}.anchor 0x{slot.anchor_addr:08X} '
|
||
f'不在槽区间内')
|
||
|
||
def slot(self, which):
|
||
if which == 'a':
|
||
return self.slot_a
|
||
if which != 'b':
|
||
raise FwPackError(f'未知槽位 {which!r} (只能是 a/b)')
|
||
if self.slot_b is None:
|
||
raise FwPackError('当前 profile 未定义 slot_b (无保底槽布局)')
|
||
return self.slot_b
|
||
|
||
def describe(self):
|
||
pg = self.page_size
|
||
lines = [f'profile: {self.name} ({self.chip}, '
|
||
f'{self.flash_size // 1024}K, page {pg}B)']
|
||
first_pg = 0
|
||
rows = [('BL', self.bl_addr, self.bl_size, ''),
|
||
('FW_A', self.slot_a.addr, self.slot_a.size, 'OTA/Ymodem 接收槽')]
|
||
if self.slot_b:
|
||
rows.append(('FW_B', self.slot_b.addr, self.slot_b.size,
|
||
'出厂保底槽' + (' (固定满槽)' if self.slot_b.fixed_slot else '')))
|
||
rows.append(('FLAG', self.flag_page, pg,
|
||
f'0x{self.flag_value:04X} @ 0x{self.flag_addr:08X}'))
|
||
for name, addr, size, note in rows:
|
||
pages = f'页 {first_pg}~{first_pg + size // pg - 1}'
|
||
lines.append(f' {name:<5} 0x{addr:08X}-0x{addr + size - 1:08X} '
|
||
f'{size // 1024:>3}K {pages:<12}{note}')
|
||
first_pg += size // pg
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def load_profile(spec):
|
||
"""内置名(CeramixNano_MB_Dual_FW / f8_single_slot) 或 profile JSON 文件路径"""
|
||
if not spec:
|
||
spec = 'CeramixNano_MB_Dual_FW'
|
||
if spec in BUILTIN_PROFILES:
|
||
return Profile(dict(BUILTIN_PROFILES[spec]))
|
||
if os.path.isfile(spec):
|
||
try:
|
||
with open(spec, 'r', encoding='utf-8') as f:
|
||
return Profile(json.load(f))
|
||
except ValueError as exc:
|
||
raise FwPackError(f'profile JSON 解析失败: {exc}') from None
|
||
raise FwPackError(f'profile {spec!r} 既不是内置名 ({"/".join(BUILTIN_PROFILES)}) '
|
||
f'也不是存在的 JSON 文件')
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 向量表: 读取 / 校验 / 自动识别 A|B
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def read_vector(data):
|
||
if len(data) < 8:
|
||
raise FwPackError(f'文件只有 {len(data)}B, 连向量表(SP+复位向量)都放不下')
|
||
return struct.unpack_from('<II', data, 0)
|
||
|
||
|
||
def validate_app_bin(data, profile, which):
|
||
"""向量表硬门槛: SP 在 SRAM, 复位向量 Thumb 且落在声明槽内"""
|
||
slot = profile.slot(which)
|
||
sp, rst = read_vector(data)
|
||
if not (SRAM_LO <= sp <= SRAM_HI):
|
||
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 GD32E230 SRAM '
|
||
f'[0x{SRAM_LO:08X},0x{SRAM_HI:08X}] (大概不是 GD32E230 固件)')
|
||
if not rst & 1:
|
||
raise FwPackError(f'复位向量 0x{rst:08X} 不是 Thumb 地址 (bit0=0)')
|
||
if not (slot.addr <= rst & ~1 < slot.end):
|
||
raise FwPackError(f'复位向量 0x{rst:08X} 不在 {which.upper()} 槽 '
|
||
f'[0x{slot.addr:08X},0x{slot.end:08X}) 内 — '
|
||
f'固件链接基址与槽位不符')
|
||
return sp, rst
|
||
|
||
|
||
def detect_slot(data, profile):
|
||
"""从向量表自动识别 bin 属于 A 包还是 B 包"""
|
||
sp, rst = read_vector(data)
|
||
if not (SRAM_LO <= sp <= SRAM_HI):
|
||
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 SRAM 范围, 无法识别 (不是 GD32E230 应用固件?)')
|
||
if not rst & 1:
|
||
raise FwPackError(f'复位向量 0x{rst:08X} 非 Thumb 地址, 无法识别')
|
||
target = rst & ~1
|
||
if profile.slot_a.addr <= target < profile.slot_a.end:
|
||
return 'a'
|
||
if profile.slot_b and profile.slot_b.addr <= target < profile.slot_b.end:
|
||
return 'b'
|
||
if profile.bl_addr <= target < profile.bl_end:
|
||
raise FwPackError('复位向量落在 BL 区 [0x%08X,0x%08X) — 传入的是 bootloader 或'
|
||
' 整片镜像, 请提供编译出的 APP bin' % (profile.bl_addr, profile.bl_end))
|
||
raise FwPackError(f'复位向量 0x{target:08X} 不落在任何槽区间, 无法识别 A/B 包')
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# A 包 (动态补页 + 末尾 trailer)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
class APackResult:
|
||
def __init__(self, data, body_len, pages, crc, record_bytes):
|
||
self.data = data # 整 N 页含 trailer
|
||
self.body_len, self.pages, self.crc = body_len, pages, crc
|
||
self.record_bytes = record_bytes
|
||
|
||
@property
|
||
def pack_len(self):
|
||
return len(self.data)
|
||
|
||
@property
|
||
def trailer_value(self):
|
||
"""trailer 按 u32 小端解释 (页数低16 | CRC 高16)"""
|
||
return struct.unpack('<I', self.record_bytes[:4])[0] if len(
|
||
self.record_bytes) >= 4 else None
|
||
|
||
|
||
def build_a_pack(data, profile, which='a'):
|
||
"""动态补页打包: 补 0xFF 至整 N 页, 末 R 字节写 {pages16|crc16}。
|
||
|
||
整页扩展规则: body 恰为整页倍数, 或 余量 < R → 向后扩一页;
|
||
扩展后须仍 ≤ 槽大小。CRC 范围 = N 页内容去掉末 R 字节(含 0xFF 填充)。
|
||
"""
|
||
slot = profile.slot(which)
|
||
if slot.fixed_slot:
|
||
raise FwPackError(f'{slot.name} 是固定满槽, 请按 B 包流程处理')
|
||
R = slot.record_area_len(profile.page_size)
|
||
body = bytes(data)
|
||
if not body:
|
||
raise FwPackError(f'{slot.name} 输入为空文件')
|
||
pages = (len(body) + profile.page_size - 1) // profile.page_size
|
||
if len(body) % profile.page_size == 0 or pages * profile.page_size - len(body) < R:
|
||
pages += 1 # 恰整页 / 记录放不下 → 扩一页
|
||
pack_len = pages * profile.page_size
|
||
if pack_len > slot.size:
|
||
raise FwPackError(f'{slot.name} 固件 {len(body)}B 需 {pages} 页({pack_len}B), '
|
||
f'超过槽容量 {slot.size}B({slot.size // profile.page_size} 页)')
|
||
buf = bytearray(body + b'\xFF' * (pack_len - len(body)))
|
||
crc = crc16_xmodem(bytes(buf[:pack_len - R]))
|
||
record = slot.record.pack(pages, crc, f'{slot.name}.record')
|
||
buf[pack_len - len(record):] = record
|
||
return APackResult(bytes(buf), len(body), pages, crc, record)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# B 包 (固定满槽 + 槽末记录)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
class BPackResult:
|
||
def __init__(self, data, body_len, pages, crc, record_bytes, record_addr):
|
||
self.data = data # 满槽镜像含记录
|
||
self.body_len, self.pages, self.crc = body_len, pages, crc
|
||
self.record_bytes, self.record_addr = record_bytes, record_addr
|
||
|
||
|
||
def build_b_pack(data, profile):
|
||
"""固定满槽打包: 补 0xFF 恰好填满槽, 槽末记录区写 {magic|pages|crc|保留FF}。
|
||
|
||
输入恰好等于槽大小 → 视为已含旧记录的满槽镜像, 末记录区原样丢弃重写;
|
||
固件 > 槽-R → 报错 (固定槽不可向后扩展)。CRC 范围 = 槽内容去掉记录区。
|
||
"""
|
||
slot = profile.slot_b
|
||
if slot is None:
|
||
raise FwPackError('当前 profile 无 slot_b')
|
||
R = slot.record_area_len(profile.page_size) # 记录区(含尾部保留字节)
|
||
body = bytes(data)
|
||
if not body:
|
||
raise FwPackError('slot_b 输入为空文件')
|
||
if len(body) == slot.size:
|
||
body = body[:slot.size - R] # 满槽镜像: 丢弃旧记录区
|
||
if len(body) > slot.size - R:
|
||
raise FwPackError(f'B 固件 {len(body)}B 超过固定槽容量 槽{slot.size}B-{R}B记录 '
|
||
f'= {slot.size - R}B (固定槽不可扩展, 请裁剪固件或加大槽)')
|
||
image = bytearray(body + b'\xFF' * (slot.size - len(body)))
|
||
crc = crc16_xmodem(bytes(image[:slot.size - R]))
|
||
pages = slot.size // profile.page_size
|
||
record = slot.record.pack(pages, crc, 'slot_b.record')
|
||
image[slot.size - R:slot.size - R + len(record)] = record
|
||
return BPackResult(bytes(image), len(body), pages, crc, record,
|
||
slot.end - R)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# FULL 整片镜像
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def build_full(profile, bl_data, a_pack, b_pack=None):
|
||
"""BL + A 包 + 槽尾锚点 + 空隙 0xFF + B 满槽 + flag → 整片镜像"""
|
||
image = bytearray(b'\xFF' * profile.flash_size)
|
||
if bl_data:
|
||
if len(bl_data) > profile.bl_size:
|
||
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
|
||
image[profile.bl_addr - profile.flash_base:
|
||
profile.bl_addr - profile.flash_base + len(bl_data)] = bl_data
|
||
sa = profile.slot_a
|
||
off = sa.addr - profile.flash_base
|
||
image[off:off + a_pack.pack_len] = a_pack.data
|
||
if sa.anchor_addr is not None and len(a_pack.record_bytes) >= 4:
|
||
anchor = a_pack.record_bytes[:4] # 锚点与 trailer 同值
|
||
image[sa.anchor_addr - profile.flash_base:
|
||
sa.anchor_addr - profile.flash_base + 4] = anchor
|
||
if b_pack is not None:
|
||
sb = profile.slot_b
|
||
off = sb.addr - profile.flash_base
|
||
image[off:off + sb.size] = b_pack.data
|
||
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
|
||
profile.flag_value)
|
||
return bytes(image)
|
||
|
||
|
||
def build_factory(profile, bl_data, b_pack):
|
||
"""工厂烧录镜像: BL + B 满槽(含记录) + flag, **无 A 包、无锚点**。
|
||
|
||
上电判定链(双固件 BL): A 锚点为擦除态(0xFFFF...) → legacy flag + A 向量校验
|
||
(A 槽全 0xFF → 向量非法) → 落 B 记录 {magic|pages|crc} 校验 → 跳出厂固件;
|
||
后续 OTA 写入 A 槽后即切运行固件。
|
||
"""
|
||
if b_pack is None:
|
||
raise FwPackError('FACTORY 镜像需要 B 底包')
|
||
image = bytearray(b'\xFF' * profile.flash_size)
|
||
if bl_data:
|
||
if len(bl_data) > profile.bl_size:
|
||
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
|
||
image[0:len(bl_data)] = bl_data
|
||
sb = profile.slot_b
|
||
off = sb.addr - profile.flash_base
|
||
image[off:off + sb.size] = b_pack.data
|
||
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
|
||
profile.flag_value)
|
||
return bytes(image)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 回读复核 (从产物字节独立重算, 与构建结果交叉验证)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def verify_a_blob(blob, profile, which='a'):
|
||
"""A 包复核: 页数/CRC/trailer 从文件字节重算; 返回 (pages, crc, trailer)"""
|
||
slot = profile.slot(which)
|
||
R = slot.record_area_len(profile.page_size)
|
||
if len(blob) <= R or len(blob) % profile.page_size:
|
||
raise FwPackError(f'A 包长度 {len(blob)}B 非法 (须为 >{R} 的整页倍数)')
|
||
pages = len(blob) // profile.page_size
|
||
crc = crc16_xmodem(blob[:-R])
|
||
expect = slot.record.pack(pages, crc)
|
||
if blob[-len(expect):] != expect:
|
||
raise FwPackError(f'A 包 trailer 复核失败: 文件 {blob[-len(expect):].hex(" ")} '
|
||
f'应为 {expect.hex(" ")} (pages={pages}, CRC=0x{crc:04X})')
|
||
return pages, crc, expect
|
||
|
||
|
||
def verify_b_blob(blob, profile):
|
||
"""B 包复核: 长度=满槽, 记录区从槽内容重算比对"""
|
||
slot = profile.slot_b
|
||
R = slot.record_area_len(profile.page_size)
|
||
if len(blob) != slot.size:
|
||
raise FwPackError(f'B 包长度 {len(blob)}B != 满槽 {slot.size}B')
|
||
crc = crc16_xmodem(blob[:slot.size - R])
|
||
expect = slot.record.pack(slot.size // profile.page_size, crc)
|
||
if blob[slot.size - R:slot.size - R + len(expect)] != expect:
|
||
raise FwPackError(f'B 包记录复核失败: 文件 '
|
||
f'{blob[slot.size - R:slot.size].hex(" ")} 应为 '
|
||
f'{expect.hex(" ")}')
|
||
return expect
|
||
|
||
|
||
def verify_full_blob(blob, profile, bl_data, a_body, b_body=None):
|
||
"""FULL 镜像逐项字节校验(规格 4.5: 分区内容/空隙/记录/flag, 17+ 项)。
|
||
全部从镜像字节独立重算, 与构建路径交叉验证。
|
||
返回 [(名称, 通过?, 说明)] 列表, 由调用方汇总展示。"""
|
||
base = profile.flash_base
|
||
sa = profile.slot_a
|
||
pg = profile.page_size
|
||
checks = []
|
||
|
||
def chk(name, ok, note=''):
|
||
checks.append((name, bool(ok), note))
|
||
|
||
# 1. 总长度
|
||
chk('总长度=flash_size', len(blob) == profile.flash_size,
|
||
f'{len(blob)}/{profile.flash_size}')
|
||
# 2-3. BL 区
|
||
if bl_data:
|
||
chk(f'BL 内容==输入({len(bl_data)}B)',
|
||
blob[:len(bl_data)] == bl_data)
|
||
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
|
||
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
|
||
else:
|
||
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
|
||
# 4-11. A 槽
|
||
a_off = sa.addr - base
|
||
R = sa.record_area_len(pg)
|
||
pages = max((len(a_body) + pg - 1) // pg, 1)
|
||
if len(a_body) % pg == 0 or pages * pg - len(a_body) < R:
|
||
pages += 1
|
||
pack_len = pages * pg
|
||
crc = crc16_xmodem(blob[a_off:a_off + pack_len - R])
|
||
trailer = sa.record.pack(pages, crc)
|
||
chk(f'A 体==输入固件({len(a_body)}B)', blob[a_off:a_off + len(a_body)] == a_body)
|
||
chk(f'A 补页 0xFF({pack_len - R - len(a_body)}B)',
|
||
all(b == 0xFF for b in blob[a_off + len(a_body):a_off + pack_len - R]))
|
||
_off = {f.name: f.offset for f in sa.record.fields}
|
||
rec_off = a_off + pack_len - R
|
||
t_pages = struct.unpack_from('<H', blob, rec_off + _off['pages'])[0]
|
||
chk(f'A trailer 页数=={pages}', t_pages == pages,
|
||
f'实为 {t_pages}')
|
||
t_crc = struct.unpack_from('<H', blob, rec_off + _off['crc'])[0]
|
||
chk(f'A trailer CRC==镜像重算(0x{crc:04X})', t_crc == crc, f'实为 0x{t_crc:04X}')
|
||
if sa.anchor_addr is not None:
|
||
chk(f'槽尾锚点==trailer @0x{sa.anchor_addr:08X}',
|
||
blob[sa.anchor_addr - base:sa.anchor_addr - base + 4]
|
||
== blob[a_off + pack_len - 4:a_off + pack_len])
|
||
chk('空隙(A 包尾~锚点)全 0xFF',
|
||
all(b == 0xFF for b in blob[a_off + pack_len:sa.anchor_addr - base]))
|
||
else:
|
||
chk('A 包尾~槽末全 0xFF',
|
||
all(b == 0xFF for b in blob[a_off + pack_len:sa.end - base]))
|
||
sp, rst = struct.unpack_from('<II', blob, a_off)
|
||
chk(f'A 向量表 SP∈SRAM(0x{sp:08X})', SRAM_LO <= sp <= SRAM_HI)
|
||
chk(f'A 复位向量∈A 槽(0x{rst:08X})', rst & 1 and sa.addr <= rst & ~1 < sa.end)
|
||
# 12-17. B 槽
|
||
if profile.slot_b:
|
||
sb = profile.slot_b
|
||
b_off = sb.addr - base
|
||
rb = sb.record_area_len(pg)
|
||
if b_body is not None:
|
||
body_n = min(len(b_body), sb.size - rb)
|
||
chk(f'B 体前缀==输入固件({body_n}B)',
|
||
blob[b_off:b_off + body_n] == b_body[:body_n])
|
||
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
|
||
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
|
||
rec_off = sb.end - rb - base
|
||
_bo = {f.name: f.offset for f in sb.record.fields}
|
||
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
|
||
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
|
||
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
|
||
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
|
||
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
|
||
f'实为 {m_pages}')
|
||
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
|
||
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc,
|
||
f'记录 0x{m_crc:04X}')
|
||
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
|
||
blob[rec_off + sb.record.length:rec_off + rb]
|
||
== b'\xFF' * (rb - sb.record.length))
|
||
else:
|
||
chk('B 槽全 0xFF(未提供底包)', all(b == 0xFF for b in blob[b_off:sb.end - base]))
|
||
# 18-19. flag
|
||
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
|
||
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
|
||
fp = profile.flag_page - base
|
||
chk('标志页其余字节 0xFF',
|
||
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
|
||
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
|
||
- profile.flag_addr - 4))
|
||
return checks
|
||
|
||
|
||
def verify_factory_blob(blob, profile, bl_data, b_body):
|
||
"""FACTORY 工厂镜像逐项字节校验: BL/B 记录/flag 在位, A 槽与锚点保持擦除态。
|
||
返回 [(名称, 通过?, 说明)] 列表。"""
|
||
base = profile.flash_base
|
||
sa, sb = profile.slot_a, profile.slot_b
|
||
pg = profile.page_size
|
||
checks = []
|
||
|
||
def chk(name, ok, note=''):
|
||
checks.append((name, bool(ok), note))
|
||
|
||
chk('总长度=flash_size', len(blob) == profile.flash_size,
|
||
f'{len(blob)}/{profile.flash_size}')
|
||
if bl_data:
|
||
chk(f'BL 内容==输入({len(bl_data)}B)', blob[:len(bl_data)] == bl_data)
|
||
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
|
||
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
|
||
else:
|
||
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
|
||
# A 槽必须整体擦除态: 无 A 包、无锚点 (判定链靠"锚点擦除+A 向量非法"落 B)
|
||
chk(f'A 槽全 0xFF·锚点擦除态({sa.size}B)',
|
||
all(b == 0xFF for b in blob[sa.addr - base:sa.end - base]))
|
||
# B 槽
|
||
b_off = sb.addr - base
|
||
rb = sb.record_area_len(pg)
|
||
body_n = min(len(b_body), sb.size - rb)
|
||
chk(f'B 体前缀==输入固件({body_n}B)',
|
||
blob[b_off:b_off + body_n] == b_body[:body_n])
|
||
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
|
||
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
|
||
rec_off = sb.end - rb - base
|
||
_bo = {f.name: f.offset for f in sb.record.fields}
|
||
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
|
||
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
|
||
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
|
||
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
|
||
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
|
||
f'实为 {m_pages}')
|
||
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
|
||
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc, f'记录 0x{m_crc:04X}')
|
||
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
|
||
blob[rec_off + sb.record.length:rec_off + rb]
|
||
== b'\xFF' * (rb - sb.record.length))
|
||
# flag
|
||
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
|
||
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
|
||
fp = profile.flag_page - base
|
||
chk('标志页其余字节 0xFF',
|
||
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
|
||
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
|
||
- profile.flag_addr - 4))
|
||
return checks
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 产物命名 / 一站式打包 (CLI 与 GUI 共用入口)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
_VER_RE = re.compile(r'^(?P<proj>.+?)_?V(?P<ver>\d+\.\d+\.\d+)', re.IGNORECASE)
|
||
_DATE_RE = re.compile(r'_(\d{4}-\d{2}-\d{2})$')
|
||
|
||
|
||
def suggest_full_name(a_path, b_path=None):
|
||
"""从输入名推导 FULL 镜像名: 项目前缀 + 双版本号 (+共同日期后缀)。"""
|
||
a_base = os.path.splitext(os.path.basename(a_path))[0]
|
||
date = _DATE_RE.search(a_base)
|
||
date_sfx = ('_' + date.group(1)) if date else ''
|
||
ma, mb = _VER_RE.match(a_base), None
|
||
if b_path:
|
||
mb = _VER_RE.match(os.path.splitext(os.path.basename(b_path))[0])
|
||
if ma and mb:
|
||
return f'{ma.group("proj")}_FULL_A-V{ma.group("ver")}_B-V{mb.group("ver")}{date_sfx}.bin'
|
||
return a_base + '_FULL' + '.bin'
|
||
|
||
|
||
def suggest_factory_name(b_path):
|
||
"""从 B 包名推导 FACTORY 镜像名: 项目前缀 + B 版本号 (+日期后缀)。"""
|
||
b_base = os.path.splitext(os.path.basename(b_path))[0]
|
||
date = _DATE_RE.search(b_base)
|
||
m = _VER_RE.match(b_base)
|
||
if m:
|
||
return (f'{m.group("proj")}_FACTORY_B-V{m.group("ver")}'
|
||
+ (f'_{date.group(1)}' if date else '') + '.bin')
|
||
return b_base + '_FACTORY.bin'
|
||
|
||
|
||
class PackOutcome:
|
||
def __init__(self):
|
||
self.paths = {} # update/full/factory/md5 -> 文件路径
|
||
self.summary = [] # 展示用文本行
|
||
self.checks = [] # FULL 逐项校验 (名称, ok, note)
|
||
self.factory_checks = [] # FACTORY 逐项校验
|
||
|
||
@property
|
||
def ok(self):
|
||
pools = self.checks + self.factory_checks
|
||
return all(ok for _n, ok, _t in pools) if pools else True
|
||
|
||
|
||
def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
|
||
make_full=True, make_factory=True, progress=print):
|
||
"""一站式打包: 自动识别/校验 → A包/FULL/FACTORY → 写盘 → 回读复核 → MD5 清单。
|
||
|
||
a_path 可为 None (仅 B 底包 → 只出 *_FACTORY.bin 工厂镜像, 不拼 FULL ——
|
||
没有 A 运行槽的整片镜像无法启动, 不允许生成)。progress: 每行输出的回调
|
||
(CLI 用 print, GUI 转日志)。返回 PackOutcome; 任何硬错误抛 FwPackError。
|
||
"""
|
||
self_test()
|
||
profile = load_profile(profile_spec)
|
||
out = PackOutcome()
|
||
log = []
|
||
|
||
def say(line=''):
|
||
log.append(line)
|
||
progress(line)
|
||
|
||
say(profile.describe())
|
||
|
||
# -- 读取与校验 --
|
||
a_body = None
|
||
if a_path is not None:
|
||
if not os.path.isfile(a_path):
|
||
raise FwPackError(f'找不到 A 包输入文件: {a_path!r}')
|
||
a_body = open(a_path, 'rb').read()
|
||
validate_app_bin(a_body, profile, 'a')
|
||
say(f'A 包输入: {os.path.basename(a_path)} ({len(a_body)}B) — '
|
||
f'向量表校验通过 (A 槽)')
|
||
b_body = None
|
||
if b_path:
|
||
if not os.path.isfile(b_path):
|
||
raise FwPackError(f'找不到 B 包输入文件: {b_path!r}')
|
||
b_body = open(b_path, 'rb').read()
|
||
validate_app_bin(b_body, profile, 'b')
|
||
say(f'B 包输入: {os.path.basename(b_path)} ({len(b_body)}B) — '
|
||
f'向量表校验通过 (B 槽)')
|
||
if a_body is None and b_body is None:
|
||
raise FwPackError('至少提供一个输入 bin (A 包或 B 包)')
|
||
|
||
bl_data = None
|
||
if bl_path:
|
||
if not os.path.isfile(bl_path):
|
||
raise FwPackError(f'找不到 BL 输入文件: {bl_path!r}')
|
||
bl_data = open(bl_path, 'rb').read()
|
||
if len(bl_data) > profile.bl_size:
|
||
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
|
||
sp, rst = read_vector(bl_data)
|
||
if not (SRAM_LO <= sp <= SRAM_HI and rst & 1
|
||
and profile.bl_addr <= rst & ~1 < profile.bl_end):
|
||
say(f'警告: BL 向量表异常 (SP=0x{sp:08X}, RST=0x{rst:08X}), 请确认这是 '
|
||
f'链接在 0x{profile.bl_addr:08X} 的 bootloader')
|
||
|
||
out_dir = out_dir or os.path.dirname(os.path.abspath(a_path or b_path))
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
a_base = os.path.splitext(os.path.basename(a_path))[0] if a_path else None
|
||
|
||
# -- A 包 --
|
||
ap = None
|
||
if a_body is not None:
|
||
ap = build_a_pack(a_body, profile)
|
||
upd_path = os.path.join(out_dir, a_base + '_UPDATE.bin')
|
||
with open(upd_path, 'wb') as f:
|
||
f.write(ap.data)
|
||
out.paths['update'] = upd_path
|
||
vp, vcrc, _rec = verify_a_blob(open(upd_path, 'rb').read(), profile)
|
||
if (vp, vcrc) != (ap.pages, ap.crc):
|
||
raise FwPackError('A 包回读复核失败')
|
||
say(f'A 包: {len(a_body)}B -> {ap.pages} 页({ap.pack_len}B) '
|
||
f'trailer=0x{ap.trailer_value:08X} (CRC 0x{ap.crc:04X}) — 回读复核通过')
|
||
say(f' {upd_path}')
|
||
|
||
# -- B 包 (仅作为镜像组成, 不再单独落盘) --
|
||
bp = None
|
||
if b_body is not None:
|
||
bp = build_b_pack(b_body, profile)
|
||
verify_b_blob(bp.data, profile)
|
||
area = profile.slot_b.record_area_len(profile.page_size)
|
||
rec_disp = (bp.record_bytes + b'\xFF' * (area - len(bp.record_bytes))) \
|
||
.hex(' ').upper()
|
||
say(f'B 包: 满槽 {bp.pages} 页({len(bp.data)}B) 记录 {rec_disp} '
|
||
f'(CRC 0x{bp.crc:04X}) — 复核通过')
|
||
elif profile.slot_b and make_full and a_body is not None:
|
||
say('提示: 未提供 B 底包, FULL 镜像中 B 槽保持 0xFF (擦除态)')
|
||
|
||
# -- FULL (A 运行槽是必需的; 仅 B 输入时不拼 FULL) --
|
||
if make_full and a_body is not None:
|
||
if bl_data is None:
|
||
say('警告: 未提供 BL bin, FULL 镜像 BL 区为 0xFF — 该镜像烧录后设备'
|
||
' 无法启动, 仅用于槽区预烧/分析!')
|
||
full_name = suggest_full_name(a_path, b_path)
|
||
full_path = os.path.join(out_dir, full_name)
|
||
image = build_full(profile, bl_data, ap, bp)
|
||
with open(full_path, 'wb') as f:
|
||
f.write(image)
|
||
out.paths['full'] = full_path
|
||
blob = open(full_path, 'rb').read()
|
||
out.checks = verify_full_blob(blob, profile, bl_data, a_body,
|
||
b_body if b_body is not None else None)
|
||
bad = [n for n, ok, _t in out.checks if not ok]
|
||
if bad:
|
||
raise FwPackError('FULL 镜像复核未通过: ' + '; '.join(bad))
|
||
say(f'FULL: {len(image)}B — {len(out.checks)} 项字节校验全部通过')
|
||
for n, ok, note in out.checks:
|
||
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
|
||
say(f' {full_path}')
|
||
|
||
# -- FACTORY (工厂烧录镜像: BL + B 满槽 + flag, 无 A; 需 B 底包) --
|
||
if make_factory and b_body is not None:
|
||
if bl_data is None:
|
||
say('警告: 未提供 BL bin, FACTORY 镜像 BL 区为 0xFF — 工厂烧录镜像'
|
||
' 必须带 BL, 请用 -b 提供!')
|
||
fac_name = suggest_factory_name(b_path)
|
||
fac_path = os.path.join(out_dir, fac_name)
|
||
fac_image = build_factory(profile, bl_data, bp)
|
||
with open(fac_path, 'wb') as f:
|
||
f.write(fac_image)
|
||
out.paths['factory'] = fac_path
|
||
fac_blob = open(fac_path, 'rb').read()
|
||
out.factory_checks = verify_factory_blob(fac_blob, profile, bl_data, b_body)
|
||
bad = [n for n, ok, _t in out.factory_checks if not ok]
|
||
if bad:
|
||
raise FwPackError('FACTORY 镜像复核未通过: ' + '; '.join(bad))
|
||
say(f'FACTORY: {len(fac_image)}B — {len(out.factory_checks)} 项字节校验'
|
||
f'全部通过 (A 槽保持擦除态, 上电跳 B)')
|
||
for n, ok, note in out.factory_checks:
|
||
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
|
||
say(f' {fac_path}')
|
||
|
||
# -- MD5 清单 (挂在 FULL, 无 FULL 则挂在 FACTORY) --
|
||
anchor_path = out.paths.get('full') or out.paths.get('factory')
|
||
if anchor_path:
|
||
md5_path = anchor_path + '.md5.txt'
|
||
with open(md5_path, 'w', encoding='utf-8', newline='\n') as f:
|
||
f.write(f'# 布局摘要 — gd32e230_firmware_merge_tool v{TOOL_VERSION}\n')
|
||
f.write('# ' + profile.describe().replace('\n', '\n# ') + '\n')
|
||
f.write(f'# 生成: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n')
|
||
if ap:
|
||
f.write('# A 包: %s -> %d 页, trailer=0x%08X (CRC 0x%04X)\n'
|
||
% (os.path.basename(a_path), ap.pages,
|
||
ap.trailer_value, ap.crc))
|
||
if bp:
|
||
f.write('# B 包: %s -> 满槽 %d 页, 记录 %s (CRC 0x%04X)\n'
|
||
% (os.path.basename(b_path), bp.pages,
|
||
(bp.record_bytes + b'\xFF'
|
||
* (profile.slot_b.record_area_len(profile.page_size)
|
||
- len(bp.record_bytes))).hex(' ').upper(), bp.crc))
|
||
f.write('#\n')
|
||
for _k, p in out.paths.items():
|
||
if _k == 'md5':
|
||
continue
|
||
data = open(p, 'rb').read()
|
||
f.write('%s %8dB %s\n' % (hashlib.md5(data).hexdigest(),
|
||
len(data), os.path.basename(p)))
|
||
out.paths['md5'] = md5_path
|
||
say(f'MD5 清单: {md5_path}')
|
||
|
||
out.summary = log
|
||
return out
|