#!/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.2' # 2.2: 新增内置 profile CeramixNano_EDDY_Dual_FW # 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'^(?Pmagic|pages|crc)(?P16|32)?@(?P\+\d+)' r'(?:=(?P0x[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(' 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('= 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(' 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('{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('.+?)_?V(?P\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