From 0a4095150be3ac68f5d7418bfb512f9302121430 Mon Sep 17 00:00:00 2001 From: hulk Date: Thu, 22 Oct 2026 05:14:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(pack):=20FACTORY=20=E5=B7=A5=E5=8E=82?= =?UTF-8?q?=E9=95=9C=E5=83=8F=20+=20=E5=86=85=E7=BD=AE=20profile=20?= =?UTF-8?q?=E6=94=B9=E5=90=8D=20CeramixNano=5FMB=5FDual=5FFW?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 28 ++++-- FlashTool/README.md | 15 +-- FlashTool/src/app_gui.py | 11 ++- FlashTool/src/config.py | 8 +- FlashTool/src/flash_gui.py | 15 ++- README.md | 34 ++++--- UPGRADE_PLAN_v2.md | 5 + fw_pack.py | 188 +++++++++++++++++++++++++++++-------- gd32e230_firmware_tool.py | 40 ++++---- test_fw_pack.py | 120 +++++++++++++++++++---- 10 files changed, 350 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 768c4ab..3fa7f38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,8 @@ GD32E230 系列固件处理与烧录工具仓库。本文件为项目标杆文 - **项目结构**: - 根目录:`gd32e230_firmware_tool.py` 命令行固件工具 v2(默认双固件 profile 打包;`-f`/`-m` 触发 legacy 单槽模式),`fw_pack.py` 打包公共库(CLI 与 GUI - 共用的单一真相:profile 解析/校验/CRC/A 包/B 包/FULL/回读复核/MD5 清单), - `test_fw_pack.py` 回归测试套件(49 项,含 CeramixNano 实测基准); + 共用的单一真相:profile 解析/校验/CRC/A 包/B 包/FULL/FACTORY/回读复核/ + MD5 清单),`test_fw_pack.py` 回归测试套件(55 项,含 CeramixNano 实测基准); `merge_bin.py`/`fill_bin.py` 旧版脚本保留兼容, `gd32e230f8_bootloader_hulk.bin` 出厂 bootloader; - `FlashTool/`:DAPLink Flash GUI v2.6.0(烧录 + 打包 v2 + 补齐 Pad + 拼接 @@ -30,7 +30,11 @@ GD32E230 系列固件处理与烧录工具仓库。本文件为项目标杆文 CRC-16/XMODEM(自检向量 `"123456789"→0x31C3`);A 包补 0xFF 至整 N 页 + 末 4B trailer{页数16|CRC16},CRC 范围=去末 4B,恰整页或余量<4B 扩 1 页; B 包固定满槽 + 槽末 8B 记录{magic=0xC0DE|页数|CRC|2B FF},CRC 范围=去末 8B, - 固件>槽-8B 报错;FULL=BL+A 包+槽尾锚点(0x0800AFFC)+B 满槽+0xEEEE@0x0800FFFC。 + 固件>槽-8B 报错;FULL=BL+A 包+槽尾锚点(0x0800AFFC)+B 满槽+0xEEEE@0x0800FFFC; + FACTORY=BL+B 满槽+flag **无 A**(A 槽整体擦除态,上电判定链落 B 跳出厂固件), + 12 项字节校验;不再单独落盘 B 包文件(B 内容已含于 FULL/FACTORY)。 + 默认 profile 名为 `CeramixNano_MB_Dual_FW`(旧名 `ceramixnano_mb_v51` 已 + 废弃不留别名,GUI 旧配置自动迁移)。 回归基准(CeramixNano 实测):1.0.17→20 页 trailer=0xD30F0014;1.0.18→ 0x6F380014;V1.0.12 B→记录 `DE C0 13 00 6F 18 FF FF`;FULL 镜像 MD5 `f704b837...` 逐字节复现。**改 `fw_pack.py` 后必须 @@ -56,14 +60,15 @@ GD32E230 系列固件处理与烧录工具仓库。本文件为项目标杆文 cfgs/ 自定义 cfg 机制、全操作 SRST 连接序列、整包 0.11 工具链、 Gitea Release 发布流水线; - 2026-10-22 **v2 彻底升级落地**(`UPGRADE_PLAN_v2.md` 全部实现并自验收): - - `fw_pack.py` 公共库:布局 profile(内置 `ceramixnano_mb_v51` / + - `fw_pack.py` 公共库:布局 profile(内置 `CeramixNano_MB_Dual_FW` / `f8_single_slot` + JSON 自定义,完整性校验)、向量表自动识别 A/B 包 (显式指定冲突报错)、A 包动态补页+trailer+整页扩展、B 包固定满槽+记录、 FULL 整片镜像+槽尾锚点+flag、CRC-16/XMODEM 启动自检、写盘回读复核、 19 项 FULL 字节校验、MD5 清单+布局摘要、FULL 自动命名(项目/双版本/日期); - `gd32e230_firmware_tool.py` v2.0.0 重构:默认 v2 模式(位置参数自动识别、 - `--a/--b/--bl/--profile/-o 目录/--no-full`),`-f/-m` 保留 legacy 行为不变; - - `test_fw_pack.py`:49 项回归全绿——规格第五节基准逐项复现(1.0.17 UPDATE + `--a/--b/--bl/--profile/-o 目录/--no-full/--no-factory`),`-f/-m` 保留 + legacy 行为不变; + - `test_fw_pack.py` 回归全绿(55 项)——规格第五节基准逐项复现(1.0.17 UPDATE 与验收包逐字节一致、FULL MD5 `f704b837...` 复现、恰整页/余量<4 扩页、 LDC1612 legacy update.bin 复现)、变体 profile(B 8K/28K/无)、错误路径、 CLI 冒烟; @@ -72,6 +77,17 @@ GD32E230 系列固件处理与烧录工具仓库。本文件为项目标杆文 `build_exe.py` 以 `--paths+hiddenimport` 收录根目录 `fw_pack.py`; - `make_v5_update.py` 已删除(能力并入 `fw_pack.py`);README(根 + FlashTool) 已重写;尚未发布 GUI 新版 zip(待下次 `build_exe.py`→`package_release.py`)。 +- 2026-10-22 **FACTORY 工厂镜像改造 + profile 改名**(上个会话收尾、本会话续完): + - `fw_pack.py`:新增 `build_factory`/`verify_factory_blob`/`suggest_factory_name` + (BL+B 满槽+flag、无 A、12 项字节校验),`pack_all` 产物改为 UPDATE/FULL/ + FACTORY/MD5,**不再单独落盘 B 包文件**;仅 B 输入时只出 FACTORY(不拼 FULL); + - CLI:根工具与 `flash_gui --pack` 均加 `--no-factory`(flash_gui 侧为 + `--pack-no-factory`),GUI 弹窗文案/结果映射同步; + - 内置 profile 改名 `ceramixnano_mb_v51` → `CeramixNano_MB_Dual_FW`(旧名 + 废弃不留别名;CLI/GUI 默认值、config 默认+旧配置迁移、测试、文档全量同步); + - 参数拼接审计:`flash_gui --pack -o` 加目录护栏(拒绝 .bin 路径,对齐根工具), + 全链路冒烟(GUI 弹窗/CLI/命名拼接)通过;`bootloader_disasm*.txt` 为分析 + 副产物留在工作区未入库。 ### 计划 / TODO diff --git a/FlashTool/README.md b/FlashTool/README.md index 6fc5399..45e96f2 100644 --- a/FlashTool/README.md +++ b/FlashTool/README.md @@ -1,8 +1,8 @@ # DAPLink Flash — GD32 固件烧录 · 双固件打包 · 补齐 · 拼接 > GD32E230 系列的桌面烧录/固件处理工具:**OpenOCD 烧录**(DAPLink / GD-Link / -> ST-Link / J-Link)+ **打包 v2**(双固件 profile 布局 → UPDATE / B / FULL 镜像 -> + MD5 清单,与命令行工具共用 `fw_pack.py` 公共库)+ **补齐 Pad**(Y-Modem +> ST-Link / J-Link)+ **打包 v2**(双固件 profile 布局 → UPDATE / FULL / FACTORY +> 镜像 + MD5 清单,与命令行工具共用 `fw_pack.py` 公共库)+ **补齐 Pad**(Y-Modem > 页对齐)+ **拼接 Merge**(Bootloader + APP + 有效标志 → 整片镜像)。 > > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases) @@ -21,7 +21,7 @@ | 固件格式 | BIN / HEX / ELF——HEX/ELF 自动用文件内地址,仅 BIN 需填基地址(完整镜像 `0x08000000`,单独 APP `0x08002000`) | | 芯片型号 | GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小与 target 配置 | | 烧录器 | DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP(TCP)、Custom;支持适配器速度与探针序列号 | -| 打包 v2 | 双固件 profile 布局(默认 `ceramixnano_mb_v51`):A/B 包向量表自动识别、补页+trailer/满槽+记录、CRC-16/XMODEM、FULL 整片镜像、MD5 清单、写盘回读复核 | +| 打包 v2 | 双固件 profile 布局(默认 `CeramixNano_MB_Dual_FW`):A/B 包向量表自动识别、补页+trailer/满槽+记录、CRC-16/XMODEM、FULL 整片镜像、FACTORY 工厂镜像(BL+B+flag,上电跳 B)、MD5 清单、写盘回读复核 | | 补齐 Pad | BIN 尾部补 `0xFF` 到 1K/2K/4K 页边界;bootloader 的 Y-Modem 只按 1K 整包写 Flash,最后一包不满必须先补齐 | | 拼接 Merge | Bootloader + APP + 有效标志(`0xEEEEEEEE` @`0x0800FFFC`) → 整片 Flash 镜像,逻辑与 `merge_bin.py` 一致 | | 内置工具链 | 发布包自带 OpenOCD(`toolchain/openocd/`),GUI 自动定位,免安装免配置 | @@ -79,13 +79,14 @@ DAPLinkFlash/ 4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区; 5. **固件工具**(顶栏按钮弹出的独立窗口,非模态): - 打包 v2:选 profile(内置下拉或浏览 JSON)+ BL/A 包/B 包 bin(至少一个)+ - 输出目录,点"执行打包"→ `*_UPDATE.bin` / `*_B.bin` / `*_FULL.bin` + MD5 清单; + 输出目录,点"执行打包"→ `*_UPDATE.bin`(A 包)/ `*_FACTORY.bin`(工厂镜像 + BL+B+flag,给了 B 包才出)/ `*_FULL.bin`(整片镜像,需 A 包)+ MD5 清单; A/B 槽按向量表自动校验,逐项字节校验与回读复核结果流进主日志; - 补齐:选输入 BIN 后输出名自动带出(`*_update.bin`,Y-Modem 升级包),对齐默认 1K,点"执行补齐"; - 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`,整片镜像),地址按典型布局 预填,点"执行拼接"。 -v2 双固件布局(默认 profile `ceramixnano_mb_v51`)与打包规则详见仓库根 +v2 双固件布局(默认 profile `CeramixNano_MB_Dual_FW`)与打包规则详见仓库根 [README.md](../README.md);legacy 单槽布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC` 有效标志 `0xEEEEEEEE`。 @@ -109,10 +110,10 @@ python src/flash_gui.py --reset 复位运行 python src/flash_gui.py --pad app.bin 补齐到 1K → app_pad.bin python src/flash_gui.py --merge bl.bin app.bin 拼接 → app_BL.bin python src/flash_gui.py --pack a.bin b.bin --pack-bl bl.bin -o outdir - v2 双固件打包 → UPDATE/B/FULL+MD5 + v2 双固件打包 → UPDATE/FULL/FACTORY+MD5 --align N / --pad-byte 0xFF / -o OUT pad 参数(-o 对 pack 为目录) --app-addr / --flag-addr / --flash-size / --flag-value merge 参数 - --pack-profile ceramixnano_mb_v51|JSON / --pack-no-full pack 参数 + --pack-profile 名字|JSON / --pack-no-full / --pack-no-factory pack 参数 --speed 4000 / --serial SN / --interface cfg / --target cfg --openocd PATH / --scripts DIR 手动指定工具链(默认自动定位) ``` diff --git a/FlashTool/src/app_gui.py b/FlashTool/src/app_gui.py index 89ba9b4..6004603 100644 --- a/FlashTool/src/app_gui.py +++ b/FlashTool/src/app_gui.py @@ -317,7 +317,7 @@ class App(ctk.CTk): box = ctk.CTkFrame(dlg, fg_color='transparent') box.pack(fill='both', expand=True, padx=18, pady=(14, 12)) ctk.CTkLabel(box, text='打包 (v2 双固件)', font=BOLD, anchor='w').pack(fill='x') - ctk.CTkLabel(box, text='按 profile 布局自动识别 A/B 槽, 产出 UPDATE / B / FULL + MD5 清单', + ctk.CTkLabel(box, text='按 profile 布局自动识别 A/B 槽, 产出 UPDATE / FULL / FACTORY + MD5 清单', text_color='gray60', anchor='w').pack(fill='x', pady=(0, 6)) row = ctk.CTkFrame(box, fg_color='transparent') row.pack(fill='x', pady=(4, 0)) @@ -339,8 +339,8 @@ class App(ctk.CTk): command=self._on_pack).pack(side='right') ctk.CTkLabel(box, textvariable=self.v_pack_result, text_color=OK_BLUE, justify='left', anchor='w').pack(fill='x', pady=(8, 0)) - ctk.CTkLabel(box, text='A 包→*_UPDATE.bin(OA/Ymodem) B 包→*_B.bin(满槽+记录) ' - 'FULL→整片镜像(@0x08000000)+*.md5.txt', + ctk.CTkLabel(box, text='A 包→*_UPDATE.bin(OTA/Ymodem) B 包→*_FACTORY.bin(工厂' + ' BL+B+flag, 上电跳 B) FULL→整片镜像(@0x08000000)', text_color='gray50', anchor='w').pack(fill='x', pady=(2, 0)) self._center_dialog(dlg, 720, 400) @@ -739,7 +739,8 @@ class App(ctk.CTk): messagebox.showerror('打包失败', str(exc)) self._log('打包失败: %s' % exc) return - names = {'update': 'UPDATE', 'b': 'B 包', 'full': 'FULL', 'md5': 'MD5 清单'} + names = {'update': 'UPDATE', 'full': 'FULL', 'factory': 'FACTORY', + 'md5': 'MD5 清单'} self.v_pack_result.set('完成: ' + ', '.join( f"{names[k]} {os.path.basename(p)}" for k, p in outcome.paths.items())) self._log('打包完成: %s' % ', '.join(outcome.paths.values())) @@ -847,7 +848,7 @@ class App(ctk.CTk): ctk.CTkLabel(box, text=f'v{APP_VERSION} — GD32E230 固件烧录与打包工具', text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12)) for line in ('OpenOCD 烧录: 检测 / 烧录 / 擦除 / 读取 / 复位 (DAPLink·GD-Link·ST-Link·J-Link)', - '打包 v2: 双固件 profile 布局 → UPDATE / B / FULL 镜像 + MD5 清单', + '打包 v2: 双固件 profile 布局 → UPDATE / FACTORY / FULL 镜像 + MD5 清单', '补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem', '拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像', '发布包内置 OpenOCD 工具链, 免安装解压即用', diff --git a/FlashTool/src/config.py b/FlashTool/src/config.py index 108270a..899babb 100644 --- a/FlashTool/src/config.py +++ b/FlashTool/src/config.py @@ -57,7 +57,7 @@ DEFAULTS = { "merge_flash": "0x10000", "flag_value": "0xEEEEEEEE", # 打包 (v2 双固件, fw_pack) - "pack_profile": "ceramixnano_mb_v51", + "pack_profile": "CeramixNano_MB_Dual_FW", "pack_bl": "", "pack_a": "", "pack_b": "", @@ -80,6 +80,10 @@ def _legacy_settings(): return path +# 旧内置 profile 名 → 现名 (改名前保存的配置一次性迁移, 旧名不再受支持) +_RENAMED_PROFILE = {"ceramixnano_mb_v51": "CeramixNano_MB_Dual_FW"} + + def load_settings(): """读配置, 带默认值兜底; 新配置不存在时尝试从旧版 per-host 配置迁移""" data = dict(DEFAULTS) @@ -97,6 +101,8 @@ def load_settings(): for key in DEFAULTS: if key in stored: data[key] = stored[key] + if data.get("pack_profile") in _RENAMED_PROFILE: + data["pack_profile"] = _RENAMED_PROFILE[data["pack_profile"]] return data diff --git a/FlashTool/src/flash_gui.py b/FlashTool/src/flash_gui.py index 1e26e91..67006a0 100644 --- a/FlashTool/src/flash_gui.py +++ b/FlashTool/src/flash_gui.py @@ -27,7 +27,7 @@ DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 — 入口与命 python flash_gui.py --pad app.bin 补齐到 1K 边界(app_pad.bin) python flash_gui.py --merge bl.bin app.bin 拼接整片镜像(*_BL.bin) python flash_gui.py --pack a.bin b.bin --pack-bl bl.bin - v2 双固件打包(UPDATE/B/FULL+MD5) + v2 双固件打包(UPDATE/FULL/FACTORY+MD5) python flash_gui.py --version 打印 OpenOCD 版本 """ @@ -153,10 +153,13 @@ def run_pack(args): a, b = found.get('a'), found.get('b') if not a and not b: sys.exit('未提供可识别的固件输入 (A 包或 B 包)') + if args.output and os.path.splitext(args.output)[1].lower() == '.bin': + sys.exit('--pack 的 -o 是输出目录(产物自动命名), 不能是 .bin 文件路径') try: fw_pack.pack_all(args.pack_profile, a, b_path=b, bl_path=args.pack_bl or None, out_dir=args.output, - make_full=not args.pack_no_full) + make_full=not args.pack_no_full, + make_factory=not args.pack_no_factory) except fw_pack.FwPackError as exc: sys.exit(f'打包失败: {exc}') return 0 @@ -187,13 +190,15 @@ def main(): op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'), help='拼接 Bootloader + APP → 整片镜像') op.add_argument('--pack', nargs='+', metavar='BIN', - help='v2 双固件打包: A/B 包自动识别 → UPDATE/B/FULL+MD5') - ap.add_argument('--pack-profile', default='ceramixnano_mb_v51', + help='v2 双固件打包: A/B 包自动识别 → UPDATE/FULL/FACTORY+MD5') + ap.add_argument('--pack-profile', default='CeramixNano_MB_Dual_FW', help='--pack 布局 profile(内置名或 JSON 路径)') ap.add_argument('--pack-bl', default=None, metavar='BIN', help='--pack 使用的 Bootloader bin') ap.add_argument('--pack-no-full', action='store_true', - help='--pack 只出 UPDATE/B 包, 不拼 FULL') + help='--pack 不拼 FULL 整片镜像') + ap.add_argument('--pack-no-factory', action='store_true', + help='--pack 不出 FACTORY 工厂镜像') ap.add_argument('--align', default='1024', help='--pad 对齐字节(默认 1024)') ap.add_argument('--pad-byte', default='0xFF', help='--pad 填充字节(默认 0xFF)') ap.add_argument('-o', '--output', diff --git a/README.md b/README.md index 1495bd2..2ec1690 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## v2 双固件打包 -### 布局(默认 profile `ceramixnano_mb_v51`,GD32E230F8/64K) +### 布局(默认 profile `CeramixNano_MB_Dual_FW`,GD32E230F8/64K) ```text 0x08000000 ┌──────────────────┐ @@ -42,40 +42,43 @@ ### 快速开始 ```bash -# 全产物: A 包 → *_UPDATE.bin, B 包 → *_B.bin, 整片 → *_FULL.bin + *.md5.txt +# 全产物: A 包 → *_UPDATE.bin, 整片 → *_FULL.bin, 工厂镜像 → *_FACTORY.bin (+*.md5.txt) python gd32e230_firmware_tool.py app_a.bin app_b.bin -b bootloader.bin # 只打 A 包(OTA 升级包) -python gd32e230_firmware_tool.py app_a.bin +python gd32e230_firmware_tool.py app_a.bin --no-full --no-factory -# 仅 B 底包(产线备用,不拼 FULL) -python gd32e230_firmware_tool.py app_b.bin +# 只打工厂烧录镜像(BL + B 满槽 + flag, 上电跳 B, 后续 OTA 写 A) +python gd32e230_firmware_tool.py app_b.bin -b bootloader.bin ``` 输入 bin 从**向量表自动识别** A/B 包(初始 SP 须在 SRAM `[0x20000000,0x20002000]`、 复位向量 Thumb 且落在对应槽区间);也可 `--a`/`--b` 显式指定,与向量表判定冲突时报错。 -写盘后回读复核(重算 trailer/记录/锚点一致才报成功),并输出布局摘要 + 19 项字节校验。 +写盘后回读复核(重算 trailer/记录/锚点一致才报成功),并输出布局摘要与逐项字节校验 +(FULL 19 项 / FACTORY 12 项)。 ### 产物 | 产物 | 用途 | |---|---| | `*_UPDATE.bin` | A 包(补页+trailer),Ymodem/OTA 推送 | -| `*_B.bin` | B 底包(满槽+记录),产线单独烧 B 槽备用 | -| `*_FULL.bin` | 整片镜像,`@0x08000000` 整片烧录(底包) | -| `*.md5.txt` | 全部产物 MD5 清单 + 布局摘要 | +| `*_FULL.bin` | 整片镜像(BL + A + B + flag),`@0x08000000` 烧录后直接运行 A(开发/验收底包) | +| `*_FACTORY.bin` | **工厂烧录镜像**(BL + B 满槽 + flag,**无 A 内容、锚点擦除态**),上电判定链落 B 跳出厂固件,之后 OTA 写 A | +| `*.md5.txt` | 全部产物 MD5 清单 + 布局摘要(挂在 FULL,无 FULL 挂 FACTORY) | -FULL 镜像自动命名 `<项目>_FULL_A-V<版本>_B-V<版本>[_日期].bin`(从输入文件名推导)。 +命名从输入文件名推导:FULL 为 `<项目>_FULL_A-V<版本>_B-V<版本>[_日期].bin`, +FACTORY 为 `<项目>_FACTORY_B-V<版本>[_日期].bin`。不再单独输出 B 包文件 +(B 内容已包含在 FULL/FACTORY 中)。 ### 布局 profile(可复用到任意工程) -内置 `ceramixnano_mb_v51`(默认)与 `f8_single_slot`(无保底槽)。其他工程写一份 +内置 `CeramixNano_MB_Dual_FW`(默认)与 `f8_single_slot`(无保底槽)。其他工程写一份 JSON 即可(保底槽 8K/28K、无保底槽、BL 更大等都支持),工具做完整性校验 (分区不重叠、页对齐、总和=flash_size): ```json { - "profile": "ceramixnano_mb_v51", + "profile": "CeramixNano_MB_Dual_FW", "chip": "GD32E230F8", "flash_base": "0x08000000", "flash_size": "64K", @@ -103,14 +106,15 @@ python gd32e230_firmware_tool.py app.bin --profile f8_single_slot # 无保底 | `bins...` | (可选) | 输入 bin,A/B 包按向量表自动识别 | | `--a BIN` / `--b BIN` | — | 显式指定 A/B 包(与自动识别冲突时报错) | | `-b, --bootloader` | — | BL bin(参与 FULL 拼接;缺省仅警告) | -| `--profile` | `ceramixnano_mb_v51` | 内置名或 profile JSON 路径 | +| `--profile` | `CeramixNano_MB_Dual_FW` | 内置名或 profile JSON 路径 | | `-o, --output` | 输入所在目录 | **输出目录**(产物自动命名) | -| `--no-full` | — | 只出 UPDATE/B 包,不拼 FULL | +| `--no-full` | — | 不拼 FULL 整片镜像 | +| `--no-factory` | — | 不出 FACTORY 工厂镜像 | ### 回归测试 ```bash -python test_fw_pack.py # 49 项: 规格基准 + 边界 + 篡改检测 + CLI 冒烟 +python test_fw_pack.py # 55 项: 规格基准 + 边界 + 篡改检测 + CLI 冒烟 ``` 覆盖 UPGRADE_PLAN_v2.md 第五节全部基准(CeramixNano 1.0.17/1.0.18/V1.0.12 diff --git a/UPGRADE_PLAN_v2.md b/UPGRADE_PLAN_v2.md index df63cbc..5b17414 100644 --- a/UPGRADE_PLAN_v2.md +++ b/UPGRADE_PLAN_v2.md @@ -3,6 +3,11 @@ > 本文可直接作为 AI 辅助重构的提示词使用;也是本次彻底升级的需求基线。 > 背景仓库:本仓库(main 分支);配套 skill:hulk-mcu-firmware(SKILL.md + tool/ 缓存)。 > 首个目标工程:CeramixNano-MB(GD32E230F8/64K,双固件 v5.1 布局,BL 侧 dev_fact@512bf7a)。 +> +> **落地后注记(2026-10-22)**:内置 profile 已定名 `CeramixNano_MB_Dual_FW` +> (本文示例中的 `ceramixnano_mb_v51` 为规划期旧名,已废弃);打包产物在 +> UPDATE/FULL 之外新增 FACTORY 工厂镜像(BL+B 满槽+flag、无 A),不再单独 +> 落盘 B 包文件。 --- diff --git a/fw_pack.py b/fw_pack.py index 131f937..b9242ec 100644 --- a/fw_pack.py +++ b/fw_pack.py @@ -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(' 文件路径 + 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))) diff --git a/gd32e230_firmware_tool.py b/gd32e230_firmware_tool.py index 75d4573..c3def30 100644 --- a/gd32e230_firmware_tool.py +++ b/gd32e230_firmware_tool.py @@ -4,10 +4,10 @@ GD32E230 固件打包工具 v2 — 布局 profile 化双固件打包 (A/B 槽 + ================================================================ v2 模式(默认): 输入编译好的 APP bin, 自动识别 A/B 槽, 产出 - *_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {页数16|CRC16}) - *_B.bin B 槽底包 (固定满槽 + 槽末 8B 记录 {magic|页数|CRC|2B FF}) - *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志) - *.md5.txt 产物 MD5 清单 + 布局摘要 (随 FULL 生成) + *_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {页数16|CRC16}) + *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志) + *_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电跳 B) + *.md5.txt 产物 MD5 清单 + 布局摘要 (随主镜像生成) 打包规则与 BL (dev_fact v5.1) 逐字一致, 写盘后回读复核。计算与校验在 fw_pack.py 公共库 (FlashTool GUI 共用)。 @@ -15,7 +15,7 @@ legacy 模式(过渡期兼容, -f/-m 二选一时启用): 旧单槽方案 -f/--fill 纯 0xFF 补页, 无 trailer (旧 BL 的 Ymodem 升级包) -m/--merge BL + APP + 0xEEEE 标志 拼整片镜像 (不补齐) -v2 Flash 布局(默认 profile ceramixnano_mb_v51, GD32E230F8/64K): +v2 Flash 布局(默认 profile CeramixNano_MB_Dual_FW, GD32E230F8/64K): 0x08000000 Bootloader 8K 页 0~7 0x08002000 FW_A 运行槽 36K 页 8~43 OTA 唯一接收区, 槽尾锚点@0x0800AFFC 0x0800B000 FW_B 保底槽 19K 页 44~62 固定满槽, B 记录@0x0800FBF8 @@ -33,7 +33,10 @@ import fw_pack SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, SCRIPT_DIR) -TOOL_VERSION = '2.0.0' +TOOL_VERSION = '2.1.0' + +# 默认布局 profile (与 fw_pack.BUILTIN_PROFILES 键一致) +DEFAULT_PROFILE = 'CeramixNano_MB_Dual_FW' # ═══════════════════════════════════════════════════════════ @@ -96,7 +99,8 @@ def run_v2(args): return 2 fw_pack.pack_all( args.profile, a_path, b_path=b_path, bl_path=args.bootloader, - out_dir=out_dir, make_full=not args.no_full) + out_dir=out_dir, make_full=not args.no_full, + make_factory=not args.no_factory) except fw_pack.FwPackError as exc: print(f'\n错误:{exc}', file=sys.stderr) return 1 @@ -305,11 +309,11 @@ def main(): epilog=f""" v2 双固件模式(默认, 规格: UPGRADE_PLAN_v2.md): python %(prog)s app_a.bin # 识别 A 包 → *_UPDATE.bin (+FULL) - python %(prog)s app_a.bin app_b.bin -b bl.bin # 全产物: UPDATE/B/FULL/md5 + python %(prog)s app_a.bin app_b.bin -b bl.bin # 全产物: UPDATE/FULL/FACTORY/md5 + python %(prog)s app_a.bin --no-full # 仅升级包 + python %(prog)s app_b.bin -b bl.bin # 仅工厂镜像 → *_FACTORY.bin python %(prog)s --a a.bin --b b.bin # 显式指定槽位(与向量表冲突报错) - python %(prog)s a.bin --profile f8_single_slot # 换布局(无保底槽) - python %(prog)s a.bin --profile my_layout.json # 自定义 profile JSON - python %(prog)s b.bin # 仅 B 底包 → *_B.bin + python %(prog)s a.bin --profile my_layout.json # 自定义布局 profile JSON legacy 模式(旧单槽方案, -f/-m 触发): python %(prog)s -f app.bin # 纯 0xFF 补齐升级包(无 trailer) @@ -328,7 +332,7 @@ legacy 模式(旧单槽方案, -f/-m 触发): help="legacy: 仅补齐 APP 到整页边界 (纯 0xFF, 无 trailer)") parser.add_argument("--legacy", action="store_true", help="显式声明 legacy 模式(需配合 -f/-m)") - parser.add_argument("--profile", default="ceramixnano_mb_v51", + parser.add_argument("--profile", default=DEFAULT_PROFILE, help="v2 布局 profile: 内置名(%s)或 JSON 文件路径" % "/".join(fw_pack.BUILTIN_PROFILES)) parser.add_argument("--a", metavar="BIN", default=None, @@ -340,7 +344,9 @@ legacy 模式(旧单槽方案, -f/-m 触发): parser.add_argument("-o", "--output", default=None, help="输出路径: v2=输出目录(自动命名); legacy=输出文件") parser.add_argument("--no-full", action="store_true", - help="v2: 只出 UPDATE/B 包, 不拼 FULL") + help="v2: 不拼 FULL 整片镜像") + parser.add_argument("--no-factory", action="store_true", + help="v2: 不出 FACTORY 工厂镜像") parser.add_argument("-s", "--flash-size", default=DEFAULT_CHIP, choices=list(CHIP_CONFIG.keys()), help="legacy 芯片型号: F4(16K) F6(32K) F8(64K)") @@ -353,10 +359,10 @@ legacy 模式(旧单槽方案, -f/-m 触发): legacy = args.fill or args.merge if args.legacy and not legacy: parser.error("--legacy 需配合 -f/--fill 或 -m/--merge 使用") - if legacy and (args.profile != "ceramixnano_mb_v51" or args.a or args.b - or args.no_full or args.app_addr): - parser.error("--profile/--a/--b/--no-full/-a 是 v2/legacy 专属参数, " - "不能与 -f/-m 同用") + if legacy and (args.profile != DEFAULT_PROFILE or args.a or args.b + or args.no_full or args.no_factory or args.app_addr): + parser.error("--profile/--a/--b/--no-full/--no-factory/-a 是 v2/legacy " + "专属参数, 不能与 -f/-m 同用") if legacy and len(args.bins) != 1: parser.error("legacy 模式需要一个 APP 位置参数, 例: python %(prog)s -f app.bin" % {'prog': parser.prog}) diff --git a/test_fw_pack.py b/test_fw_pack.py index 840ae66..6cb4a60 100644 --- a/test_fw_pack.py +++ b/test_fw_pack.py @@ -10,9 +10,10 @@ fw_pack / gd32e230_firmware_tool v2 回归测试套件 4. A 包整页扩展规则(恰整页 / 余量<4 / 余量==4 / 超槽); 5. B 包固定满槽 + 记录(满槽输入 / 小输入 / 超槽-8); 6. FULL 拼装 + 逐项字节校验 + 回读复核(含篡改检测); - 7. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现 + 7. FACTORY 工厂镜像(BL+B+flag 无 A, 锚点擦除态) + 篡改检测; + 8. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现 2026-10-22 验收包, 文件缺失时自动跳过); - 8. CLI 冒烟(v2 / legacy / 错误路径)。 + 9. CLI 冒烟(v2 / legacy / 错误路径)。 运行: python test_fw_pack.py [-v] 全部通过 = v2 升级验收通过。 @@ -89,11 +90,11 @@ def synth_bin(base, size, rst_off=0x101): def ceramix_profile(): - return fw_pack.load_profile('ceramixnano_mb_v51') + return fw_pack.load_profile('CeramixNano_MB_Dual_FW') def variant_profile(slot_a_k, slot_b_k, with_b=True): - raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51'])) + raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW'])) raw['slot_a']['size'] = f'{slot_a_k}K' if with_b: raw['slot_b']['size'] = f'{slot_b_k}K' @@ -183,7 +184,7 @@ class TestProfile(unittest.TestCase): fw_pack.parse_num('') def _invalid(self, mutate, needle): - raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51'])) + raw = json.loads(json.dumps(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW'])) mutate(raw) with self.assertRaises(fw_pack.FwPackError) as cm: fw_pack.Profile(raw) @@ -208,9 +209,9 @@ class TestProfile(unittest.TestCase): with tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'p.json') with open(path, 'w', encoding='utf-8') as f: - json.dump(fw_pack.BUILTIN_PROFILES['ceramixnano_mb_v51'], f) + json.dump(fw_pack.BUILTIN_PROFILES['CeramixNano_MB_Dual_FW'], f) p = fw_pack.load_profile(path) - self.assertEqual(p.name, 'ceramixnano_mb_v51') + self.assertEqual(p.name, 'CeramixNano_MB_Dual_FW') bad = os.path.join(td, 'bad.json') with open(bad, 'w', encoding='utf-8') as f: f.write('{ not json') @@ -441,7 +442,60 @@ class TestFull(unittest.TestCase): # ═══════════════════════════════════════════════════════════ -# 7. 真机回归: CeramixNano v5.1 (字节级复现验收包) +# 7. FACTORY 工厂镜像 (BL + B 满槽 + flag, 无 A) +# ═══════════════════════════════════════════════════════════ + +class TestFactory(unittest.TestCase): + def setUp(self): + self.p = ceramix_profile() + self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00) + self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8) + self.bp = fw_pack.build_b_pack(self.b_body, self.p) + + def test_factory_layout_bytes(self): + """BL + B 满槽 + flag; A 槽(含锚点)整体擦除态 → 上电判定链落 B""" + img = fw_pack.build_factory(self.p, self.bl, self.bp) + self.assertEqual(len(img), 64 * 1024) + self.assertEqual(img[:4044], self.bl) + self.assertTrue(all(b == 0xFF for b in img[4044:0xB000])) # BL 余量 + 整个 A 槽 + self.assertEqual(img[0xAFFC:0xB000], b'\xff' * 4) # 锚点擦除态 + self.assertEqual(img[0xB000:0xFC00], self.bp.data) + self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00') + + def test_verify_factory_all_pass(self): + img = fw_pack.build_factory(self.p, self.bl, self.bp) + checks = fw_pack.verify_factory_blob(img, self.p, self.bl, self.b_body) + bad = [(n, t) for n, ok, t in checks if not ok] + self.assertEqual(bad, [], checks) + self.assertGreaterEqual(len(checks), 10) + + def test_verify_factory_detects_corruption(self): + img = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp)) + img[0xB000 + 100] ^= 0xFF # 破坏 B 体 + checks = fw_pack.verify_factory_blob(bytes(img), self.p, self.bl, + self.b_body) + self.assertFalse(all(ok for _n, ok, _t in checks)) + img2 = bytearray(fw_pack.build_factory(self.p, self.bl, self.bp)) + img2[0xAFFC] = 0x14 # A 槽出现锚点痕迹 + checks = fw_pack.verify_factory_blob(bytes(img2), self.p, self.bl, + self.b_body) + self.assertFalse(all(ok for _n, ok, _t in checks)) + + def test_requires_b_pack(self): + with self.assertRaises(fw_pack.FwPackError): + fw_pack.build_factory(self.p, self.bl, None) + + def test_suggest_factory_name(self): + self.assertEqual( + fw_pack.suggest_factory_name( + r'x\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'), + 'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin') + self.assertEqual(fw_pack.suggest_factory_name(r'x\app_b.bin'), + 'app_b_FACTORY.bin') + + +# ═══════════════════════════════════════════════════════════ +# 8. 真机回归: CeramixNano v5.1 (字节级复现验收包) # ═══════════════════════════════════════════════════════════ @unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件(见 test_fw_pack.py 头部路径)') @@ -499,25 +553,31 @@ class TestRegressionCeramixNano(unittest.TestCase): with tempfile.TemporaryDirectory() as td: bl_path = os.path.join(td, 'bl.bin') open(bl_path, 'wb').write(self.bl) - out = fw_pack.pack_all('ceramixnano_mb_v51', A17, b_path=B12, + out = fw_pack.pack_all('CeramixNano_MB_Dual_FW', A17, b_path=B12, bl_path=bl_path, out_dir=td, progress=quiet()) upd = open(out.paths['update'], 'rb').read() - bpk = open(out.paths['b'], 'rb').read() full = open(out.paths['full'], 'rb').read() + fac = open(out.paths['factory'], 'rb').read() self.assertEqual(upd, open(U17_REF, 'rb').read()) - self.assertEqual(bpk[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff')) self.assertEqual(full, self.ref_full) + # FACTORY = 参考 FULL 把 A 槽区(含锚点)整体置 0xFF + expect_fac = bytearray(self.ref_full) + expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000) + self.assertEqual(fac, bytes(expect_fac)) self.assertTrue(out.ok) - # FULL 自动命名与验收包一致 + # 自动命名: FULL 与验收包一致, FACTORY 带 B 版本号 self.assertEqual(os.path.basename(out.paths['full']), 'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin') - # MD5 清单包含全部产物与正确摘要 + self.assertEqual(os.path.basename(out.paths['factory']), + 'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin') + # MD5 清单挂在 FULL, 包含全部产物与正确摘要 mani = open(out.paths['md5'], encoding='utf-8').read() - for key in ('update', 'b', 'full'): + for key in ('update', 'full', 'factory'): blob = open(out.paths[key], 'rb').read() self.assertIn(hashlib.md5(blob).hexdigest(), mani) - self.assertIn('ceramixnano_mb_v51', mani) + self.assertIn('CeramixNano_MB_Dual_FW', mani) self.assertIn('0xD30F0014', mani) + self.assertIn('DE C0 13 00 6F 18 FF FF', mani) def test_suggest_full_name(self): self.assertEqual( @@ -539,8 +599,10 @@ class TestCLI(unittest.TestCase): self.dir = self.td.name self.a = os.path.join(self.dir, 'app_a.bin') self.b = os.path.join(self.dir, 'app_b.bin') + self.bl = os.path.join(self.dir, 'bl.bin') open(self.a, 'wb').write(synth_bin(A_BASE, 19580)) open(self.b, 'wb').write(synth_bin(B_BASE, SLOT_B_SIZE)) + open(self.bl, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00)) def tearDown(self): self.td.cleanup() @@ -548,21 +610,31 @@ class TestCLI(unittest.TestCase): def test_v2_full_pipeline(self): rc, out, err = run_cli(self.a, self.b, '-o', self.dir) self.assertEqual(rc, 0, err or out) - for name in ('app_a_UPDATE.bin', 'app_b_B.bin', 'app_a_FULL.bin'): + for name in ('app_a_UPDATE.bin', 'app_a_FULL.bin', 'app_b_FACTORY.bin'): self.assertTrue(os.path.isfile(os.path.join(self.dir, name)), name) self.assertTrue(os.path.isfile( os.path.join(self.dir, 'app_a_FULL.bin.md5.txt'))) full = open(os.path.join(self.dir, 'app_a_FULL.bin'), 'rb').read() self.assertEqual(len(full), 65536) + fac = open(os.path.join(self.dir, 'app_b_FACTORY.bin'), 'rb').read() + self.assertEqual(fac[0x2000:0xB000], b'\xFF' * 0x9000) # A 槽擦除态 + self.assertFalse([f for f in os.listdir(self.dir) + if f.endswith('_B.bin')]) # 不再单独出 B 包 self.assertIn('回读复核通过', out) - def test_v2_b_only_no_full(self): - """仅 B 输入 → 只出 *_B.bin, 不拼 FULL (无 A 运行槽的整片镜像不可启动)""" - rc, out, err = run_cli(self.b, '-o', self.dir) + def test_v2_b_only_factory(self): + """仅 B 输入 → 只出 *_FACTORY.bin(+MD5), 不拼 FULL""" + rc, out, err = run_cli(self.b, '-b', self.bl, '-o', self.dir) self.assertEqual(rc, 0, err or out) - self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_B.bin'))) + self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin'))) + self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_FACTORY.bin.md5.txt'))) self.assertFalse([f for f in os.listdir(self.dir) if 'FULL' in f]) + def test_v2_no_factory_flag(self): + rc, out, err = run_cli(self.a, self.b, '-o', self.dir, '--no-factory') + self.assertEqual(rc, 0, err or out) + self.assertFalse([f for f in os.listdir(self.dir) if 'FACTORY' in f]) + def test_v2_explicit_conflict(self): rc, out, err = run_cli('--b', self.a) # A 固件显式塞给 B 槽 self.assertNotEqual(rc, 0) @@ -603,8 +675,16 @@ class TestCLI(unittest.TestCase): td, 'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin'), 'rb').read() self.assertEqual(full, open(FULL_REF, 'rb').read()) + expect_fac = bytearray(open(FULL_REF, 'rb').read()) + expect_fac[0x2000:0xB000] = b'\xFF' * (0xB000 - 0x2000) + fac = open(os.path.join(td, + 'CeramixNano-MB_FACTORY_B-V1.0.12_2026-10-20.bin'), + 'rb').read() + self.assertEqual(fac, bytes(expect_fac)) self.assertIn('0xD30F0014', out) self.assertIn('DE C0 13 00 6F 18 FF FF', out) + self.assertIn('FACTORY', out) + self.assertFalse([f for f in os.listdir(td) if f.endswith('_B.bin')]) def test_legacy_fill(self): out = os.path.join(self.dir, 'legacy_update.bin')