feat(pack): FACTORY 工厂镜像 + 内置 profile 改名 CeramixNano_MB_Dual_FW

This commit is contained in:
2026-10-22 05:14:58 +08:00
parent 0ffa671656
commit 0a4095150b
10 changed files with 350 additions and 114 deletions
+150 -38
View File
@@ -4,10 +4,11 @@
fw_pack — GD32E230 双固件打包公共库 (CLI 与 FlashTool GUI 共用)
================================================================
输入编译好的 APP bin, 按 layout profile 补页 / 算 CRC / 写记录, 产出:
*_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {pages16|crc16})
*_B.bin B 槽底包 (固定满槽 + 槽末 8B 记录 {magic16|pages16|crc16|2B FF})
*_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志)
*.md5.txt 产物 MD5 清单 + 布局摘要
*_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 清单 + 布局摘要
规则与 BL dev_fact 侧 (v5.1) 逐字一致:
- CRC-16/XMODEM (poly 0x1021, init 0x0000, 不反转, 无 xorout),
@@ -32,12 +33,12 @@ __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',
'verify_a_blob', 'verify_b_blob', 'verify_full_blob',
'pack_all', 'suggest_full_name',
'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.0'
TOOL_VERSION = '2.1'
# GD32E230 家族 SRAM: F8 最大 8K, SP 必须落在其中 (F4/F6 的 4K 也在范围内)
SRAM_LO, SRAM_HI = 0x20000000, 0x20002000
@@ -196,9 +197,9 @@ class SlotSpec:
# ═══════════════════════════════════════════════════════════
BUILTIN_PROFILES = {
# CeramixNano-MB v5.1 双固件布局 (BL dev_fact@512bf7a)
'ceramixnano_mb_v51': {
'profile': 'ceramixnano_mb_v51',
# CeramixNano-MB v5.1 双固件 BL 的布局 (BL dev_fact@512bf7a)
'CeramixNano_MB_Dual_FW': {
'profile': 'CeramixNano_MB_Dual_FW',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
@@ -378,9 +379,9 @@ class Profile:
def load_profile(spec):
"""内置名(ceramixnano_mb_v51 / f8_single_slot) 或 profile JSON 文件路径"""
"""内置名(CeramixNano_MB_Dual_FW / f8_single_slot) 或 profile JSON 文件路径"""
if not spec:
spec = 'ceramixnano_mb_v51'
spec = 'CeramixNano_MB_Dual_FW'
if spec in BUILTIN_PROFILES:
return Profile(dict(BUILTIN_PROFILES[spec]))
if os.path.isfile(spec):
@@ -551,6 +552,28 @@ def build_full(profile, bl_data, a_pack, b_pack=None):
return bytes(image)
def build_factory(profile, bl_data, b_pack):
"""工厂烧录镜像: BL + B 满槽(含记录) + flag, **无 A 包、无锚点**。
上电判定链(v5.1 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)
# ═══════════════════════════════════════════════════════════
# 回读复核 (从产物字节独立重算, 与构建结果交叉验证)
# ═══════════════════════════════════════════════════════════
@@ -677,6 +700,60 @@ def verify_full_blob(blob, profile, bl_data, a_body, b_body=None):
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 共用入口)
# ═══════════════════════════════════════════════════════════
@@ -698,24 +775,37 @@ def suggest_full_name(a_path, b_path=None):
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/b/full/md5 -> 文件路径
self.paths = {} # update/full/factory/md5 -> 文件路径
self.summary = [] # 展示用文本行
self.checks = [] # FULL 逐项校验 (名称, ok, note)
self.factory_checks = [] # FACTORY 逐项校验
@property
def ok(self):
return all(ok for _n, ok, _t in self.checks) if self.checks else True
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, progress=print):
"""一站式打包: 自动识别/校验 → A包/B包/FULL → 写盘 → 回读复核 → MD5 清单。
make_full=True, make_factory=True, progress=print):
"""一站式打包: 自动识别/校验 → A包/FULL/FACTORY → 写盘 → 回读复核 → MD5 清单。
a_path 可为 None (仅 B 底包 → 只出 *_B.bin, 不拼 FULL —— 没有 A 运行槽的
整片镜像无法启动, 不允许生成)。progress: 每行输出的回调(CLI 用 print,
GUI 转日志)。返回 PackOutcome; 任何硬错误抛 FwPackError。
a_path 可为 None (仅 B 底包 → 只出 *_FACTORY.bin 工厂镜像, 不拼 FULL ——
没有 A 运行槽的整片镜像无法启动, 不允许生成)。progress: 每行输出的回调
(CLI 用 print, GUI 转日志)。返回 PackOutcome; 任何硬错误抛 FwPackError。
"""
self_test()
profile = load_profile(profile_spec)
@@ -764,7 +854,6 @@ def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
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
b_base = os.path.splitext(os.path.basename(b_path))[0] if b_path else None
# -- A 包 --
ap = None
@@ -781,21 +870,16 @@ def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
f'trailer=0x{ap.trailer_value:08X} (CRC 0x{ap.crc:04X}) — 回读复核通过')
say(f' {upd_path}')
# -- B 包 --
# -- B 包 (仅作为镜像组成, 不再单独落盘) --
bp = None
if b_body is not None:
bp = build_b_pack(b_body, profile)
b_out = os.path.join(out_dir, b_base + '_B.bin')
with open(b_out, 'wb') as f:
f.write(bp.data)
out.paths['b'] = b_out
verify_b_blob(open(b_out, 'rb').read(), 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}) — 回读复核通过')
say(f' {b_out}')
f'(CRC 0x{bp.crc:04X}) — 复核通过')
elif profile.slot_b and make_full and a_body is not None:
say('提示: 未提供 B 底包, FULL 镜像中 B 槽保持 0xFF (擦除态)')
@@ -816,27 +900,55 @@ def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
bad = [n for n, ok, _t in out.checks if not ok]
if bad:
raise FwPackError('FULL 镜像复核未通过: ' + '; '.join(bad))
n_ok = len(out.checks)
say(f'FULL: {len(image)}B — {n_ok} 项字节校验全部通过')
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}')
# MD5 清单
md5_path = full_path + '.md5.txt'
products = [(k, p) for k, p in out.paths.items()]
# -- 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')
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 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.hex(' ').upper(), bp.crc))
(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 products:
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)))