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
+22 -6
View File
@@ -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
+8 -7
View File
@@ -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 手动指定工具链(默认自动定位)
```
+6 -5
View File
@@ -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 工具链, 免安装解压即用',
+7 -1
View File
@@ -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
+10 -5
View File
@@ -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',
+19 -15
View File
@@ -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
+5
View File
@@ -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 包文件。
---
+146 -34
View File
@@ -5,8 +5,9 @@ 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 标志)
*_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电判定链
锚点擦除态 → A 向量非法 → 落 B 记录校验 → 跳出厂固件)
*.md5.txt 产物 MD5 清单 + 布局摘要
规则与 BL dev_fact 侧 (v5.1) 逐字一致:
@@ -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')
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))
% (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)))
+21 -15
View File
@@ -5,9 +5,9 @@ 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 生成)
*_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})
+100 -20
View File
@@ -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')