Author SHA1 Message Date
hulk 0ded03c858 docs: 勾销"目标板 SWD 无应答"TODO——2026-10-24 起同探针多轮实测正常,判定为临时供电/连接问题(排查思路留档) 2026-10-25 01:55:03 +08:00
hulk 9492a8dcce release(v2.6.1): GUI 2.6.1 安装包发布,板卡 BL bin 改随本仓库 Release 分发
- app_gui.py APP_VERSION 2.6.0→2.6.1;重建 onedir + 组 zip(23.1 MB,MD5 ae9aa6590bf46b8e46472dc2d1f1ebc7)并上传 DAPLinkFlash-v2.6.1 Release
- 本版 GUI 首次随包分发 fw_pack 2.2(含 CeramixNano_EDDY_Dual_FW profile,CLI 与「打包 v2」弹窗均可用)
- Release 同时附 MB/EDDY 两板 BL bin + bootloader_bins.md5.txt(BL bin 分发渠道自此为本仓库 Release)
- 入库发布脚本 publish_bl_release.py(建 Release + 传 BL 附件);bootloader_disasm*.txt 分析副产物加入 .gitignore(维持不入库)
- AGENTS.md 补 v2.6.1 发布进度(含 zip MD5,供复现核对)
2026-10-25 00:49:48 +08:00
hulk 8af9264ae4 chore: 内部迭代号出清收尾——CLI/测试/README 注释标签改中性表述,删除旧 profile 名迁移映射
- gd32e230_firmware_tool.py / test_fw_pack.py / README.md:v5/dev_fact 等会话自管理标签改为"双固件 bootloader"中性表述;参考产物 *_UPDATE_V5.bin 更名 *_UPDATE.bin(含验收包内 1.0.18),测试引用同步
- config.py:删除 _RENAMED_PROFILE 旧名迁移映射(ceramixnano_mb_v51 仅存在数小时;残留旧名加载 profile 时报错提示重选)
- AGENTS.md:留档出清进度,并修正"GUI 旧配置自动迁移"的过时表述(迁移映射已删)
- 55 项回归全绿;fw_pack.py 侧标签清理已随 5a85f68 入库
2026-10-25 00:43:53 +08:00
hulk 5a85f68c5d chore(version): fw_pack 2.1→2.2——新增内置 profile CeramixNano_EDDY_Dual_FW 2026-10-24 21:46:24 +08:00
hulk 5b302d13d1 docs(profile): EDDY profile 注释改为专属 BL 分支 bin(撤销复用表述) 2026-10-24 21:34:07 +08:00
hulk b8de2dce3c feat(profile): 新增内置 CeramixNano_EDDY_Dual_FW——EDDY 板双固件布局(与 MB 逐字节相同,BL 复用同一 bin) 2026-10-24 21:15:16 +08:00
hulk 5c46441b29 docs(agents): v2.6.0 发布完成 — exe 冻结态新 profile 名验证通过, zip 已传 Gitea Release 2026-10-22 08:31:49 +08:00
hulk 0a4095150b feat(pack): FACTORY 工厂镜像 + 内置 profile 改名 CeramixNano_MB_Dual_FW 2026-10-22 05:14:58 +08:00
hulk 0ffa671656 docs(agents): v2 落地进度同步 — skill 缓存/SKILL.md 已同步, exe 冻结态打包验证通过 2026-10-22 00:56:09 +08:00
hulk 031ee4efdf feat(pack): v2 双固件 profile 打包 — fw_pack 公共库 + CLI 重构 + GUI 打包弹窗 2026-10-22 00:53:52 +08:00
14 changed files with 2717 additions and 292 deletions
+2
View File
@@ -26,4 +26,6 @@ FlashTool/flash_gui_settings.json
FlashTool/release/ FlashTool/release/
FlashTool/src/build/ FlashTool/src/build/
FlashTool/toolchain/ FlashTool/toolchain/
# BL 反汇编分析副产物(不入库, 见 AGENTS 2026-10-22 留档)
bootloader_disasm*.txt
+127
View File
@@ -0,0 +1,127 @@
# AGENTS.md — gd32e230f8_firmware_merge_tool
GD32E230 系列固件处理与烧录工具仓库。本文件为项目标杆文档,随开发持续更新。
## 项目约定
- **语言与运行环境**:Python ≥3.10;GUI 用 CustomTkinter(`py -m pip install customtkinter`),
无其他第三方依赖;Windows 为主平台。
- **OpenOCD 工具链**:目标板连接依赖 **xPack OpenOCD 0.11.0-3**
(本机 `D:/toolchain/xpack-openocd-0.11.0-3`,发布包整包自带于
`release/DAPLinkFlash/toolchain/openocd/`)。0.12 不用:`adapter srst`
命令语法不同且未经验证。
- **项目结构**:
- 根目录:`gd32e230_firmware_tool.py` 命令行固件工具 v2(默认双固件 profile
打包;`-f`/`-m` 触发 legacy 单槽模式),`fw_pack.py` 打包公共库(CLI 与 GUI
共用的单一真相: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.1(烧录 + 打包 v2 + 补齐 Pad + 拼接
Merge),源码在 `src/`(`flash_gui.py` 入口与 CLI、`app_gui.py` 界面、
`openocd_runner.py` 命令构建、`fw_image.py` 旧补齐/拼接、`config.py` 设置、
`build_exe.py`/`package_release.py`/`publish_gitea.py` 发布流水线);
`fw_pack` 由仓库根导入(源码模式 sys.path 回退,打包模式
`--paths 根 --hiddenimport fw_pack`),**不要往 src/ 复制副本**;
- `FlashTool/cfgs/openocd_gdlink_gd32e23x.cfg`:随包分发的工程定制 target cfg
(含 `reset_config srst_only srst_nogate connect_assert_srst`,**必须**复位下连接,
因为应用固件运行后会把 SWD 引脚复用为 LED)。
- **v2 打包规则(与 BL dev_fact v5.1 逐字一致,改前必读 `UPGRADE_PLAN_v2.md`)**:
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;
FACTORY=BL+B 满槽+flag **无 A**(A 槽整体擦除态,上电判定链落 B 跳出厂固件),
12 项字节校验;不再单独落盘 B 包文件(B 内容已含于 FULL/FACTORY)。
默认 profile 名为 `CeramixNano_MB_Dual_FW`(旧名 `ceramixnano_mb_v51` 已
废弃不留别名;迁移映射已删,配置残留旧名会在加载时报错提示重选)。
回归基准(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` 后必须
`python test_fw_pack.py` 全绿再提交**(真机固件路径见该文件头部,缺文件自动跳过)。
- **构建与发布**:`build_exe.py`(PyInstaller **onedir** → `release/DAPLinkFlash/`)
→ `package_release.py`(组 zip,版本号取 `DAPLinkFlash-v*` git tag,工具链优先
`FlashTool/toolchain/openocd` → `D:/toolchain/xpack-openocd-0.11.0-3`)
→ `publish_gitea.py`(上传 Gitea Release,用环境变量 `GITEA_TOKEN`,需 `--insecure`)。
`release/` 是机器生成区,只应有 `DAPLinkFlash/`(onedir)与 `*.zip`。
- **OpenOCD 复位序列**:所有操作(检测/烧录/擦除/读取/复位)统一带
`CONNECT_SEQ = "adapter deassert srst; sleep 200; catch {reset halt}; reset halt"`
(0.11 语法),复刻 VSCode tasks.json 的任务链;烧录后复位前同样先释放 SRST。
改动 `openocd_runner.py` 的序列时保持与 VSCode 工程任务链一致。
## 开发进度
### 已完成
- 命令行固件工具 legacy 模式(校验链接地址 / 1K 页补齐 `_update.bin` / 拼接
bootloader `_BL.bin` / 有效标志 `0xEEEEEEEE`;`-a/--app-addr` 自定义落位);
- DAPLink Flash GUI v2.5.2:单列仪表盘布局、OpenOCD 全操作(检测/烧录/擦除/
读取/复位)、BIN/HEX/ELF、补齐 Pad / 拼接 Merge 弹窗、深浅色主题、
cfgs/ 自定义 cfg 机制、全操作 SRST 连接序列、整包 0.11 工具链、
Gitea Release 发布流水线;
- 2026-10-22 **v2 彻底升级落地**(`UPGRADE_PLAN_v2.md` 全部实现并自验收):
- `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/--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 冒烟;
- FlashTool GUI v2.6.0:顶栏"打包 (v2)"弹窗(profile 下拉+JSON 浏览、BL/A/B
输入、输出目录、生成 FULL 开关),`flash_gui.py --pack` 命令行模式,
`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` 为分析
副产物留在工作区未入库。
- 2026-10-22 **v2.6.0 发布**(tag `DAPLinkFlash-v2.6.0`):重建 onedir exe
(冻结新 fw_pack,`--pack` 默认 profile 显示 `CeramixNano_MB_Dual_FW`、
旧名拒绝、UPDATE/FULL/FACTORY 产物冻结态验证通过),组 zip 23.1 MB
(内置 OpenOCD 0.11 工具链 + cfgs),已传 Gitea Release。
- 2026-10-22 **内部迭代号出清**(用户定规:v4/v4.1/v5/v5.1、dev_fact 等
会话自管理标签只许存在于 AGENTS/SKILL 等文档,不得出现在任何代码):
清理 `fw_pack.py`/`gd32e230_firmware_tool.py`/`test_fw_pack.py`/`README.md`
注释中的标签表述(改"双固件 bootloader");**删除 config.py 的
`_RENAMED_PROFILE` 旧名迁移映射**(`ceramixnano_mb_v51` 仅存在数小时,
残留配置会在加载 profile 时报错提示重选);同步 skill 缓存
(fw_pack.py / gd32e230_firmware_tool.py 重新逐字节一致);参考产物
`*_UPDATE_V5.bin` 更名 `*_UPDATE.bin`(含验收包内 1.0.18),测试引用同步;
55 项回归全过。注:BL 侧 `bootloader.c` 同日清理 3 处注释标签(注释不影响
代码生成,已烧 BL bin 仍有效)。
- 2026-10-25 **v2.6.1 发布**(tag `DAPLinkFlash-v2.6.1`,Release id 47):GUI
升版 2.6.1 重建 onedir 并组 zip 23.1 MB(24,177,112B,MD5
`ae9aa6590bf46b8e46472dc2d1f1ebc7`)上传 Release——fw_pack 2.2
(`CeramixNano_EDDY_Dual_FW`)首次随 GUI 安装包分发,CLI 与「打包 v2」弹窗
均可用;同 Release 附 MB/EDDY 两板 BL bin + `bootloader_bins.md5.txt`,
**BL bin 分发渠道自此为本仓库 Release**(不再从 BL 仓库 Build 目录取);
发布脚本 `publish_bl_release.py` 入库(建 Release + 传 BL 附件)。zip 由
本次提交树构建(tag `5a85f68` 之后叠加出清与升版),复现核对以 MD5 为准。
### 计划 / TODO
- (已解决,2026-10-25 勾销)目标板 SWD 无应答(`cannot read IDR`):2026-10-24
起同探针同工具链多轮实测正常(EDDY 板双固件全链验收,SWD halt 探针/整片读回/
SRST 脉冲恢复均成功),判定为当时临时供电/连接问题。若复现按原思路排查:目标板
供电、DAPLink RST → GD32 NRST 连线、拔插 DAPLink USB / 断电重上电,恢复后用
`release/DAPLinkFlash/DAPLinkFlash.exe` 重新"检测设备"验证。
### 教训(避免复发)
- **不要运行 `release/` 根目录下的任何 exe**:09-14 旧 onefile 残留(已删)曾
导致 2026-10-20 误报"烧录不正常"——它用旧 0.12 工具链 + 原版 target cfg、
无 SRST 连接序列,与本工程固件(SWD 引脚复用为 LED)不兼容。运行入口只有
`release/DAPLinkFlash/DAPLinkFlash.exe`(打包版)或 `start_flash_gui.bat`(源码版)。
+40 -15
View File
@@ -1,8 +1,9 @@
# DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 # DAPLink Flash — GD32 固件烧录 · 双固件打包 · 补齐 · 拼接
> GD32E230 系列的桌面烧录/固件处理工具:**OpenOCD 烧录**(DAPLink / GD-Link / > GD32E230 系列的桌面烧录/固件处理工具:**OpenOCD 烧录**(DAPLink / GD-Link /
> ST-Link / J-Link)+ **补齐 Pad**(Y-Modem 页对齐)+ **拼接 Merge** > ST-Link / J-Link)+ **打包 v2**(双固件 profile 布局 → UPDATE / FULL / FACTORY
> (Bootloader + APP + 有效标志 → 整片镜像)。 > 镜像 + MD5 清单,与命令行工具共用 `fw_pack.py` 公共库)+ **补齐 Pad**(Y-Modem
> 页对齐)+ **拼接 Merge**(Bootloader + APP + 有效标志 → 整片镜像)。
> >
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases) > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
> 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe` > 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe`
@@ -10,7 +11,7 @@
> - **要改代码/重新打包**:见 §5、§6。 > - **要改代码/重新打包**:见 §5、§6。
> >
> 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色卡片仪表盘 + > 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色卡片仪表盘 +
> PyInstaller onefile + Gitea Release 发布)。更新:2026-09-14 > PyInstaller onedir + Gitea Release 发布)。更新:2026-10-22
## 1. 功能总览 ## 1. 功能总览
@@ -20,10 +21,11 @@
| 固件格式 | BIN / HEX / ELF——HEX/ELF 自动用文件内地址,仅 BIN 需填基地址(完整镜像 `0x08000000`,单独 APP `0x08002000`) | | 固件格式 | BIN / HEX / ELF——HEX/ELF 自动用文件内地址,仅 BIN 需填基地址(完整镜像 `0x08000000`,单独 APP `0x08002000`) |
| 芯片型号 | GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小与 target 配置 | | 芯片型号 | GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小与 target 配置 |
| 烧录器 | DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP(TCP)、Custom;支持适配器速度与探针序列号 | | 烧录器 | DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP(TCP)、Custom;支持适配器速度与探针序列号 |
| 打包 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,最后一包不满必须先补齐 | | 补齐 Pad | BIN 尾部补 `0xFF` 到 1K/2K/4K 页边界;bootloader 的 Y-Modem 只按 1K 整包写 Flash,最后一包不满必须先补齐 |
| 拼接 Merge | Bootloader + APP + 有效标志(`0xEEEEEEEE` @`0x0800FFFC`) → 整片 Flash 镜像,逻辑与 `merge_bin.py` 一致 | | 拼接 Merge | Bootloader + APP + 有效标志(`0xEEEEEEEE` @`0x0800FFFC`) → 整片 Flash 镜像,逻辑与 `merge_bin.py` 一致 |
| 内置工具链 | 发布包自带 OpenOCD(`toolchain/openocd/`),GUI 自动定位,免安装免配置 | | 内置工具链 | 发布包自带 OpenOCD(`toolchain/openocd/`),GUI 自动定位,免安装免配置 |
| 命令行模式 | 全部功能可脚本化调用(`--flash / --pad / --merge ...`,见 §4) | | 命令行模式 | 全部功能可脚本化调用(`--flash / --pack / --pad / --merge ...`,见 §4) |
| 外观 | 跟随系统深/浅色,顶栏可切换 Dark / Light / System | | 外观 | 跟随系统深/浅色,顶栏可切换 Dark / Light / System |
## 2. 下载与安装 ## 2. 下载与安装
@@ -54,15 +56,15 @@ DAPLinkFlash/
## 3. GUI 使用 ## 3. GUI 使用
主窗口为单列仪表盘,补齐/拼接经顶栏按钮弹出独立窗口: 主窗口为单列仪表盘,打包/补齐/拼接经顶栏按钮弹出独立窗口:
``` ```
顶栏 DAPLink Flash [补齐 (Pad)] [拼接 (Merge)] 关于 Dark/Light/System 顶栏 DAPLink Flash [打包 (v2)] [补齐 (Pad)] [拼接 (Merge)] 关于 Dark/Light/System
┌ 探针与工具链 [自动定位] ┐ ┌ 探针与工具链 [自动定位] ┐
├ 芯片与固件(型号/固件/基地址) ┤ ├ 芯片与固件(型号/固件/基地址) ┤
├ 运行日志 — OpenOCD 输出 ┤ ├ 运行日志 — OpenOCD 输出 / 打包校验 ┤
└ 操作: 检测/烧录/擦除/读取/复位/停止 ┘ └ 操作: 检测/烧录/擦除/读取/复位/停止 ┘
弹窗 补齐 (Pad) / 拼接 (Merge) — 非模态, 配置与主窗口共享 弹窗 打包 (v2) / 补齐 (Pad) / 拼接 (Merge) — 非模态, 配置与主窗口共享
``` ```
1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上 1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上
@@ -76,12 +78,17 @@ DAPLinkFlash/
单独 APP `0x08002000`); 单独 APP `0x08002000`);
4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区; 4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区;
5. **固件工具**(顶栏按钮弹出的独立窗口,非模态): 5. **固件工具**(顶栏按钮弹出的独立窗口,非模态):
- 打包 v2:选 profile(内置下拉或浏览 JSON)+ BL/A 包/B 包 bin(至少一个)+
输出目录,点"执行打包"→ `*_UPDATE.bin`(A 包)/ `*_FACTORY.bin`(工厂镜像
BL+B+flag,给了 B 包才出)/ `*_FULL.bin`(整片镜像,需 A 包)+ MD5 清单;
A/B 槽按向量表自动校验,逐项字节校验与回读复核结果流进主日志;
- 补齐:选输入 BIN 后输出名自动带出(`*_update.bin`,Y-Modem 升级包),对齐默认 1K,点"执行补齐"; - 补齐:选输入 BIN 后输出名自动带出(`*_update.bin`,Y-Modem 升级包),对齐默认 1K,点"执行补齐";
- 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`,整片镜像),地址按典型布局 - 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`,整片镜像),地址按典型布局
预填,点"执行拼接"。 预填,点"执行拼接"。
典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC` v2 双固件布局(默认 profile `CeramixNano_MB_Dual_FW`)与打包规则详见仓库根
有效标志 `0xEEEEEEEE` | `0x08010000` Flash 结束 (64KB)。 [README.md](../README.md);legacy 单槽布局:`0x08000000` Bootloader 8KB |
`0x08002000` APP | `0x0800FFFC` 有效标志 `0xEEEEEEEE`。
配置(全部输入框的值)自动保存在 exe 旁的 `flash_gui_settings.json`,下次启动 配置(全部输入框的值)自动保存在 exe 旁的 `flash_gui_settings.json`,下次启动
自动恢复;旧版 `config/flash_gui_<主机名>.json` 在首次启动时自动迁移。 自动恢复;旧版 `config/flash_gui_<主机名>.json` 在首次启动时自动迁移。
@@ -102,8 +109,11 @@ python src/flash_gui.py --read dump.bin 读取整片 Flash
python src/flash_gui.py --reset 复位运行 python src/flash_gui.py --reset 复位运行
python src/flash_gui.py --pad app.bin 补齐到 1K → app_pad.bin 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 --merge bl.bin app.bin 拼接 → app_BL.bin
--align N / --pad-byte 0xFF / -o OUT pad 参数 python src/flash_gui.py --pack a.bin b.bin --pack-bl bl.bin -o outdir
v2 双固件打包 → UPDATE/FULL/FACTORY+MD5
--align N / --pad-byte 0xFF / -o OUT pad 参数(-o 对 pack 为目录)
--app-addr / --flag-addr / --flash-size / --flag-value merge 参数 --app-addr / --flag-addr / --flash-size / --flag-value merge 参数
--pack-profile 名字|JSON / --pack-no-full / --pack-no-factory pack 参数
--speed 4000 / --serial SN / --interface cfg / --target cfg --speed 4000 / --serial SN / --interface cfg / --target cfg
--openocd PATH / --scripts DIR 手动指定工具链(默认自动定位) --openocd PATH / --scripts DIR 手动指定工具链(默认自动定位)
``` ```
@@ -116,7 +126,7 @@ python src/flash_gui.py --merge bl.bin app.bin 拼接 → app_BL.bin
FlashTool/ FlashTool/
├── src/ # 源代码(人工维护区) ├── src/ # 源代码(人工维护区)
│ ├── flash_gui.py # 入口与命令行模式(argparse 分发) │ ├── flash_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) │ ├── app_gui.py # CustomTkinter 主界面(单列仪表盘 + 打包/补齐/拼接弹窗)
│ ├── openocd_runner.py # 工具链定位 / OpenOCD 命令构建与子进程 │ ├── openocd_runner.py # 工具链定位 / OpenOCD 命令构建与子进程
│ ├── fw_image.py # 补齐(pad)/拼接(merge) + 芯片参数表 │ ├── fw_image.py # 补齐(pad)/拼接(merge) + 芯片参数表
│ ├── config.py # 运行目录与 flash_gui_settings.json 配置 │ ├── config.py # 运行目录与 flash_gui_settings.json 配置
@@ -134,8 +144,13 @@ FlashTool/
从源码运行:`python src/flash_gui.py` 或双击 `start_flash_gui.bat`, 从源码运行:`python src/flash_gui.py` 或双击 `start_flash_gui.bat`,
需 Python ≥3.10 + customtkinter(`py -m pip install customtkinter`); 需 Python ≥3.10 + customtkinter(`py -m pip install customtkinter`);
OpenOCD 用本机安装(默认找 `D:/toolchain/openocd`)或先跑一次 OpenOCD 默认找本机 `D:/toolchain/openocd`,要 vendor 的话把工具链放到
`package_release.py` 把工具链放进 `release/toolchain/`。 `FlashTool/toolchain/openocd/`(`package_release.py` 打包时同样优先取这里)。
> **fw_pack.py**(v2 打包公共库)位于**仓库根目录**(与命令行工具共用,单一真相):
> 源码模式由 `app_gui.py`/`flash_gui.py` 自动把仓库根补进 `sys.path`;
> PyInstaller 打包由 `build_exe.py` 的 `--paths 仓库根 --hiddenimport fw_pack`
> 收进运行库,不要把副本复制进 `src/`。
## 6. 构建与发布 ## 6. 构建与发布
@@ -159,6 +174,16 @@ python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip --insecure
处理见 §2 的三种绕过方式;要彻底消除需购买代码签名证书对 exe 签名; 处理见 §2 的三种绕过方式;要彻底消除需购买代码签名证书对 exe 签名;
- **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/` - **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/`
目录和 exe 在一起; 目录和 exe 在一起;
- **`Error connecting DP: cannot read IDR`(SWD 完全连不上)**:目标板对 SWD
无应答,与工具/命令无关,按序排查:
① 目标板是否已上电(DAPLink 指示灯亮 ≠ 板子有电,两者供电独立);
② DAPLink 的 **RST 引脚必须接到 GD32 的 NRST**——本类工程固件运行后会把
SWD 引脚复用为 LED,必须"按住复位连接"(内置 `cfgs/openocd_gdlink_gd32e23x.cfg`
已配置 `connect_assert_srst`),RST 线没接/松脱时就是这个错;
③ 拔插 DAPLink 的 USB、目标板彻底断电再上电(深睡/锁死状态恢复);
④ 速度降到 1000 kHz;
⑤ 确认运行的是 `DAPLinkFlash` 文件夹里的 exe(日志第一屏工具链应显示"内置",
OpenOCD 版本 0.11.0-3、target 为 `cfgs/...` 才是新版);
- **烧录失败排查**:①"检测设备"先验链路;② 探针序列号留空可排除序列号问题; - **烧录失败排查**:①"检测设备"先验链路;② 探针序列号留空可排除序列号问题;
③ 速度过高可降到 1000 kHz;④ DAPLink 固件过旧换 GD-Link 固件试试; ③ 速度过高可降到 1000 kHz;④ DAPLink 固件过旧换 GD-Link 固件试试;
- **exe 放只读目录**:`flash_gui_settings.json` 写在 exe 旁边,只读目录存不了配置; - **exe 放只读目录**:`flash_gui_settings.json` 写在 exe 旁边,只读目录存不了配置;
+116 -4
View File
@@ -28,12 +28,20 @@ import openocd_runner
from fw_image import (CHIP_PROFILES, FLAG_VALUE_DEFAULT, FLASH_BASE_DEFAULT, from fw_image import (CHIP_PROFILES, FLAG_VALUE_DEFAULT, FLASH_BASE_DEFAULT,
merge_firmware, pad_bin, parse_int) merge_firmware, pad_bin, parse_int)
# fw_pack (v2 双固件打包公共库) 位于仓库根目录: 源码模式补 sys.path,
# 打包模式(PyInstaller --paths+hiddenimport)由运行库提供
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if os.path.isfile(os.path.join(_REPO_ROOT, 'fw_pack.py')) \
and _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
import fw_pack
MONO = ('Consolas', 12) MONO = ('Consolas', 12)
BOLD = ('Microsoft YaHei UI', 13, 'bold') BOLD = ('Microsoft YaHei UI', 13, 'bold')
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
APP_NAME = 'DAPLink Flash' APP_NAME = 'DAPLink Flash'
APP_VERSION = '2.5.2' APP_VERSION = '2.6.1'
REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool' REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool'
OK_BLUE = '#3B8ED0' OK_BLUE = '#3B8ED0'
@@ -110,6 +118,7 @@ class App(ctk.CTk):
self._op_buttons = [] self._op_buttons = []
self.v_pad_result = tk.StringVar(value='') self.v_pad_result = tk.StringVar(value='')
self.v_merge_result = tk.StringVar(value='') self.v_merge_result = tk.StringVar(value='')
self.v_pack_result = tk.StringVar(value='')
self._build() self._build()
self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接') self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接')
@@ -159,8 +168,11 @@ class App(ctk.CTk):
ctk.CTkLabel(top, text=APP_NAME, font=TITLE_FONT).pack(side='left') ctk.CTkLabel(top, text=APP_NAME, font=TITLE_FONT).pack(side='left')
ctk.CTkLabel(top, text='GD32 固件烧录', ctk.CTkLabel(top, text='GD32 固件烧录',
text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
ctk.CTkButton(top, text='打包 (v2)', width=96, height=26,
command=self._open_pack_dialog).pack(side='left', padx=(16, 6),
pady=(4, 0))
ctk.CTkButton(top, text='补齐 (Pad)', width=88, height=26, ctk.CTkButton(top, text='补齐 (Pad)', width=88, height=26,
command=self._open_pad_dialog).pack(side='left', padx=(16, 6), command=self._open_pad_dialog).pack(side='left', padx=(6, 6),
pady=(4, 0)) pady=(4, 0))
ctk.CTkButton(top, text='拼接 (Merge)', width=100, height=26, ctk.CTkButton(top, text='拼接 (Merge)', width=100, height=26,
command=self._open_merge_dialog).pack(side='left', pady=(4, 0)) command=self._open_merge_dialog).pack(side='left', pady=(4, 0))
@@ -288,7 +300,7 @@ class App(ctk.CTk):
self.btn_stop.grid(row=1, column=2, sticky='ew', padx=3, pady=3) self.btn_stop.grid(row=1, column=2, sticky='ew', padx=3, pady=3)
# ============================================================ # ============================================================
# 固件工具弹窗: 补齐 / 拼接 # 固件工具弹窗: 打包(v2) / 补齐 / 拼接
# ============================================================ # ============================================================
def _center_dialog(self, dlg, w, h): def _center_dialog(self, dlg, w, h):
"""弹窗叠在主窗口中上部""" """弹窗叠在主窗口中上部"""
@@ -297,6 +309,41 @@ class App(ctk.CTk):
y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 4) y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 4)
dlg.geometry(f'{w}x{h}+{x}+{y}') dlg.geometry(f'{w}x{h}+{x}+{y}')
def _open_pack_dialog(self):
dlg = ctk.CTkToplevel(self)
dlg.title('固件工具 — 打包 (v2 双固件)')
dlg.resizable(False, False)
dlg.transient(self)
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 / 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))
ctk.CTkLabel(row, text='Profile:', width=150, anchor='w').pack(side='left')
ctk.CTkComboBox(row, values=list(fw_pack.BUILTIN_PROFILES),
variable=self.v['pack_profile']).pack(
side='left', fill='x', expand=True)
ctk.CTkButton(row, text='浏览 JSON', width=84,
command=self._browse_pack_profile).pack(side='left', padx=(6, 0))
self._field(box, 'Bootloader (BL)', self.v['pack_bl'], self._browse_pack_bl)
self._field(box, 'A 包固件 (运行槽)', self.v['pack_a'], self._browse_pack_a)
self._field(box, 'B 包固件 (保底槽)', self.v['pack_b'], self._browse_pack_b)
self._field(box, '输出目录', self.v['pack_out'], self._browse_pack_out)
opts = ctk.CTkFrame(box, fg_color='transparent')
opts.pack(fill='x', pady=(6, 0))
ctk.CTkCheckBox(opts, text='生成 FULL 整片镜像', variable=self.v['pack_full'],
checkbox_width=18, checkbox_height=18).pack(side='left')
ctk.CTkButton(opts, text='执行打包', width=96,
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(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)
def _open_pad_dialog(self): def _open_pad_dialog(self):
dlg = ctk.CTkToplevel(self) dlg = ctk.CTkToplevel(self)
dlg.title('固件工具 — 补齐 (Pad)') dlg.title('固件工具 — 补齐 (Pad)')
@@ -501,6 +548,38 @@ class App(ctk.CTk):
if path: if path:
self.v['merge_out'].set(path) self.v['merge_out'].set(path)
def _browse_pack_profile(self):
path = filedialog.askopenfilename(
title='选择 profile JSON',
filetypes=[('Profile JSON', '*.json'), ('所有文件', '*.*')])
if path:
self.v['pack_profile'].set(path)
def _browse_pack_bl(self):
path = filedialog.askopenfilename(
title='选择 Bootloader BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['pack_bl'].set(path)
def _browse_pack_a(self):
path = filedialog.askopenfilename(
title='选择 A 包固件 BIN (运行槽)', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['pack_a'].set(path)
if not self.v['pack_out'].get().strip():
self.v['pack_out'].set(os.path.dirname(path))
def _browse_pack_b(self):
path = filedialog.askopenfilename(
title='选择 B 包固件 BIN (保底槽)', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['pack_b'].set(path)
def _browse_pack_out(self):
path = filedialog.askdirectory(title='选择产物输出目录')
if path:
self.v['pack_out'].set(path)
def _on_chip_change(self, *_a): def _on_chip_change(self, *_a):
self.v['flash_size'].set(openocd_runner.chip_flash_size(self.v['chip'].get())) self.v['flash_size'].set(openocd_runner.chip_flash_size(self.v['chip'].get()))
self.v['target'].set(openocd_runner.chip_target(self.v['chip'].get())) self.v['target'].set(openocd_runner.chip_target(self.v['chip'].get()))
@@ -634,8 +713,40 @@ class App(ctk.CTk):
self._set_status('就绪') self._set_status('就绪')
# ============================================================ # ============================================================
# 固件工具: 补齐 / 拼接 # 固件工具: 打包(v2) / 补齐 / 拼接
# ============================================================ # ============================================================
def _on_pack(self):
"""v2 双固件打包: 与命令行共用 fw_pack.pack_all, 结果与校验流进主日志"""
profile = self.v['pack_profile'].get().strip()
a = self.v['pack_a'].get().strip()
b = self.v['pack_b'].get().strip()
bl = self.v['pack_bl'].get().strip()
out_dir = self.v['pack_out'].get().strip() or None
if not a and not b:
messagebox.showerror('缺少文件', '请至少选择 A 包或 B 包固件 BIN。')
return
for p, tag in ((a, 'A 包'), (b, 'B 包'), (bl, 'BL')):
if p and not os.path.isfile(p):
messagebox.showerror('缺少文件', f'{tag}文件不存在:\n{p}')
return
self._log('== 打包 (v2) ==')
try:
outcome = fw_pack.pack_all(
profile, a or None, b_path=b or None, bl_path=bl or None,
out_dir=out_dir, make_full=self.v['pack_full'].get(),
progress=lambda line: self._log_q.put(('log', ' ' + line)))
except fw_pack.FwPackError as exc:
messagebox.showerror('打包失败', str(exc))
self._log('打包失败: %s' % exc)
return
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()))
self._set_status('打包完成')
self._save()
def _on_pad(self): def _on_pad(self):
src = self.v['pad_in'].get().strip() src = self.v['pad_in'].get().strip()
dst = self.v['pad_out'].get().strip() dst = self.v['pad_out'].get().strip()
@@ -737,6 +848,7 @@ class App(ctk.CTk):
ctk.CTkLabel(box, text=f'v{APP_VERSION} — GD32E230 固件烧录与打包工具', ctk.CTkLabel(box, text=f'v{APP_VERSION} — GD32E230 固件烧录与打包工具',
text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12)) text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12))
for line in ('OpenOCD 烧录: 检测 / 烧录 / 擦除 / 读取 / 复位 (DAPLink·GD-Link·ST-Link·J-Link)', for line in ('OpenOCD 烧录: 检测 / 烧录 / 擦除 / 读取 / 复位 (DAPLink·GD-Link·ST-Link·J-Link)',
'打包 v2: 双固件 profile 布局 → UPDATE / FACTORY / FULL 镜像 + MD5 清单',
'补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem', '补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem',
'拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像', '拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像',
'发布包内置 OpenOCD 工具链, 免安装解压即用', '发布包内置 OpenOCD 工具链, 免安装解压即用',
+12 -3
View File
@@ -28,6 +28,9 @@ BASE = os.path.dirname(SRC)
DIST = os.path.join(BASE, 'release') DIST = os.path.join(BASE, 'release')
WORK = os.path.join(SRC, 'build') WORK = os.path.join(SRC, 'build')
ICO = os.path.join(SRC, 'logos', 'logo_flash.ico') ICO = os.path.join(SRC, 'logos', 'logo_flash.ico')
# 仓库根: fw_pack.py(v2 打包公共库)的规范位置, PyInstaller 需要显式加入
# 搜索路径并声明 hiddenimport(源码里是带 sys.path 回退的动态导入)
REPO_ROOT = os.path.dirname(BASE)
def ensure_pyinstaller(): def ensure_pyinstaller():
@@ -41,13 +44,19 @@ def ensure_pyinstaller():
def extra_collect_args(): def extra_collect_args():
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错""" """CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错;
fw_pack 是仓库根的公共库, 需进 module 搜索路径 + hiddenimport"""
args = []
try: try:
import customtkinter # noqa: F401 import customtkinter # noqa: F401
return ['--collect-all', 'customtkinter'] args += ['--collect-all', 'customtkinter']
except ImportError: except ImportError:
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!') print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
return [] if os.path.isfile(os.path.join(REPO_ROOT, 'fw_pack.py')):
args += ['--paths', REPO_ROOT, '--hiddenimport', 'fw_pack']
else:
print('警告: 仓库根缺少 fw_pack.py, 打包产物将无法使用"打包 v2"功能!')
return args
def remove_stale(out_dir, name): def remove_stale(out_dir, name):
+10
View File
@@ -56,6 +56,13 @@ DEFAULTS = {
"flag_addr": "0x0800FFFC", "flag_addr": "0x0800FFFC",
"merge_flash": "0x10000", "merge_flash": "0x10000",
"flag_value": "0xEEEEEEEE", "flag_value": "0xEEEEEEEE",
# 打包 (v2 双固件, fw_pack)
"pack_profile": "CeramixNano_MB_Dual_FW",
"pack_bl": "",
"pack_a": "",
"pack_b": "",
"pack_out": "",
"pack_full": True,
} }
@@ -73,6 +80,9 @@ def _legacy_settings():
return path return path
# 旧内置 profile 名已废弃: 配置里若残留旧名, 会在加载 profile 时报错提示重选
def load_settings(): def load_settings():
"""读配置, 带默认值兜底; 新配置不存在时尝试从旧版 per-host 配置迁移""" """读配置, 带默认值兜底; 新配置不存在时尝试从旧版 per-host 配置迁移"""
data = dict(DEFAULTS) data = dict(DEFAULTS)
+58 -1
View File
@@ -26,6 +26,8 @@ DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 — 入口与命
python flash_gui.py --reset 复位运行 python flash_gui.py --reset 复位运行
python flash_gui.py --pad app.bin 补齐到 1K 边界(app_pad.bin) 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 --merge bl.bin app.bin 拼接整片镜像(*_BL.bin)
python flash_gui.py --pack a.bin b.bin --pack-bl bl.bin
v2 双固件打包(UPDATE/FULL/FACTORY+MD5)
python flash_gui.py --version 打印 OpenOCD 版本 python flash_gui.py --version 打印 OpenOCD 版本
""" """
@@ -40,6 +42,14 @@ import openocd_runner
from fw_image import (APP_ADDR_DEFAULT, FLASH_BASE_DEFAULT, FLAG_ADDR_DEFAULT, from fw_image import (APP_ADDR_DEFAULT, FLASH_BASE_DEFAULT, FLAG_ADDR_DEFAULT,
FLAG_VALUE_DEFAULT, merge_firmware, pad_bin, parse_int) FLAG_VALUE_DEFAULT, merge_firmware, pad_bin, parse_int)
# fw_pack (v2 双固件打包公共库) 位于仓库根目录: 源码模式补 sys.path,
# 打包模式(PyInstaller --paths+hiddenimport)由运行库提供
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if os.path.isfile(os.path.join(_REPO_ROOT, 'fw_pack.py')) \
and _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
import fw_pack
def _toolchain(args): def _toolchain(args):
"""命令行工具链解析: 参数 > 配置文件 > 自动定位(内置/本机/PATH)""" """命令行工具链解析: 参数 > 配置文件 > 自动定位(内置/本机/PATH)"""
@@ -121,6 +131,40 @@ def run_merge(args):
return 0 return 0
def run_pack(args):
"""v2 双固件打包(GUI '打包 v2' 的命令行形态, 共用 fw_pack.pack_all)"""
try:
fw_pack.self_test()
profile = fw_pack.load_profile(args.pack_profile)
except fw_pack.FwPackError as exc:
sys.exit(f'{exc}')
found = {}
for path in args.pack:
if not os.path.isfile(path):
sys.exit(f'找不到文件: {path}')
try:
slot = fw_pack.detect_slot(open(path, 'rb').read(), profile)
except fw_pack.FwPackError as exc:
sys.exit(f'{path}: {exc}')
if slot in found:
sys.exit(f'{path} 与 {found[slot]} 都被识别为 {slot.upper()} 包, '
f'一个槽只能有一个输入')
found[slot] = path
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_factory=not args.pack_no_factory)
except fw_pack.FwPackError as exc:
sys.exit(f'打包失败: {exc}')
return 0
def _ensure_parent(path): def _ensure_parent(path):
parent = os.path.dirname(os.path.abspath(path)) parent = os.path.dirname(os.path.abspath(path))
if parent: if parent:
@@ -145,9 +189,20 @@ def main():
op.add_argument('--pad', metavar='FILE', dest='pad', help='补齐 BIN 到页边界') op.add_argument('--pad', metavar='FILE', dest='pad', help='补齐 BIN 到页边界')
op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'), op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'),
help='拼接 Bootloader + APP → 整片镜像') help='拼接 Bootloader + APP → 整片镜像')
op.add_argument('--pack', nargs='+', metavar='BIN',
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 不拼 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('--align', default='1024', help='--pad 对齐字节(默认 1024)')
ap.add_argument('--pad-byte', default='0xFF', help='--pad 填充字节(默认 0xFF)') ap.add_argument('--pad-byte', default='0xFF', help='--pad 填充字节(默认 0xFF)')
ap.add_argument('-o', '--output', help='--pad/--merge 输出路径(默认自动命名)') ap.add_argument('-o', '--output',
help='输出路径: --pad/--merge 为文件, --pack 为目录(默认自动命名)')
ap.add_argument('--app-addr', default='0x%08X' % APP_ADDR_DEFAULT, ap.add_argument('--app-addr', default='0x%08X' % APP_ADDR_DEFAULT,
help='--merge APP 地址(默认 0x08002000)') help='--merge APP 地址(默认 0x08002000)')
ap.add_argument('--flag-addr', default='0x%08X' % FLAG_ADDR_DEFAULT, ap.add_argument('--flag-addr', default='0x%08X' % FLAG_ADDR_DEFAULT,
@@ -174,6 +229,8 @@ def main():
ap.add_argument('--scripts', help='OpenOCD scripts 目录(默认自动定位)') ap.add_argument('--scripts', help='OpenOCD scripts 目录(默认自动定位)')
args = ap.parse_args() args = ap.parse_args()
if args.pack:
sys.exit(run_pack(args))
if args.pad: if args.pad:
sys.exit(run_pad(args)) sys.exit(run_pad(args))
if args.merge: if args.merge:
+131 -150
View File
@@ -1,34 +1,130 @@
# GD32E230 固件工具 # GD32E230 固件工具
本仓库提供 GD32E230 系列固件的处理与烧录工具: 本仓库提供 GD32E230 系列固件的打包与烧录工具:
- **DAPLink Flash GUI(推荐)**:图形界面烧录工具,位于 `FlashTool/` - **v2 双固件打包(默认)**:`gd32e230_firmware_tool.py` + `fw_pack.py` 公共库——
- **命令行固件工具**:`gd32e230_firmware_tool.py`(合成 + 补齐二合一) 布局 profile 化,输入编译好的 bin 自动识别 A/B 槽,产出 OTA 包 / B 底包 /
- **旧版脚本**:`merge_bin.py`、`fill_bin.py`(保留兼容) 整片镜像 + MD5 清单,写盘后回读复核;
- **DAPLink Flash GUI(推荐)**:图形界面烧录工具(含 v2 打包弹窗),位于 `FlashTool/`;
- **legacy 单槽模式(过渡兼容)**:`-f` 纯 0xFF 补页 / `-m` 拼 BL+APP+flag;
- **旧版脚本**:`merge_bin.py`、`fill_bin.py`(保留兼容)。
## Flash 布局 ## v2 双固件打包
### 布局(默认 profile `CeramixNano_MB_Dual_FW`,GD32E230F8/64K)
```text ```text
0x08000000 ┌──────────────────┐ 0x08000000 ┌──────────────────┐
│ Bootloader │ 8KB │ Bootloader │ 8K 页 0~7
0x08002000 ├──────────────────┤ 0x08002000 ├──────────────────┤
│ │ │ FW_A 运行槽 │ 36K 页 8~43 OTA/Ymodem 唯一接收区
│ Application │ 最大 54KB (F8) / 22KB (F6) / 6KB (F4) 0x0800AFFC │ [槽尾锚点]──────│ ← 与 trailer 同值 {页数16|CRC16}
│ │ 0x0800B000 ├──────────────────┤
0x0800F800 ├──────────────────┤ │ FW_B 出厂保底槽 │ 19K 页 44~62 固定满槽,产线冻结
│ 校准数据 │ 1KB (预留) 0x0800FBF8 │ [B 记录 8B]─────│ ← magic|页数|CRC|2B 保留
0x0800FC00 ├──────────────────┤ 0x0800FC00 ├──────────────────┤
│ 0xEEEE 标志 │ 1KB │ 0xEEEE 标志 │ 1K 页 63 @0x0800FFFC
0x08010000 └──────────────────┘ 0x08010000 └──────────────────┘
``` ```
- 空白区域填充 `0xFF`(Flash 擦除默认值) ### 计算规则(与双固件 bootloader 逐字一致)
- 标志位 `0xEEEE` 告诉 Bootloader:APP 有效,直接跳转
- 校准页当前为预留空间 - **CRC-16/XMODEM**(poly 0x1021、init 0x0000、不反转、无 xorout),
启动自检向量 `"123456789" → 0x31C3`;
- **A 包**:补 `0xFF` 至整 N 页,末 4B 写 trailer `{页数N(低16)|CRC(高16)}`;
CRC 范围 = N 页内容去掉末 4B(含 0xFF 填充)。**整页扩展**:body 恰为整页
倍数、或余量 < 4B → 向后扩 1 页;扩展后须 ≤ 槽容量,否则报错;
- **B 包**:补 `0xFF` 恰好填满槽(19,456B),槽末 8B 写记录
`{magic16=0xC0DE, 页数16, CRC16, 2B 0xFF}`;CRC 范围 = 槽内容去掉末 8B;
**固件 > 槽-8B 直接报错**(固定槽不可扩展);
- **FULL**:BL + A 包 + 槽尾锚点 + 空隙 0xFF + B 满槽 + `0xEEEE` 标志。
### 快速开始
```bash
# 全产物: 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 --no-full --no-factory
# 只打工厂烧录镜像(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/记录/锚点一致才报成功),并输出布局摘要与逐项字节校验
(FULL 19 项 / FACTORY 12 项)。
### 产物
| 产物 | 用途 |
|---|---|
| `*_UPDATE.bin` | A 包(补页+trailer),Ymodem/OTA 推送 |
| `*_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`,
FACTORY 为 `<项目>_FACTORY_B-V<版本>[_日期].bin`。不再单独输出 B 包文件
(B 内容已包含在 FULL/FACTORY 中)。
### 布局 profile(可复用到任意工程)
内置 `CeramixNano_MB_Dual_FW`(默认)与 `f8_single_slot`(无保底槽)。其他工程写一份
JSON 即可(保底槽 8K/28K、无保底槽、BL 更大等都支持),工具做完整性校验
(分区不重叠、页对齐、总和=flash_size):
```json
{
"profile": "CeramixNano_MB_Dual_FW",
"chip": "GD32E230F8",
"flash_base": "0x08000000",
"flash_size": "64K",
"page_size": 1024,
"bl": { "addr": "0x08000000", "size": "8K" },
"slot_a": { "addr": "0x08002000", "size": "36K",
"anchor": "slot_end-4", "record": "{pages16@+0, crc16@+2}" },
"slot_b": { "addr": "0x0800B000", "size": "19K", "fixed_slot": true,
"record": "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}",
"record_addr": "slot_end-8" },
"flag": { "page": "last", "addr": "0x0800FFFC", "value": "0xEEEE" },
"crc": "xmodem"
}
```
```bash
python gd32e230_firmware_tool.py app.bin --profile my_layout.json # 自定义布局
python gd32e230_firmware_tool.py app.bin --profile f8_single_slot # 无保底槽
```
### v2 参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `bins...` | (可选) | 输入 bin,A/B 包按向量表自动识别 |
| `--a BIN` / `--b BIN` | — | 显式指定 A/B 包(与自动识别冲突时报错) |
| `-b, --bootloader` | — | BL bin(参与 FULL 拼接;缺省仅警告) |
| `--profile` | `CeramixNano_MB_Dual_FW` | 内置名或 profile JSON 路径 |
| `-o, --output` | 输入所在目录 | **输出目录**(产物自动命名) |
| `--no-full` | — | 不拼 FULL 整片镜像 |
| `--no-factory` | — | 不出 FACTORY 工厂镜像 |
### 回归测试
```bash
python test_fw_pack.py # 55 项: 规格基准 + 边界 + 篡改检测 + CLI 冒烟
```
覆盖 UPGRADE_PLAN_v2.md 第五节全部基准(CeramixNano 1.0.17/1.0.18/V1.0.12
实测值逐字节复现、恰整页/余量<4 扩页等);真机固件不在本仓库时相关用例自动跳过。
## DAPLink Flash GUI(推荐) ## DAPLink Flash GUI(推荐)
日常烧录优先使用图形界面工具,不需要打开 VSCode,也不需要记忆 OpenOCD 命令。 日常烧录优先使用图形界面工具,不需要打开 VSCode,也不需要记忆 OpenOCD 命令。
v2.6.0 起顶栏新增**"打包 (v2)"**弹窗(profile 选择 + BL/A/B 输入 + 输出目录),
与命令行共用 `fw_pack.py`,结果与逐项校验流进主日志。
- **免安装版**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases) - **免安装版**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压后进入 `DAPLinkFlash` 文件夹 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压后进入 `DAPLinkFlash` 文件夹
@@ -36,165 +132,50 @@
(`toolchain/openocd/`),目标机器无需任何环境。 (`toolchain/openocd/`),目标机器无需任何环境。
- **源码运行**:`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat` - **源码运行**:`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat`
(需 Python 3.10+ 与 `pip install customtkinter`;OpenOCD 默认找 `D:/toolchain`)。 (需 Python 3.10+ 与 `pip install customtkinter`;OpenOCD 默认找 `D:/toolchain`)。
- **命令行打包**:`python FlashTool/src/flash_gui.py --pack a.bin b.bin --pack-bl bl.bin -o outdir`
详细说明见 [FlashTool/README.md](FlashTool/README.md)(GUI 截图说明、命令行模式、 详细说明见 [FlashTool/README.md](FlashTool/README.md)。
构建与发布流程)。下面是旧单文件版说明保留的要点:
### 功能 ## legacy 单槽模式(过渡兼容)
- 烧录操作:检测设备、整片擦除、烧录固件、读取 Flash、复位芯片、停止 旧单槽方案(LDC1612 等,布局:BL 8K + APP 54K + 校准页 1K + 标志页 1K),
- 固件格式:BIN / HEX / ELF `-f`/`-m` 触发:
- BIN:手动填写烧录基地址
- HEX / ELF:自动使用文件内地址
- 芯片型号:GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小和 target 配置
- 烧录器:DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link
- 烧录参数:适配器速度、探针序列号、烧录前擦除、烧录后校验、烧录后复位运行
- 固件处理:补齐(1K / 2K / 4K)、拼接 Bootloader + APP + 有效标志
- 工具链自动定位:内置(exe 旁 `toolchain/openocd`)→ 本机 `D:/toolchain` → PATH
### 配置
- 配置文件:`flash_gui_settings.json`,保存在程序目录(exe 旁 / FlashTool 根),
自动保存与恢复
- 旧版 `config/flash_gui_<主机名>.json` 在新版首次启动时自动迁移
### 使用步骤
1. 连接烧录器(DAPLink / GD-Link 等)到目标板 SWD:SWDIO、SWCLK、GND
2. 启动 GUI,选择芯片型号和烧录器型号
3. 选择固件文件并确认地址:
- 完整镜像(bootloader + app):`0x08000000`
- 单独烧录 APP:`0x08002000`
4. 点击“检测设备”确认连接正常
5. 点击“烧录固件”
> Bootloader 的 Y-Modem 只按 1K 整包接收,OTA 升级包发送前请先用“补齐”功能处理。
## 命令行固件工具
### 快速开始
```bash ```bash
# 合成完整镜像(整片烧录用) python gd32e230_firmware_tool.py -f app.bin # 纯 0xFF 补页, 无 trailer → *_UPDATE.bin
python gd32e230_firmware_tool.py -m app.bin python gd32e230_firmware_tool.py -m app.bin # BL+APP+0xEEEE → *_BL.bin
python gd32e230_firmware_tool.py -m app.bin -s F6 # F6 芯片
# 补齐升级包(Ymodem 升级用) python gd32e230_firmware_tool.py -m app.bin -a 0x0800B000 -b bl.bin # FW_B 槽落位
python gd32e230_firmware_tool.py -f app.bin
``` ```
### 模式说明
#### `-m` / `--merge` — 合成完整镜像
将 Bootloader + APP + FLAG 合并为一个 Flash 镜像,直接整片烧录。**不补齐**(整片烧录无需考虑页对齐)。
```bash
python gd32e230_firmware_tool.py -m CeramixNano-MB_V1.0.9.bin
```
输出:`CeramixNano-MB_V1.0.9_BL.bin`
#### `-f` / `--fill` — 补齐升级包
将 APP 尾部补 `0xFF` 到 1KB 页边界,供 Bootloader 的 Ymodem 接收端使用。补齐后自动检查是否超过芯片 APP 容量上限。
```bash
python gd32e230_firmware_tool.py -f CeramixNano-MB_V1.0.9.bin
```
输出:`CeramixNano-MB_V1.0.9_UPDATE.bin`
### 参数
| 参数 | 默认值 | 说明 | | 参数 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| `app` | (必填) | APP 的 .bin 文件 | | `-m, --merge` | — | 合成完整 Flash 镜像(不补齐) |
| `-m, --merge` | — | 合成完整 Flash 镜像 | | `-f, --fill` | — | 补齐到整页边界(纯 0xFF) |
| `-f, --fill` | — | 补齐到整页边界 |
| `-s, --flash-size` | `F8` | 芯片型号:`F4`(16K) `F6`(32K) `F8`(64K) | | `-s, --flash-size` | `F8` | 芯片型号:`F4`(16K) `F6`(32K) `F8`(64K) |
| `-b, --bootloader` | `gd32e230f8_bootloader_hulk.bin` | Bootloader .bin 路径 | | `-b, --bootloader` | 内置 `gd32e230f8_bootloader_hulk.bin` | Bootloader .bin 路径 |
| `-o, --output` | 自动生成 | 输出文件路径 | | `-o, --output` | 自动生成 | **输出文件**路径 |
| `-h, --help` | — | 查看帮助 | | `-a, --app-addr` | `0x08002000` | APP 落位地址(1K 页对齐) |
> **`-m` 和 `-f` 互斥**,每次只能选一种模式。 > `-m` 与 `-f` 互斥;新工程请用 v2 模式(不带 `-f/-m`)。
### 使用示例
```bash
# F8 合成镜像(默认)
python gd32e230_firmware_tool.py -m CeramixNano-MB_V1.0.9.bin
# F6 合成镜像
python gd32e230_firmware_tool.py -m app.bin -s F6
# F8 补齐升级包(默认)
python gd32e230_firmware_tool.py -f app.bin
# F4 补齐升级包
python gd32e230_firmware_tool.py -f app.bin -s F4
# 自定义 Bootloader 和输出路径
python gd32e230_firmware_tool.py -m app.bin -b my_bootloader.bin -o release.bin
```
### 输出示例
**merge 模式:**
```text
芯片: GD32E230F8 (64KB)
合成完成: CeramixNano-MB_V1.0.9_BL.bin
芯片 : 64KB
Bootloader : 3272B @ 0x08000000
Application: 25600B @ 0x08002000
Flag : 0xEEEE @ 0x0800FFFC
校准预留 : 1KB @ 0x0800F800
APP 剩余 : 29696B
总大小 : 65536B (64KB)
```
**fill 模式:**
```text
芯片: GD32E230F8 (64KB)
补齐完成: app_UPDATE.bin
原始大小: 25000B
页大小 : 1024B (1KB)
补齐字节: 600B
输出大小: 25600B
APP 上限: 55296B (54KB) → 剩余 29696B
```
**超限拦截:**
```text
错误:APP (60000B / 58.6KB) 超过最大容量 (55296B / 54KB)
芯片 64KB: 64KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) = 54KB
```
### 旧版脚本 ### 旧版脚本
`merge_bin.py` / `fill_bin.py` 为旧版命令行脚本,功能已合并进 `gd32e230_firmware_tool.py`,历史说明见 [README_old.md](README_old.md)。 `merge_bin.py` / `fill_bin.py` 为旧版命令行脚本,功能已合并进 `gd32e230_firmware_tool.py`,
历史说明见 [README_old.md](README_old.md)。带 trailer 的临时打包脚本
已在 v2 落地后删除(能力并入 `fw_pack.py`)。
### 烧录 ### 烧录
```bash ```bash
# OpenOCD # OpenOCD
openocd -f interface/cmsis-dap.cfg -f target/gd32e23x.cfg \ openocd -f interface/cmsis-dap.cfg -f target/gd32e23x.cfg \
-c "init; reset halt; flash write_image erase merged.bin 0x08000000; reset; exit" -c "init; reset halt; flash write_image erase FULL.bin 0x08000000; reset; exit"
# J-Link # J-Link
JFlash -openprj project.jflash -open merged.bin,0x08000000 -auto -exit JFlash -openprj project.jflash -open FULL.bin,0x08000000 -auto -exit
``` ```
### 安全校验
- Bootloader 体积不超过 APP 起始偏移(8KB)
- APP 大小不超过芯片可用空间
- fill 模式补齐后再次校验容量上限
- 芯片不支持自动检测,需通过 `-s` 手动指定
## License ## License
MIT MIT
+113
View File
@@ -0,0 +1,113 @@
# gd32e230f8_firmware_merge_tool v2 升级计划(需求规格 / 提示词)
> 本文可直接作为 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 包文件。
---
## 一、背景与目标
现有 `gd32e230_firmware_tool.py` 是单槽时代的工具:布局硬编码(APP@0x08002000、标志页、校准页),只有两种模式(`-f` 纯 0xFF 补页、`-m` 拼 BL+APP+flag)。现已演进为**双固件方案**(BL + FW_A 运行槽 + FW_B 出厂保底槽 + 记录/CRC),临时脚本(`make_v5_update.py`、拼 FULL 的临时逻辑)散落各处,需要彻底整合升级。
**目标**:一个布局可配置、可复用到任意 GD32E230 工程(不同 Flash 型号、不同槽大小、可有/可无保底槽)的固件打包工具:输入编译好的 bin,自动识别槽位、补页、计算长度与 CRC、写记录/trailer/标志,产出完整可烧录镜像与 OTA 包。
## 二、现状与痛点
1. 布局硬编码(0x08002000/末页标志/校准页),换工程即失效;
2. 无 A/B 双槽概念,无 trailer/记录/CRC 计算;
3. 新增的 `-a/--app-addr` 只是补丁,无法描述 B 记录、锚点等;
4. GUI(FlashTool)拼接逻辑同样固定 0x08002000;
5. 临时脚本(make_v5_update.py 等)逻辑未固化,存在两套真相。
## 三、核心设计:布局 Profile
用 JSON 配置文件描述一个工程的 Flash 布局(CLI 也可覆盖单项)。示例(CeramixNano-MB v5.1):
```json
{
"profile": "ceramixnano_mb_v51",
"chip": "GD32E230F8",
"flash_base": "0x08000000",
"flash_size": "64K",
"page_size": 1024,
"bl": { "addr": "0x08000000", "size": "8K" },
"slot_a": { "addr": "0x08002000", "size": "36K",
"anchor": "slot_end-4", "record": "{pages16@+0, crc16@+2}" },
"slot_b": { "addr": "0x0800B000", "size": "19K", "fixed_slot": true,
"record": "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}",
"record_addr": "slot_end-8" },
"flag": { "page": "last", "addr": "0x0800FFFC", "value": "0xEEEE" },
"crc": "xmodem"
}
```
**可复用性要求**:其他工程仅换 profile——例如保底槽 8K 或 28K、无保底槽(`slot_b: null`)、BL 更大等;所有地址/大小支持 `0x` 十六进制与 `K` 后缀;工具对 profile 做完整性校验(分区不重叠、页对齐、总和=flash_size)。
## 四、功能规格
### 4.1 输入与自动识别
- 输入为**编译好的 bin**:底包(LD/向量配置在 B 槽基址,如 0x0800B000)与升级包(配置在 A 槽基址,如 0x08002000)。
- 工具从**向量表自动识别**:初始 SP ∈ SRAM、复位向量落在哪个槽区间 → 判定该 bin 属于 A 包还是 B 包;与显式参数冲突时报错。
- BL bin(可选)、底包 bin(可选)作为输入项;BL 需校验 ≤ `bl.size`。
### 4.2 计算规则(与 BL dev_fact 侧逐字一致,不得偏离)
- **CRC-16/XMODEM**(poly 0x1021、init 0x0000、不反转、无 xorout;测试向量 `"123456789"` → `0x31C3`——工具启动自检)。
- **A 包(OTA/升级包)**:补 0xFF 至整 N 页,末 4B 写 trailer `{页数N(低16), CRC(高16)}`,CRC 范围 = **N 页内容去掉末 4B**(含 0xFF 填充)。
- **槽尾锚点**(FULL 镜像用):A 槽末 4B(`slot_a` 末)写与 trailer 同值——BL 收包后会独立算出同值交叉验证。
- **B 包(底包,固定槽)**:补 0xFF 至**恰好填满槽**(如 19,456B),槽末 8B 写记录 `{magic16, pages16, crc16, 2B 0xFF}`,CRC 范围 = 槽内容去掉末 8B。**若 B 固件 > 槽−8B → 报错拒绝**(固定槽不可向后扩展,这是与 A 包的本质区别)。
- **flag**:`0xEEEE` 写 `flag.addr`(FULL 镜像)。
### 4.3 整页扩展规则(重点)
对动态补页的 A 包:设记录长度 R(A=4B trailer),
- `余量 = N_pages*1024 − body_len`;
- 若 `body_len` 恰为整页倍数,或 `余量 < R` → **向后扩展一页**(N_pages+1,全 0xFF 补齐后写记录);
- 扩展后仍须 ≤ `slot_a.size`,否则报错。
(B 包为固定槽,不适用扩展,见 4.2;若未来某工程 B 也动态化,须在 profile 中用 `fixed_slot:false` 显式声明并用同一规则。)
### 4.4 产物
1. `*_FULL.bin`:整片镜像 = BL + FW_A(补页+trailer+锚点) + 空隙0xFF + FW_B(满槽+记录) + flag;输出布局摘要与 MD5 清单(`*.md5.txt`)。
2. `*_UPDATE.bin`:A 包(补页+trailer),供 Ymodem/OTA。
3. `*_B.bin`(可选):满槽 B 包单独输出(产线备用)。
4. 兼容模式:`--legacy` 产出旧格式(纯 0xFF 补页、无 trailer),过渡期用。
### 4.5 校验(打包前硬门槛)
- 向量表:SP ∈ [0x20000000, 0x20002000];复位向量 Thumb 且落在声明槽内;
- 尺寸:A 包 ≤ 槽;B 包 = 槽(含记录);BL ≤ `bl.size`;
- 写盘后**回读复核**(重算 CRC/trailer 一致才报成功);
- 输出布局摘要表(各分区起止/大小/页号/记录值)供人工核对。
## 五、回归基准(本项目实测值,重构后必须逐项复现)
| 用例 | 期望 |
|---|---|
| 1.0.17 原始 bin 19,580B | A 包 20 页/20,480B,trailer=锚点=`0xD30F0014` |
| 1.0.18 原始 bin 19,580B | A 包 20 页,trailer=`0x6F380014`(CRC 0x6F38) |
| V1.0.12 B bin 19,456B(尾部已含 magic) | B 记录 = `DE C0 13 00 6F 18 FF FF`(pages=19,CRC=0x186F) |
| FULL 镜像 65,536B | 17 项字节校验全过(分区内容/空隙 0xFF/记录/flag) |
| 恰整页用例(构造 body=20480B) | 扩展为 21 页,trailer pages=21 |
| 余量<4B 用例(body=20477B) | 扩展为 21 页 |
## 六、GUI 与配套同步
- FlashTool(DAPLinkFlash):拼接/补齐弹窗升级为 profile 选择 + 双槽模式,与新 CLI 共用同一打包模块(抽公共库 `fw_pack.py`,CLI 与 GUI 仅为壳);
- 本仓库 README 更新;`AGENTS.md` 进度同步;
- skill `hulk-mcu-firmware`:改开发仓库 → push Gitea → `python scripts/update_tool.py` 同步缓存,SKILL.md 流程描述同步改。
## 七、实施步骤建议
1. 抽公共库 `fw_pack.py`(profile 解析/校验/计算/复核),CLI 改造调用之,先跑通第五节回归基准;
2. 新增 `--profile`(默认内置 ceramixnano_mb_v51),保留 `--legacy`;
3. GUI 接入;
4. 文档/skill 同步、Gitea 发布。
---
## 八、提示词(可直接粘贴给 AI 会话)
"请重构 `gd32e230_firmware_tool.py`:按上文第三~五节实现布局 profile 化的双固件打包工具,抽公共库供 GUI 复用。所有 CRC/记录/补页/整页扩展规则必须与第四节逐字一致,并以第五节回归基准作为验收(构造测试 bin 跑通全部用例)。要求:纯标准库、Windows 兼容、输出布局摘要与 MD5 清单、打包后回读复核;保留 `--legacy` 兼容模式;不要动 `FlashTool/` 之外的无关文件;完成后更新 README 与 AGENTS.md。"
+977
View File
@@ -0,0 +1,977 @@
#!/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'^(?P<name>magic|pages|crc)(?P<bits>16|32)?@(?P<off>\+\d+)'
r'(?:=(?P<val>0x[0-9a-fA-F]+|\d+))?$')
_KNOWN_FIELDS = ('magic', 'pages', 'crc')
class RecordField:
def __init__(self, name, bits, offset, fixed):
self.name, self.bits, self.offset, self.fixed = name, bits, offset, fixed
class RecordSpec:
"""从 "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}" 解析出的记录布局"""
def __init__(self, fields):
self.fields = fields
@classmethod
def parse(cls, text, what='record'):
s = str(text).strip()
if not (s.startswith('{') and s.endswith('}')):
raise FwPackError(f'{what}: 必须形如 "{{pages16@+0, crc16@+2}}", 得到 {text!r}')
fields = []
for part in s[1:-1].split(','):
part = part.strip()
if not part:
continue
m = _RECORD_FIELD_RE.match(part)
if not m:
raise FwPackError(f'{what}: 无法解析字段 {part!r} '
f'(支持 magic16/pages16/crc16@+偏移[=固定值])')
fields.append(RecordField(m.group('name'), int(m.group('bits') or 16),
int(m.group('off')[1:]),
int(m.group('val'), 0) if m.group('val') else None))
if not fields:
raise FwPackError(f'{what}: 至少需要一个字段')
names = [f.name for f in fields]
for need in ('pages', 'crc'):
if need not in names:
raise FwPackError(f'{what}: 缺少 {need}16 字段')
return cls(fields)
@property
def length(self):
"""记录本体字节数(不含区内尾部保留字节)"""
return max(f.offset + f.bits // 8 for f in self.fields)
def pack(self, pages, crc, what='record'):
"""按布局写出记录字节; pages/crc 字段自动填入, 固定值字段校验存在"""
buf = bytearray(b'\xFF' * self.length)
for f in self.fields:
val = f.fixed if f.name == 'magic' else (
pages if f.name == 'pages' else crc if f.name == 'crc' else None)
if val is None:
raise FwPackError(f'{what}: 未知字段 {f.name}')
if f.name == 'crc':
val &= (1 << f.bits) - 1
if f.bits == 16:
struct.pack_into('<H', buf, f.offset, val & 0xFFFF)
else:
struct.pack_into('<I', buf, f.offset, val & 0xFFFFFFFF)
return bytes(buf)
class SlotSpec:
def __init__(self, name, addr, size, fixed_slot, record, record_addr, anchor_addr):
self.name = name
self.addr, self.size = addr, size
self.fixed_slot = fixed_slot # True: 固定满槽(B), 不可扩展
self.record = record # RecordSpec | None
self.record_addr = record_addr # 固定记录区地址(fixed_slot), None=记录随包尾
self.anchor_addr = anchor_addr # FULL 镜像槽尾锚点地址, None=无
@property
def end(self):
return self.addr + self.size
def record_area_len(self, page_size):
"""记录区长度: 固定槽取 [record_addr, slot_end); 动态槽取记录本体长"""
if self.fixed_slot and self.record_addr is not None:
return self.end - self.record_addr
return self.record.length
# ═══════════════════════════════════════════════════════════
# 内置 profile
# ═══════════════════════════════════════════════════════════
BUILTIN_PROFILES = {
# CeramixNano-MB 双固件 bootloader 的布局
'CeramixNano_MB_Dual_FW': {
'profile': 'CeramixNano_MB_Dual_FW',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '36K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
'record_addr': 'slot_end-8'},
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
# CeramixNano EDDY (LDC1612) 双固件布局——Flash 分配与 MB 完全相同;
# BL 用专属分支 bootloader_CeramixNano_EDDY 的 bin(LED=PB1,与 MB 分支并列、独立编号)
'CeramixNano_EDDY_Dual_FW': {
'profile': 'CeramixNano_EDDY_Dual_FW',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '36K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
'record_addr': 'slot_end-8'},
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
# 无保底槽的单槽布局 (旧 BL 升级过渡 / 无 B 槽工程)
'f8_single_slot': {
'profile': 'f8_single_slot',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '55K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': None,
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
}
class Profile:
"""解析并校验后的 Flash 布局"""
def __init__(self, raw):
if not isinstance(raw, dict):
raise FwPackError('profile 必须是 JSON 对象')
self.name = str(raw.get('profile') or '(未命名)')
self.chip = str(raw.get('chip') or 'GD32E230')
self.flash_base = parse_num(raw.get('flash_base', '0x08000000'), 'flash_base')
self.flash_size = parse_num(raw.get('flash_size'), 'flash_size')
self.page_size = parse_num(raw.get('page_size', 1024), 'page_size')
crc_algo = str(raw.get('crc', 'xmodem')).lower()
if crc_algo != 'xmodem':
raise FwPackError(f'crc 算法仅支持 xmodem, profile 声明 {crc_algo!r}')
bl = raw.get('bl') or {}
self.bl_addr = parse_num(bl.get('addr', self.flash_base), 'bl.addr')
self.bl_size = parse_num(bl.get('size', 0), 'bl.size')
self.bl_end = self.bl_addr + self.bl_size
sa = raw.get('slot_a') or {}
self.slot_a = SlotSpec(
'slot_a', parse_num(sa.get('addr'), 'slot_a.addr'),
parse_num(sa.get('size'), 'slot_a.size'),
bool(sa.get('fixed_slot', False)),
RecordSpec.parse(sa.get('record', '{pages16@+0, crc16@+2}'), 'slot_a.record'),
None, None)
if sa.get('record_addr'):
self.slot_a.record_addr = _parse_addr_expr(
sa['record_addr'], self.slot_a.addr, self.slot_a.end, 'slot_a.record_addr')
if sa.get('anchor'):
self.slot_a.anchor_addr = _parse_addr_expr(
sa['anchor'], self.slot_a.addr, self.slot_a.end, 'slot_a.anchor')
sb = raw.get('slot_b')
self.slot_b = None
if sb:
self.slot_b = SlotSpec(
'slot_b', parse_num(sb.get('addr'), 'slot_b.addr'),
parse_num(sb.get('size'), 'slot_b.size'),
bool(sb.get('fixed_slot', False)),
RecordSpec.parse(sb.get('record', '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}'),
'slot_b.record'),
None, None)
if sb.get('record_addr'):
self.slot_b.record_addr = _parse_addr_expr(
sb['record_addr'], self.slot_b.addr, self.slot_b.end, 'slot_b.record_addr')
elif self.slot_b.fixed_slot:
raise FwPackError('slot_b.fixed_slot 需要 record_addr (如 "slot_end-8")')
fl = raw.get('flag') or {}
self.flag_value = parse_num(fl.get('value', '0xEEEE'), 'flag.value') & 0xFFFFFFFF
self.flag_page = self.flash_base + self.flash_size - self.page_size # page:last
self.flag_addr = parse_num(fl.get('addr', self.flash_base + self.flash_size - 4),
'flag.addr')
self.validate()
# -- 校验: 页对齐 / 不重叠 / 恰好铺满 / 记录区合法 --
def validate(self):
pg, end = self.page_size, self.flash_base + self.flash_size
if self.page_size <= 0 or self.page_size & (self.page_size - 1):
raise FwPackError(f'page_size 必须是 2 的幂, 得到 {self.page_size}')
if self.flash_size <= 0 or self.flash_size % pg:
raise FwPackError(f'flash_size {self.flash_size} 不是页({pg})整数倍')
parts = [('BL', self.bl_addr, self.bl_end), ('slot_a', self.slot_a.addr, self.slot_a.end)]
if self.bl_addr != self.flash_base:
raise FwPackError(f'bl.addr 0x{self.bl_addr:08X} 必须等于 flash_base '
f'0x{self.flash_base:08X}')
for name, lo, hi in parts:
if lo % pg or hi % pg:
raise FwPackError(f'{name} 区间 [0x{lo:08X},0x{hi:08X}) 未页对齐({pg})')
if hi <= lo:
raise FwPackError(f'{name} 区间为空')
if self.slot_b:
parts.append(('slot_b', self.slot_b.addr, self.slot_b.end))
parts.append(('flag页', self.flag_page, end))
parts.sort(key=lambda p: p[1])
for (n1, l1, h1), (n2, l2, _h2) in zip(parts, parts[1:]):
if l2 < h1:
raise FwPackError(f'{n1} [0x{l1:08X},0x{h1:08X}) 与 {n2} '
f'[0x{l2:08X},...) 重叠')
if parts[0][1] != self.flash_base:
raise FwPackError(f'flash 起始 0x{self.flash_base:08X} 未被 BL 覆盖')
total = sum(hi - lo for _n, lo, hi in parts)
if total != self.flash_size:
missing = self.flash_size - total
raise FwPackError(f'分区总和 {total} != flash_size {self.flash_size} '
f'(缺口 {missing}B, 请调整槽大小补齐; 校准页等保留区'
f'需并入某个槽或单独声明)')
for i in range(len(parts) - 1):
if parts[i][2] != parts[i + 1][1]:
gap = parts[i + 1][1] - parts[i][2]
raise FwPackError(f'{parts[i][0]} 与 {parts[i + 1][0]} 之间有 {gap}B '
f'未分配空隙 (要求分区连续铺满)')
# flag
if not (self.flag_page <= self.flag_addr and self.flag_addr + 4 <= end):
raise FwPackError(f'flag.addr 0x{self.flag_addr:08X} 不在末页 '
f'[0x{self.flag_page:08X},0x{end:08X}) 内')
# 记录区
for slot in (self.slot_a, self.slot_b):
if slot is None or slot.record is None:
continue
area = slot.record_area_len(pg)
if area <= 0 or area > pg:
raise FwPackError(f'{slot.name}.record 记录区 {area}B 非法 (须 1~{pg}B)')
if slot.record.length > area:
raise FwPackError(f'{slot.name}.record 本体 {slot.record.length}B '
f'超过记录区 {area}B')
if slot.record_addr is not None and slot.record_addr + area != slot.end:
raise FwPackError(f'{slot.name}.record_addr+记录区 必须等于槽末'
f' (BL 约定记录位于槽尾)')
if slot.anchor_addr is not None and not (slot.addr <= slot.anchor_addr
and slot.anchor_addr + 4 <= slot.end):
raise FwPackError(f'{slot.name}.anchor 0x{slot.anchor_addr:08X} '
f'不在槽区间内')
def slot(self, which):
if which == 'a':
return self.slot_a
if which != 'b':
raise FwPackError(f'未知槽位 {which!r} (只能是 a/b)')
if self.slot_b is None:
raise FwPackError('当前 profile 未定义 slot_b (无保底槽布局)')
return self.slot_b
def describe(self):
pg = self.page_size
lines = [f'profile: {self.name} ({self.chip}, '
f'{self.flash_size // 1024}K, page {pg}B)']
first_pg = 0
rows = [('BL', self.bl_addr, self.bl_size, ''),
('FW_A', self.slot_a.addr, self.slot_a.size, 'OTA/Ymodem 接收槽')]
if self.slot_b:
rows.append(('FW_B', self.slot_b.addr, self.slot_b.size,
'出厂保底槽' + (' (固定满槽)' if self.slot_b.fixed_slot else '')))
rows.append(('FLAG', self.flag_page, pg,
f'0x{self.flag_value:04X} @ 0x{self.flag_addr:08X}'))
for name, addr, size, note in rows:
pages = f'页 {first_pg}~{first_pg + size // pg - 1}'
lines.append(f' {name:<5} 0x{addr:08X}-0x{addr + size - 1:08X} '
f'{size // 1024:>3}K {pages:<12}{note}')
first_pg += size // pg
return '\n'.join(lines)
def load_profile(spec):
"""内置名(CeramixNano_MB_Dual_FW / f8_single_slot) 或 profile JSON 文件路径"""
if not spec:
spec = 'CeramixNano_MB_Dual_FW'
if spec in BUILTIN_PROFILES:
return Profile(dict(BUILTIN_PROFILES[spec]))
if os.path.isfile(spec):
try:
with open(spec, 'r', encoding='utf-8') as f:
return Profile(json.load(f))
except ValueError as exc:
raise FwPackError(f'profile JSON 解析失败: {exc}') from None
raise FwPackError(f'profile {spec!r} 既不是内置名 ({"/".join(BUILTIN_PROFILES)}) '
f'也不是存在的 JSON 文件')
# ═══════════════════════════════════════════════════════════
# 向量表: 读取 / 校验 / 自动识别 A|B
# ═══════════════════════════════════════════════════════════
def read_vector(data):
if len(data) < 8:
raise FwPackError(f'文件只有 {len(data)}B, 连向量表(SP+复位向量)都放不下')
return struct.unpack_from('<II', data, 0)
def validate_app_bin(data, profile, which):
"""向量表硬门槛: SP 在 SRAM, 复位向量 Thumb 且落在声明槽内"""
slot = profile.slot(which)
sp, rst = read_vector(data)
if not (SRAM_LO <= sp <= SRAM_HI):
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 GD32E230 SRAM '
f'[0x{SRAM_LO:08X},0x{SRAM_HI:08X}] (大概不是 GD32E230 固件)')
if not rst & 1:
raise FwPackError(f'复位向量 0x{rst:08X} 不是 Thumb 地址 (bit0=0)')
if not (slot.addr <= rst & ~1 < slot.end):
raise FwPackError(f'复位向量 0x{rst:08X} 不在 {which.upper()} 槽 '
f'[0x{slot.addr:08X},0x{slot.end:08X}) 内 — '
f'固件链接基址与槽位不符')
return sp, rst
def detect_slot(data, profile):
"""从向量表自动识别 bin 属于 A 包还是 B 包"""
sp, rst = read_vector(data)
if not (SRAM_LO <= sp <= SRAM_HI):
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 SRAM 范围, 无法识别 (不是 GD32E230 应用固件?)')
if not rst & 1:
raise FwPackError(f'复位向量 0x{rst:08X} 非 Thumb 地址, 无法识别')
target = rst & ~1
if profile.slot_a.addr <= target < profile.slot_a.end:
return 'a'
if profile.slot_b and profile.slot_b.addr <= target < profile.slot_b.end:
return 'b'
if profile.bl_addr <= target < profile.bl_end:
raise FwPackError('复位向量落在 BL 区 [0x%08X,0x%08X) — 传入的是 bootloader 或'
' 整片镜像, 请提供编译出的 APP bin' % (profile.bl_addr, profile.bl_end))
raise FwPackError(f'复位向量 0x{target:08X} 不落在任何槽区间, 无法识别 A/B 包')
# ═══════════════════════════════════════════════════════════
# A 包 (动态补页 + 末尾 trailer)
# ═══════════════════════════════════════════════════════════
class APackResult:
def __init__(self, data, body_len, pages, crc, record_bytes):
self.data = data # 整 N 页含 trailer
self.body_len, self.pages, self.crc = body_len, pages, crc
self.record_bytes = record_bytes
@property
def pack_len(self):
return len(self.data)
@property
def trailer_value(self):
"""trailer 按 u32 小端解释 (页数低16 | CRC 高16)"""
return struct.unpack('<I', self.record_bytes[:4])[0] if len(
self.record_bytes) >= 4 else None
def build_a_pack(data, profile, which='a'):
"""动态补页打包: 补 0xFF 至整 N 页, 末 R 字节写 {pages16|crc16}。
整页扩展规则: body 恰为整页倍数, 或 余量 < R → 向后扩一页;
扩展后须仍 ≤ 槽大小。CRC 范围 = N 页内容去掉末 R 字节(含 0xFF 填充)。
"""
slot = profile.slot(which)
if slot.fixed_slot:
raise FwPackError(f'{slot.name} 是固定满槽, 请按 B 包流程处理')
R = slot.record_area_len(profile.page_size)
body = bytes(data)
if not body:
raise FwPackError(f'{slot.name} 输入为空文件')
pages = (len(body) + profile.page_size - 1) // profile.page_size
if len(body) % profile.page_size == 0 or pages * profile.page_size - len(body) < R:
pages += 1 # 恰整页 / 记录放不下 → 扩一页
pack_len = pages * profile.page_size
if pack_len > slot.size:
raise FwPackError(f'{slot.name} 固件 {len(body)}B 需 {pages} 页({pack_len}B), '
f'超过槽容量 {slot.size}B({slot.size // profile.page_size} 页)')
buf = bytearray(body + b'\xFF' * (pack_len - len(body)))
crc = crc16_xmodem(bytes(buf[:pack_len - R]))
record = slot.record.pack(pages, crc, f'{slot.name}.record')
buf[pack_len - len(record):] = record
return APackResult(bytes(buf), len(body), pages, crc, record)
# ═══════════════════════════════════════════════════════════
# B 包 (固定满槽 + 槽末记录)
# ═══════════════════════════════════════════════════════════
class BPackResult:
def __init__(self, data, body_len, pages, crc, record_bytes, record_addr):
self.data = data # 满槽镜像含记录
self.body_len, self.pages, self.crc = body_len, pages, crc
self.record_bytes, self.record_addr = record_bytes, record_addr
def build_b_pack(data, profile):
"""固定满槽打包: 补 0xFF 恰好填满槽, 槽末记录区写 {magic|pages|crc|保留FF}。
输入恰好等于槽大小 → 视为已含旧记录的满槽镜像, 末记录区原样丢弃重写;
固件 > 槽-R → 报错 (固定槽不可向后扩展)。CRC 范围 = 槽内容去掉记录区。
"""
slot = profile.slot_b
if slot is None:
raise FwPackError('当前 profile 无 slot_b')
R = slot.record_area_len(profile.page_size) # 记录区(含尾部保留字节)
body = bytes(data)
if not body:
raise FwPackError('slot_b 输入为空文件')
if len(body) == slot.size:
body = body[:slot.size - R] # 满槽镜像: 丢弃旧记录区
if len(body) > slot.size - R:
raise FwPackError(f'B 固件 {len(body)}B 超过固定槽容量 槽{slot.size}B-{R}B记录 '
f'= {slot.size - R}B (固定槽不可扩展, 请裁剪固件或加大槽)')
image = bytearray(body + b'\xFF' * (slot.size - len(body)))
crc = crc16_xmodem(bytes(image[:slot.size - R]))
pages = slot.size // profile.page_size
record = slot.record.pack(pages, crc, 'slot_b.record')
image[slot.size - R:slot.size - R + len(record)] = record
return BPackResult(bytes(image), len(body), pages, crc, record,
slot.end - R)
# ═══════════════════════════════════════════════════════════
# FULL 整片镜像
# ═══════════════════════════════════════════════════════════
def build_full(profile, bl_data, a_pack, b_pack=None):
"""BL + A 包 + 槽尾锚点 + 空隙 0xFF + B 满槽 + flag → 整片镜像"""
image = bytearray(b'\xFF' * profile.flash_size)
if bl_data:
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
image[profile.bl_addr - profile.flash_base:
profile.bl_addr - profile.flash_base + len(bl_data)] = bl_data
sa = profile.slot_a
off = sa.addr - profile.flash_base
image[off:off + a_pack.pack_len] = a_pack.data
if sa.anchor_addr is not None and len(a_pack.record_bytes) >= 4:
anchor = a_pack.record_bytes[:4] # 锚点与 trailer 同值
image[sa.anchor_addr - profile.flash_base:
sa.anchor_addr - profile.flash_base + 4] = anchor
if b_pack is not None:
sb = profile.slot_b
off = sb.addr - profile.flash_base
image[off:off + sb.size] = b_pack.data
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
profile.flag_value)
return bytes(image)
def build_factory(profile, bl_data, b_pack):
"""工厂烧录镜像: BL + B 满槽(含记录) + flag, **无 A 包、无锚点**。
上电判定链(双固件 BL): A 锚点为擦除态(0xFFFF...) → legacy flag + A 向量校验
(A 槽全 0xFF → 向量非法) → 落 B 记录 {magic|pages|crc} 校验 → 跳出厂固件;
后续 OTA 写入 A 槽后即切运行固件。
"""
if b_pack is None:
raise FwPackError('FACTORY 镜像需要 B 底包')
image = bytearray(b'\xFF' * profile.flash_size)
if bl_data:
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
image[0:len(bl_data)] = bl_data
sb = profile.slot_b
off = sb.addr - profile.flash_base
image[off:off + sb.size] = b_pack.data
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
profile.flag_value)
return bytes(image)
# ═══════════════════════════════════════════════════════════
# 回读复核 (从产物字节独立重算, 与构建结果交叉验证)
# ═══════════════════════════════════════════════════════════
def verify_a_blob(blob, profile, which='a'):
"""A 包复核: 页数/CRC/trailer 从文件字节重算; 返回 (pages, crc, trailer)"""
slot = profile.slot(which)
R = slot.record_area_len(profile.page_size)
if len(blob) <= R or len(blob) % profile.page_size:
raise FwPackError(f'A 包长度 {len(blob)}B 非法 (须为 >{R} 的整页倍数)')
pages = len(blob) // profile.page_size
crc = crc16_xmodem(blob[:-R])
expect = slot.record.pack(pages, crc)
if blob[-len(expect):] != expect:
raise FwPackError(f'A 包 trailer 复核失败: 文件 {blob[-len(expect):].hex(" ")} '
f'应为 {expect.hex(" ")} (pages={pages}, CRC=0x{crc:04X})')
return pages, crc, expect
def verify_b_blob(blob, profile):
"""B 包复核: 长度=满槽, 记录区从槽内容重算比对"""
slot = profile.slot_b
R = slot.record_area_len(profile.page_size)
if len(blob) != slot.size:
raise FwPackError(f'B 包长度 {len(blob)}B != 满槽 {slot.size}B')
crc = crc16_xmodem(blob[:slot.size - R])
expect = slot.record.pack(slot.size // profile.page_size, crc)
if blob[slot.size - R:slot.size - R + len(expect)] != expect:
raise FwPackError(f'B 包记录复核失败: 文件 '
f'{blob[slot.size - R:slot.size].hex(" ")} 应为 '
f'{expect.hex(" ")}')
return expect
def verify_full_blob(blob, profile, bl_data, a_body, b_body=None):
"""FULL 镜像逐项字节校验(规格 4.5: 分区内容/空隙/记录/flag, 17+ 项)。
全部从镜像字节独立重算, 与构建路径交叉验证。
返回 [(名称, 通过?, 说明)] 列表, 由调用方汇总展示。"""
base = profile.flash_base
sa = profile.slot_a
pg = profile.page_size
checks = []
def chk(name, ok, note=''):
checks.append((name, bool(ok), note))
# 1. 总长度
chk('总长度=flash_size', len(blob) == profile.flash_size,
f'{len(blob)}/{profile.flash_size}')
# 2-3. BL 区
if bl_data:
chk(f'BL 内容==输入({len(bl_data)}B)',
blob[:len(bl_data)] == bl_data)
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
else:
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
# 4-11. A 槽
a_off = sa.addr - base
R = sa.record_area_len(pg)
pages = max((len(a_body) + pg - 1) // pg, 1)
if len(a_body) % pg == 0 or pages * pg - len(a_body) < R:
pages += 1
pack_len = pages * pg
crc = crc16_xmodem(blob[a_off:a_off + pack_len - R])
trailer = sa.record.pack(pages, crc)
chk(f'A 体==输入固件({len(a_body)}B)', blob[a_off:a_off + len(a_body)] == a_body)
chk(f'A 补页 0xFF({pack_len - R - len(a_body)}B)',
all(b == 0xFF for b in blob[a_off + len(a_body):a_off + pack_len - R]))
_off = {f.name: f.offset for f in sa.record.fields}
rec_off = a_off + pack_len - R
t_pages = struct.unpack_from('<H', blob, rec_off + _off['pages'])[0]
chk(f'A trailer 页数=={pages}', t_pages == pages,
f'实为 {t_pages}')
t_crc = struct.unpack_from('<H', blob, rec_off + _off['crc'])[0]
chk(f'A trailer CRC==镜像重算(0x{crc:04X})', t_crc == crc, f'实为 0x{t_crc:04X}')
if sa.anchor_addr is not None:
chk(f'槽尾锚点==trailer @0x{sa.anchor_addr:08X}',
blob[sa.anchor_addr - base:sa.anchor_addr - base + 4]
== blob[a_off + pack_len - 4:a_off + pack_len])
chk('空隙(A 包尾~锚点)全 0xFF',
all(b == 0xFF for b in blob[a_off + pack_len:sa.anchor_addr - base]))
else:
chk('A 包尾~槽末全 0xFF',
all(b == 0xFF for b in blob[a_off + pack_len:sa.end - base]))
sp, rst = struct.unpack_from('<II', blob, a_off)
chk(f'A 向量表 SP∈SRAM(0x{sp:08X})', SRAM_LO <= sp <= SRAM_HI)
chk(f'A 复位向量∈A 槽(0x{rst:08X})', rst & 1 and sa.addr <= rst & ~1 < sa.end)
# 12-17. B 槽
if profile.slot_b:
sb = profile.slot_b
b_off = sb.addr - base
rb = sb.record_area_len(pg)
if b_body is not None:
body_n = min(len(b_body), sb.size - rb)
chk(f'B 体前缀==输入固件({body_n}B)',
blob[b_off:b_off + body_n] == b_body[:body_n])
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
rec_off = sb.end - rb - base
_bo = {f.name: f.offset for f in sb.record.fields}
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
f'实为 {m_pages}')
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc,
f'记录 0x{m_crc:04X}')
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
blob[rec_off + sb.record.length:rec_off + rb]
== b'\xFF' * (rb - sb.record.length))
else:
chk('B 槽全 0xFF(未提供底包)', all(b == 0xFF for b in blob[b_off:sb.end - base]))
# 18-19. flag
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
fp = profile.flag_page - base
chk('标志页其余字节 0xFF',
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
- profile.flag_addr - 4))
return checks
def verify_factory_blob(blob, profile, bl_data, b_body):
"""FACTORY 工厂镜像逐项字节校验: BL/B 记录/flag 在位, A 槽与锚点保持擦除态。
返回 [(名称, 通过?, 说明)] 列表。"""
base = profile.flash_base
sa, sb = profile.slot_a, profile.slot_b
pg = profile.page_size
checks = []
def chk(name, ok, note=''):
checks.append((name, bool(ok), note))
chk('总长度=flash_size', len(blob) == profile.flash_size,
f'{len(blob)}/{profile.flash_size}')
if bl_data:
chk(f'BL 内容==输入({len(bl_data)}B)', blob[:len(bl_data)] == bl_data)
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
else:
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
# A 槽必须整体擦除态: 无 A 包、无锚点 (判定链靠"锚点擦除+A 向量非法"落 B)
chk(f'A 槽全 0xFF·锚点擦除态({sa.size}B)',
all(b == 0xFF for b in blob[sa.addr - base:sa.end - base]))
# B 槽
b_off = sb.addr - base
rb = sb.record_area_len(pg)
body_n = min(len(b_body), sb.size - rb)
chk(f'B 体前缀==输入固件({body_n}B)',
blob[b_off:b_off + body_n] == b_body[:body_n])
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
rec_off = sb.end - rb - base
_bo = {f.name: f.offset for f in sb.record.fields}
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
f'实为 {m_pages}')
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc, f'记录 0x{m_crc:04X}')
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
blob[rec_off + sb.record.length:rec_off + rb]
== b'\xFF' * (rb - sb.record.length))
# flag
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
fp = profile.flag_page - base
chk('标志页其余字节 0xFF',
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
- profile.flag_addr - 4))
return checks
# ═══════════════════════════════════════════════════════════
# 产物命名 / 一站式打包 (CLI 与 GUI 共用入口)
# ═══════════════════════════════════════════════════════════
_VER_RE = re.compile(r'^(?P<proj>.+?)_?V(?P<ver>\d+\.\d+\.\d+)', re.IGNORECASE)
_DATE_RE = re.compile(r'_(\d{4}-\d{2}-\d{2})$')
def suggest_full_name(a_path, b_path=None):
"""从输入名推导 FULL 镜像名: 项目前缀 + 双版本号 (+共同日期后缀)。"""
a_base = os.path.splitext(os.path.basename(a_path))[0]
date = _DATE_RE.search(a_base)
date_sfx = ('_' + date.group(1)) if date else ''
ma, mb = _VER_RE.match(a_base), None
if b_path:
mb = _VER_RE.match(os.path.splitext(os.path.basename(b_path))[0])
if ma and mb:
return f'{ma.group("proj")}_FULL_A-V{ma.group("ver")}_B-V{mb.group("ver")}{date_sfx}.bin'
return a_base + '_FULL' + '.bin'
def suggest_factory_name(b_path):
"""从 B 包名推导 FACTORY 镜像名: 项目前缀 + B 版本号 (+日期后缀)。"""
b_base = os.path.splitext(os.path.basename(b_path))[0]
date = _DATE_RE.search(b_base)
m = _VER_RE.match(b_base)
if m:
return (f'{m.group("proj")}_FACTORY_B-V{m.group("ver")}'
+ (f'_{date.group(1)}' if date else '') + '.bin')
return b_base + '_FACTORY.bin'
class PackOutcome:
def __init__(self):
self.paths = {} # update/full/factory/md5 -> 文件路径
self.summary = [] # 展示用文本行
self.checks = [] # FULL 逐项校验 (名称, ok, note)
self.factory_checks = [] # FACTORY 逐项校验
@property
def ok(self):
pools = self.checks + self.factory_checks
return all(ok for _n, ok, _t in pools) if pools else True
def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
make_full=True, make_factory=True, progress=print):
"""一站式打包: 自动识别/校验 → A包/FULL/FACTORY → 写盘 → 回读复核 → MD5 清单。
a_path 可为 None (仅 B 底包 → 只出 *_FACTORY.bin 工厂镜像, 不拼 FULL ——
没有 A 运行槽的整片镜像无法启动, 不允许生成)。progress: 每行输出的回调
(CLI 用 print, GUI 转日志)。返回 PackOutcome; 任何硬错误抛 FwPackError。
"""
self_test()
profile = load_profile(profile_spec)
out = PackOutcome()
log = []
def say(line=''):
log.append(line)
progress(line)
say(profile.describe())
# -- 读取与校验 --
a_body = None
if a_path is not None:
if not os.path.isfile(a_path):
raise FwPackError(f'找不到 A 包输入文件: {a_path!r}')
a_body = open(a_path, 'rb').read()
validate_app_bin(a_body, profile, 'a')
say(f'A 包输入: {os.path.basename(a_path)} ({len(a_body)}B) — '
f'向量表校验通过 (A 槽)')
b_body = None
if b_path:
if not os.path.isfile(b_path):
raise FwPackError(f'找不到 B 包输入文件: {b_path!r}')
b_body = open(b_path, 'rb').read()
validate_app_bin(b_body, profile, 'b')
say(f'B 包输入: {os.path.basename(b_path)} ({len(b_body)}B) — '
f'向量表校验通过 (B 槽)')
if a_body is None and b_body is None:
raise FwPackError('至少提供一个输入 bin (A 包或 B 包)')
bl_data = None
if bl_path:
if not os.path.isfile(bl_path):
raise FwPackError(f'找不到 BL 输入文件: {bl_path!r}')
bl_data = open(bl_path, 'rb').read()
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
sp, rst = read_vector(bl_data)
if not (SRAM_LO <= sp <= SRAM_HI and rst & 1
and profile.bl_addr <= rst & ~1 < profile.bl_end):
say(f'警告: BL 向量表异常 (SP=0x{sp:08X}, RST=0x{rst:08X}), 请确认这是 '
f'链接在 0x{profile.bl_addr:08X} 的 bootloader')
out_dir = out_dir or os.path.dirname(os.path.abspath(a_path or b_path))
os.makedirs(out_dir, exist_ok=True)
a_base = os.path.splitext(os.path.basename(a_path))[0] if a_path else None
# -- A 包 --
ap = None
if a_body is not None:
ap = build_a_pack(a_body, profile)
upd_path = os.path.join(out_dir, a_base + '_UPDATE.bin')
with open(upd_path, 'wb') as f:
f.write(ap.data)
out.paths['update'] = upd_path
vp, vcrc, _rec = verify_a_blob(open(upd_path, 'rb').read(), profile)
if (vp, vcrc) != (ap.pages, ap.crc):
raise FwPackError('A 包回读复核失败')
say(f'A 包: {len(a_body)}B -> {ap.pages} 页({ap.pack_len}B) '
f'trailer=0x{ap.trailer_value:08X} (CRC 0x{ap.crc:04X}) — 回读复核通过')
say(f' {upd_path}')
# -- B 包 (仅作为镜像组成, 不再单独落盘) --
bp = None
if b_body is not None:
bp = build_b_pack(b_body, profile)
verify_b_blob(bp.data, profile)
area = profile.slot_b.record_area_len(profile.page_size)
rec_disp = (bp.record_bytes + b'\xFF' * (area - len(bp.record_bytes))) \
.hex(' ').upper()
say(f'B 包: 满槽 {bp.pages} 页({len(bp.data)}B) 记录 {rec_disp} '
f'(CRC 0x{bp.crc:04X}) — 复核通过')
elif profile.slot_b and make_full and a_body is not None:
say('提示: 未提供 B 底包, FULL 镜像中 B 槽保持 0xFF (擦除态)')
# -- FULL (A 运行槽是必需的; 仅 B 输入时不拼 FULL) --
if make_full and a_body is not None:
if bl_data is None:
say('警告: 未提供 BL bin, FULL 镜像 BL 区为 0xFF — 该镜像烧录后设备'
' 无法启动, 仅用于槽区预烧/分析!')
full_name = suggest_full_name(a_path, b_path)
full_path = os.path.join(out_dir, full_name)
image = build_full(profile, bl_data, ap, bp)
with open(full_path, 'wb') as f:
f.write(image)
out.paths['full'] = full_path
blob = open(full_path, 'rb').read()
out.checks = verify_full_blob(blob, profile, bl_data, a_body,
b_body if b_body is not None else None)
bad = [n for n, ok, _t in out.checks if not ok]
if bad:
raise FwPackError('FULL 镜像复核未通过: ' + '; '.join(bad))
say(f'FULL: {len(image)}B — {len(out.checks)} 项字节校验全部通过')
for n, ok, note in out.checks:
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
say(f' {full_path}')
# -- FACTORY (工厂烧录镜像: BL + B 满槽 + flag, 无 A; 需 B 底包) --
if make_factory and b_body is not None:
if bl_data is None:
say('警告: 未提供 BL bin, FACTORY 镜像 BL 区为 0xFF — 工厂烧录镜像'
' 必须带 BL, 请用 -b 提供!')
fac_name = suggest_factory_name(b_path)
fac_path = os.path.join(out_dir, fac_name)
fac_image = build_factory(profile, bl_data, bp)
with open(fac_path, 'wb') as f:
f.write(fac_image)
out.paths['factory'] = fac_path
fac_blob = open(fac_path, 'rb').read()
out.factory_checks = verify_factory_blob(fac_blob, profile, bl_data, b_body)
bad = [n for n, ok, _t in out.factory_checks if not ok]
if bad:
raise FwPackError('FACTORY 镜像复核未通过: ' + '; '.join(bad))
say(f'FACTORY: {len(fac_image)}B — {len(out.factory_checks)} 项字节校验'
f'全部通过 (A 槽保持擦除态, 上电跳 B)')
for n, ok, note in out.factory_checks:
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
say(f' {fac_path}')
# -- MD5 清单 (挂在 FULL, 无 FULL 则挂在 FACTORY) --
anchor_path = out.paths.get('full') or out.paths.get('factory')
if anchor_path:
md5_path = anchor_path + '.md5.txt'
with open(md5_path, 'w', encoding='utf-8', newline='\n') as f:
f.write(f'# 布局摘要 — gd32e230_firmware_merge_tool v{TOOL_VERSION}\n')
f.write('# ' + profile.describe().replace('\n', '\n# ') + '\n')
f.write(f'# 生成: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n')
if ap:
f.write('# A 包: %s -> %d 页, trailer=0x%08X (CRC 0x%04X)\n'
% (os.path.basename(a_path), ap.pages,
ap.trailer_value, ap.crc))
if bp:
f.write('# B 包: %s -> 满槽 %d 页, 记录 %s (CRC 0x%04X)\n'
% (os.path.basename(b_path), bp.pages,
(bp.record_bytes + b'\xFF'
* (profile.slot_b.record_area_len(profile.page_size)
- len(bp.record_bytes))).hex(' ').upper(), bp.crc))
f.write('#\n')
for _k, p in out.paths.items():
if _k == 'md5':
continue
data = open(p, 'rb').read()
f.write('%s %8dB %s\n' % (hashlib.md5(data).hexdigest(),
len(data), os.path.basename(p)))
out.paths['md5'] = md5_path
say(f'MD5 清单: {md5_path}')
out.summary = log
return out
+242 -119
View File
@@ -1,32 +1,114 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
GD32E230 固件工具:整页补齐 + 合成烧录镜像,二合一。 GD32E230 固件打包工具 v2 — 布局 profile 化双固件打包 (A/B 槽 + 记录/CRC)
================================================================
模式(互斥): v2 模式(默认): 输入编译好的 APP bin, 自动识别 A/B 槽, 产出
-m / --merge 合成完整 Flash 镜像 (bootloader + APP + FLAG) *_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {页数16|CRC16})
→ 用于 OpenOCD / J-Link 整片烧录,不补齐 *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志)
-f / --fill 仅补齐 APP 到整页边界 *_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电跳 B)
→ 用于 Bootloader Ymodem 升级 *.md5.txt 产物 MD5 清单 + 布局摘要 (随主镜像生成)
打包规则与双固件 bootloader 逐字一致, 写盘后回读复核。计算与校验在
fw_pack.py 公共库 (FlashTool GUI 共用)。
Flash 布局: legacy 模式(过渡期兼容, -f/-m 二选一时启用): 旧单槽方案
0x08000000 ┌──────────────┐ -f/--fill 纯 0xFF 补页, 无 trailer (旧 BL 的 Ymodem 升级包)
│ Bootloader │ 8KB -m/--merge BL + APP + 0xEEEE 标志 拼整片镜像 (不补齐)
APP_ADDR ├──────────────┤
│ Application │ 最大 54KB (F8) / 22KB (F6) / 6KB (F4) v2 Flash 布局(默认 profile CeramixNano_MB_Dual_FW, GD32E230F8/64K):
│ ... │ 0x08000000 Bootloader 8K 页 0~7
CAL_ADDR │ 校准数据 │ 1KB (预留) 0x08002000 FW_A 运行槽 36K 页 8~43 OTA 唯一接收区, 槽尾锚点@0x0800AFFC
FLAG_ADDR │ 0xEEEE 标志 │ 1KB 0x0800B000 FW_B 保底槽 19K 页 44~62 固定满槽, B 记录@0x0800FBF8
FLASH_END └──────────────┘ 0x0800FC00 标志页 1K 页 63 0xEEEE@0x0800FFFC
其他工程换 profile 即可(保底槽 8K/28K/无保底槽等), 见 --profile。
""" """
import argparse import argparse
import os
import struct import struct
import sys import sys
import os
import fw_pack
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
TOOL_VERSION = '2.1.0'
# 默认布局 profile (与 fw_pack.BUILTIN_PROFILES 键一致)
DEFAULT_PROFILE = 'CeramixNano_MB_Dual_FW'
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 常量 # v2 模式: 双固件 profile 打包
# ═══════════════════════════════════════════════════════════
def _collect_inputs(args, profile):
"""汇合 位置参数(自动识别) 与 --a/--b(显式指定), 冲突报错。
返回 (a_path|None, b_path|None)。"""
if (args.a or args.b) and args.bins:
raise fw_pack.FwPackError(
'位置输入(自动识别)与显式 --a/--b 不能混用, 二选一')
if args.bins:
found = {}
for path in args.bins:
if not os.path.isfile(path):
raise fw_pack.FwPackError(f'找不到输入文件: {path}')
data = open(path, 'rb').read()
slot = fw_pack.detect_slot(data, profile)
if slot in found:
raise fw_pack.FwPackError(
f'{path!r} 与 {found[slot]!r} 都被识别为 {slot.upper()} 包, '
f'一个槽只能有一个输入')
found[slot] = path
return found.get('a'), found.get('b')
if args.a and args.b and os.path.abspath(args.a) == os.path.abspath(args.b):
raise fw_pack.FwPackError('--a 与 --b 指向同一个文件')
for opt, path, slot in (('--a', args.a, 'a'), ('--b', args.b, 'b')):
if not path:
continue
if not os.path.isfile(path):
raise fw_pack.FwPackError(f'找不到 {opt} 输入文件: {path}')
data = open(path, 'rb').read()
which = fw_pack.detect_slot(data, profile)
if which != slot: # 显式指定与向量表判定冲突 → 报错(规格 4.1)
raise fw_pack.FwPackError(
f'{opt} 显式指定为 {slot.upper()} 槽, 但其复位向量落在 '
f'{which.upper()} 槽 — 请改用 --{which} 或调整输入')
return args.a, args.b
def run_v2(args):
fw_pack.self_test()
try:
profile = fw_pack.load_profile(args.profile)
except fw_pack.FwPackError as exc:
print(f'错误:{exc}', file=sys.stderr)
return 2
try:
a_path, b_path = _collect_inputs(args, profile)
if not a_path and not b_path:
print('错误:v2 模式至少需要一个输入 bin(A 包或 B 包)。\n'
'示例: python gd32e230_firmware_tool.py app_a.bin app_b.bin -b bl.bin',
file=sys.stderr)
return 2
out_dir = args.output
if out_dir and os.path.splitext(args.output)[1].lower() == '.bin':
print('错误:v2 模式 -o 是输出目录(产物自动命名);'
'legacy 模式 -o 才是输出文件。', file=sys.stderr)
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,
make_factory=not args.no_factory)
except fw_pack.FwPackError as exc:
print(f'\n错误:{exc}', file=sys.stderr)
return 1
return 0
# ═══════════════════════════════════════════════════════════
# legacy 模式: 旧单槽方案 (纯 0xFF 补页 / 拼接, 无 trailer)
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
FLASH_BASE = 0x08000000 FLASH_BASE = 0x08000000
@@ -44,33 +126,34 @@ CHIP_CONFIG = {
} }
DEFAULT_CHIP = "F8" DEFAULT_CHIP = "F8"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_BOOTLOADER = os.path.join(SCRIPT_DIR, "gd32e230f8_bootloader_hulk.bin") DEFAULT_BOOTLOADER = os.path.join(SCRIPT_DIR, "gd32e230f8_bootloader_hulk.bin")
# ═══════════════════════════════════════════════════════════ def calc_layout(flash_size, app_addr=None):
# 地址计算 if app_addr is None:
# ═══════════════════════════════════════════════════════════ app_addr = FLASH_BASE + BOOTLOADER_SIZE # 0x08002000
cal_addr = FLASH_BASE + flash_size - CAL_PAGE_SIZE - FLAG_PAGE_SIZE
def calc_layout(flash_size): app_max = cal_addr - app_addr
app_addr = FLASH_BASE + BOOTLOADER_SIZE # 0x08002000 cal_reserved = True
cal_addr = FLASH_BASE + flash_size - CAL_PAGE_SIZE - FLAG_PAGE_SIZE else:
flag_addr = FLASH_BASE + flash_size - 4 # 最后一页末尾 - 4B # 自定义落位(如 FW_B 槽 0x0800B000):APP 可用到标志页前,校准页让渡给 APP
app_max = cal_addr - app_addr cal_addr = None
app_max = FLASH_BASE + flash_size - FLAG_PAGE_SIZE - app_addr
cal_reserved = False
flag_addr = FLASH_BASE + flash_size - 4 # 最后一页末尾 - 4B
flag_page = FLASH_BASE + flash_size - FLAG_PAGE_SIZE
return { return {
"flash_size": flash_size, "flash_size": flash_size,
"flash_end": FLASH_BASE + flash_size, "flash_end": FLASH_BASE + flash_size,
"app_addr": app_addr, "app_addr": app_addr,
"app_max": app_max, "app_max": app_max,
"cal_addr": cal_addr, "cal_addr": cal_addr,
"cal_reserved": cal_reserved,
"flag_page": flag_page,
"flag_addr": flag_addr, "flag_addr": flag_addr,
} }
# ═══════════════════════════════════════════════════════════
# 补齐
# ═══════════════════════════════════════════════════════════
def pad_to_page(data, page_size=PAGE_SIZE, fill_byte=FILL_BYTE): def pad_to_page(data, page_size=PAGE_SIZE, fill_byte=FILL_BYTE):
remainder = len(data) % page_size remainder = len(data) % page_size
if remainder == 0: if remainder == 0:
@@ -79,42 +162,39 @@ def pad_to_page(data, page_size=PAGE_SIZE, fill_byte=FILL_BYTE):
return data + bytes([fill_byte]) * pad_len, pad_len return data + bytes([fill_byte]) * pad_len, pad_len
# ═══════════════════════════════════════════════════════════
# 合成模式
# ═══════════════════════════════════════════════════════════
def merge(bootloader_path, app_path, output_path, layout): def merge(bootloader_path, app_path, output_path, layout):
# 读取
with open(bootloader_path, "rb") as f: with open(bootloader_path, "rb") as f:
bl = f.read() bl = f.read()
with open(app_path, "rb") as f: with open(app_path, "rb") as f:
app = f.read() app = f.read()
# 校验
app_offset = layout["app_addr"] - FLASH_BASE app_offset = layout["app_addr"] - FLASH_BASE
if len(bl) > app_offset: if len(bl) > app_offset:
print(f"错误:Bootloader ({len(bl)}B) 超过 APP 偏移 ({app_offset}B)", file=sys.stderr) print(f"错误:Bootloader ({len(bl)}B) 超过 APP 偏移 ({app_offset}B)", file=sys.stderr)
return False return False
if len(app) > layout["app_max"]: if len(app) > layout["app_max"]:
limit_desc = (
f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) "
f"= {layout['app_max']//1024}KB"
if layout["cal_reserved"] else
f"APP 0x{layout['app_addr']:08X} ~ 0x{layout['flag_page']:08X}(Flag 页前)"
f"= {layout['app_max']//1024}KB"
)
print( print(
f"错误:APP ({len(app)}B / {len(app)/1024:.1f}KB) 超过最大容量 " f"错误:APP ({len(app)}B / {len(app)/1024:.1f}KB) 超过最大容量 "
f"({layout['app_max']}B / {layout['app_max']/1024:.0f}KB)\n" f"({layout['app_max']}B / {layout['app_max']//1024:.0f}KB)\n"
f" 芯片 {layout['flash_size']//1024}KB: " f" 芯片 {layout['flash_size']//1024}KB: {limit_desc}",
f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) "
f"= {layout['app_max']//1024}KB",
file=sys.stderr, file=sys.stderr,
) )
return False return False
# 构建镜像
image = bytearray(b'\xFF' * layout["flash_size"]) image = bytearray(b'\xFF' * layout["flash_size"])
image[0:len(bl)] = bl image[0:len(bl)] = bl
image[app_offset:app_offset + len(app)] = app image[app_offset:app_offset + len(app)] = app
flag_offset = layout["flag_addr"] - FLASH_BASE flag_offset = layout["flag_addr"] - FLASH_BASE
struct.pack_into("<I", image, flag_offset, FLAG_VALUE) struct.pack_into("<I", image, flag_offset, FLAG_VALUE)
# 输出
with open(output_path, "wb") as f: with open(output_path, "wb") as f:
f.write(image) f.write(image)
@@ -123,30 +203,31 @@ def merge(bootloader_path, app_path, output_path, layout):
print(f" Bootloader : {len(bl):>6}B @ 0x{FLASH_BASE:08X}") print(f" Bootloader : {len(bl):>6}B @ 0x{FLASH_BASE:08X}")
print(f" Application: {len(app):>6}B @ 0x{layout['app_addr']:08X}") print(f" Application: {len(app):>6}B @ 0x{layout['app_addr']:08X}")
print(f" Flag : 0x{FLAG_VALUE:04X} @ 0x{layout['flag_addr']:08X}") print(f" Flag : 0x{FLAG_VALUE:04X} @ 0x{layout['flag_addr']:08X}")
print(f" 校准预留 : 1KB @ 0x{layout['cal_addr']:08X}") if layout["cal_reserved"]:
print(f" 校准预留 : 1KB @ 0x{layout['cal_addr']:08X}")
print(f" APP 剩余 : {layout['app_max'] - len(app):>6}B") print(f" APP 剩余 : {layout['app_max'] - len(app):>6}B")
print(f" 总大小 : {layout['flash_size']:>6}B ({layout['flash_size']//1024}KB)") print(f" 总大小 : {layout['flash_size']:>6}B ({layout['flash_size']//1024}KB)")
return True return True
# ═══════════════════════════════════════════════════════════
# 补齐模式
# ═══════════════════════════════════════════════════════════
def fill_only(input_path, output_path, layout): def fill_only(input_path, output_path, layout):
with open(input_path, "rb") as f: with open(input_path, "rb") as f:
data = f.read() data = f.read()
padded, pad_len = pad_to_page(data) padded, pad_len = pad_to_page(data)
# 校验补齐后不超过 APP 最大容量
if len(padded) > layout["app_max"]: if len(padded) > layout["app_max"]:
limit_desc = (
f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) "
f"= {layout['app_max']//1024}KB"
if layout["cal_reserved"] else
f"APP 0x{layout['app_addr']:08X} ~ 0x{layout['flag_page']:08X}(Flag 页前)"
f"= {layout['app_max']//1024}KB"
)
print( print(
f"错误:补齐后 ({len(padded)}B / {len(padded)/1024:.1f}KB) 超过 APP 最大容量 " f"错误:补齐后 ({len(padded)}B / {len(padded)/1024:.1f}KB) 超过 APP 最大容量 "
f"({layout['app_max']}B / {layout['app_max']/1024:.0f}KB)\n" f"({layout['app_max']}B / {layout['app_max']//1024:.0f}KB)\n"
f" 芯片 {layout['flash_size']//1024}KB: " f" 芯片 {layout['flash_size']//1024}KB: {limit_desc}",
f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) "
f"= {layout['app_max']//1024}KB",
file=sys.stderr, file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -166,86 +247,128 @@ def fill_only(input_path, output_path, layout):
print(f" 说明 : 已是整页对齐,原样输出") print(f" 说明 : 已是整页对齐,原样输出")
def run_legacy(args):
chip = CHIP_CONFIG[args.flash_size]
app_addr = None
if args.app_addr is not None:
try:
app_addr = int(args.app_addr, 0)
except ValueError:
print(f"错误:-a/--app-addr 无法解析:{args.app_addr}", file=sys.stderr)
return 2
if app_addr % PAGE_SIZE != 0:
print(f"错误:APP 落位地址 0x{app_addr:08X} 未按 1KB 页对齐", file=sys.stderr)
return 2
addr_min = FLASH_BASE + BOOTLOADER_SIZE
addr_max = FLASH_BASE + chip["size"] - FLAG_PAGE_SIZE
if not (addr_min <= app_addr < addr_max):
print(
f"错误:APP 落位地址 0x{app_addr:08X} 超出允许范围 "
f"[0x{addr_min:08X}, 0x{addr_max:08X})",
file=sys.stderr,
)
return 2
layout = calc_layout(chip["size"], app_addr)
print(f"[legacy] 芯片: {chip['label']}")
app = args.bins[0] if args.bins else None
if not app or not os.path.isfile(app):
print(f"错误:找不到 APP 文件 {app}", file=sys.stderr)
return 2
if args.output is None:
base, ext = os.path.splitext(os.path.basename(app))
suffix = "_BL" if args.merge else "_UPDATE"
args.output = os.path.join(os.path.dirname(os.path.abspath(app)),
f"{base}{suffix}{ext}")
if args.merge:
bl = args.bootloader or DEFAULT_BOOTLOADER
if not os.path.isfile(bl):
print(f"错误:找不到 Bootloader 文件 {bl}", file=sys.stderr)
return 2
if not merge(bl, app, args.output, layout):
return 1
else:
fill_only(app, args.output, layout)
return 0
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 入口 # 入口
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="GD32E230 固件工具 — 整页补齐 & 合成烧录镜像", prog='gd32e230_firmware_tool',
description=f"GD32E230 固件打包工具 v{TOOL_VERSION} — 双固件 profile 打包"
"(默认)/ 旧单槽补齐·拼接(-f/-m)",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog=f"""
示例: v2 双固件模式(默认, 规格: UPGRADE_PLAN_v2.md):
python %(prog)s -m app.bin # F8 合成镜像 (默认) python %(prog)s app_a.bin # 识别 A 包 → *_UPDATE.bin (+FULL)
python %(prog)s -m app.bin -s F6 # F6 合成镜像 python %(prog)s app_a.bin app_b.bin -b bl.bin # 全产物: UPDATE/FULL/FACTORY/md5
python %(prog)s -f app.bin # F8 补齐升级包 (默认) python %(prog)s app_a.bin --no-full # 仅升级包
python %(prog)s -f app.bin -s F4 # F4 补齐升级包 python %(prog)s app_b.bin -b bl.bin # 仅工厂镜像 → *_FACTORY.bin
python %(prog)s -m app.bin -b my_bl.bin -o out.bin # 自定义 bootloader/输出 python %(prog)s --a a.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)
python %(prog)s -m app.bin # BL+APP+flag 拼整片
python %(prog)s -m app.bin -s F6 # F6 芯片
python %(prog)s -m app.bin -a 0x0800B000 -b bl.bin # 自定义落位
""", """,
) )
# 位置参数
parser.add_argument( parser.add_argument(
"app", help="Application 的 .bin 文件路径" "bins", nargs="*",
) help="v2: 输入 bin(A/B 包自动识别, 可多个);legacy: APP 路径")
mode = parser.add_mutually_exclusive_group()
# 模式 (互斥) mode.add_argument("-m", "--merge", action="store_true",
mode = parser.add_mutually_exclusive_group(required=True) help="legacy: 合成完整 Flash 镜像 (bootloader + APP + FLAG)")
mode.add_argument( mode.add_argument("-f", "--fill", action="store_true",
"-m", "--merge", help="legacy: 仅补齐 APP 到整页边界 (纯 0xFF, 无 trailer)")
action="store_true", parser.add_argument("--legacy", action="store_true",
help="合成完整 Flash 镜像 (bootloader + APP + FLAG),用于整片烧录", help="显式声明 legacy 模式(需配合 -f/-m)")
) parser.add_argument("--profile", default=DEFAULT_PROFILE,
mode.add_argument( help="v2 布局 profile: 内置名(%s)或 JSON 文件路径"
"-f", "--fill", % "/".join(fw_pack.BUILTIN_PROFILES))
action="store_true", parser.add_argument("--a", metavar="BIN", default=None,
help="仅补齐 APP 到整页边界,用于 Bootloader Ymodem 升级", help="v2: 显式指定 A 包(运行槽固件)")
) parser.add_argument("--b", metavar="BIN", default=None,
help="v2: 显式指定 B 包(出厂保底固件)")
# 选项 parser.add_argument("-b", "--bootloader", default=None,
parser.add_argument( help="BL bin (v2: 参与 FULL 拼接; legacy -m: 默认内置 BL)")
"-s", "--flash-size", parser.add_argument("-o", "--output", default=None,
default=DEFAULT_CHIP, help="输出路径: v2=输出目录(自动命名); legacy=输出文件")
choices=list(CHIP_CONFIG.keys()), parser.add_argument("--no-full", action="store_true",
help=f"芯片型号: F4(16K) F6(32K) F8(64K) (默认: {DEFAULT_CHIP})", help="v2: 不拼 FULL 整片镜像")
) parser.add_argument("--no-factory", action="store_true",
parser.add_argument( help="v2: 不出 FACTORY 工厂镜像")
"-b", "--bootloader", parser.add_argument("-s", "--flash-size", default=DEFAULT_CHIP,
default=DEFAULT_BOOTLOADER, choices=list(CHIP_CONFIG.keys()),
help=f"Bootloader bin 路径 (默认: {DEFAULT_BOOTLOADER})", help="legacy 芯片型号: F4(16K) F6(32K) F8(64K)")
) parser.add_argument("-a", "--app-addr", default=None,
parser.add_argument( help="legacy: APP 落位地址, 如 0x0800B000 (默认 0x08002000)")
"-o", "--output",
default=None,
help="输出文件路径 (默认: APP名_BL.bin 或 APP名_UPDATE.bin)",
)
args = parser.parse_args() args = parser.parse_args()
print(f"gd32e230_firmware_tool v{TOOL_VERSION} (fw_pack {fw_pack.TOOL_VERSION})")
chip = CHIP_CONFIG[args.flash_size] legacy = args.fill or args.merge
layout = calc_layout(chip["size"]) if args.legacy and not legacy:
parser.error("--legacy 需配合 -f/--fill 或 -m/--merge 使用")
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})
print(f"芯片: {chip['label']}") rc = run_legacy(args) if legacy else run_v2(args)
sys.exit(rc)
# 输出文件名
if args.output is None:
base, ext = os.path.splitext(os.path.basename(args.app))
suffix = "_BL" if args.merge else "_UPDATE"
args.output = f"{base}{suffix}{ext}"
# 输入文件检查
if not os.path.isfile(args.app):
print(f"错误:找不到 APP 文件 {args.app}", file=sys.stderr)
sys.exit(1)
if args.merge:
if not os.path.isfile(args.bootloader):
print(f"错误:找不到 Bootloader 文件 {args.bootloader}", file=sys.stderr)
sys.exit(1)
if not merge(args.bootloader, args.app, args.output, layout):
sys.exit(1)
else:
fill_only(args.app, args.output, layout)
if __name__ == "__main__": if __name__ == "__main__":
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""发布 DAPLinkFlash-v2.6.1:fw_pack 2.2(EDDY profile)+ 随 Release 附板卡 BL bin"""
import hashlib
import json
import os
import ssl
import time
import urllib.request
from pathlib import Path
BASE = "https://gitea.hulk.wang/api/v1"
REPO = "hulk/gd32e230f8_firmware_merge_tool"
TOKEN = os.environ["GITEA_TOKEN"]
CTX = ssl._create_unverified_context()
BL_DIR = Path(r"E:\Hulk-Coding\gd32e230_bootloader\Build\Release")
BLS = [
BL_DIR / "gd32e23x_V0.1.0_bootloader_CeramixNano_MB_2026-10-22.bin",
BL_DIR / "gd32e23x_V0.1.0_bootloader_CeramixNano_EDDY_2026-10-24.bin",
]
MANIFEST = BL_DIR / "bootloader_bins.md5.txt"
BODY = """## 本版变更
- **fw_pack 2.1 → 2.2**:新增内置布局 profile `CeramixNano_EDDY_Dual_FW`(CeramixNano EDDY 板 / LDC1612 电涡流传感器单板;Flash 分配与 MB 逐字节相同,BL 用各自的板卡分支 bin)。
- **DAPLinkFlash GUI 无改动**——需要 GUI 请继续用 [v2.6.0](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases/tag/DAPLinkFlash-v2.6.0) 的安装包。
- **新增:随 Release 分发各板卡双固件 BL bin**(拼 FULL/FACTORY 时 `-b` 传入):
| 附件 | 板卡 | 大小 | MD5 | 来源 |
|---|---|---|---|---|
| `gd32e23x_V0.1.0_bootloader_CeramixNano_MB_2026-10-22.bin` | CeramixNano-MB(LED=PA14) | 4,044B | f7b447cf800e062cdf15abd8d7edc723 | BL 仓库 `bootloader_CeramixNano_MB` 分支,tag `v0.1.0` |
| `gd32e23x_V0.1.0_bootloader_CeramixNano_EDDY_2026-10-24.bin` | CeramixNano-EDDY(LED=PB1,升级会话期间 SWD 可用) | 4,040B | 1dcc17bf530930a9686afa4dd04d30db | BL 仓库 `bootloader_CeramixNano_EDDY` 分支,V0.1.0 |
| `bootloader_bins.md5.txt` | 清单 | — | — | 上述两文件的 MD5 |
## 用法
```bash
# MB 双固件打包(在 APP bin 所在目录)
python gd32e230_firmware_tool.py app_a.bin app_b.bin -b gd32e23x_V0.1.0_bootloader_CeramixNano_MB_2026-10-22.bin
# EDDY 双固件打包
python gd32e230_firmware_tool.py app_a.bin app_b.bin \
-b gd32e23x_V0.1.0_bootloader_CeramixNano_EDDY_2026-10-24.bin \
--profile CeramixNano_EDDY_Dual_FW
```
两块板 Flash 布局完全一致(BL 8K + FW_A 36K + FW_B 19K + 末页标志),仅 LED 引脚不同;
**BL 版本号按板卡分支独立编号,勿跨板混烧**(MB 版 LED=PA14 会占 SWCLK)。
"""
def api(method, path, data=None, ctype="application/json", tries=6):
for i in range(tries):
try:
r = urllib.request.Request(BASE + path, method=method)
r.add_header("Authorization", "token " + TOKEN)
body = None
if data is not None:
body = json.dumps(data).encode() if ctype == "application/json" else data
r.add_header("Content-Type", ctype)
with urllib.request.urlopen(r, body, context=CTX) as resp:
txt = resp.read()
return resp.status, (json.loads(txt) if txt else {})
except Exception as e:
print(f" retry {i + 1} {method} {path.split('?')[0]}: {e!r}")
time.sleep(8)
raise SystemExit(f"API 多次重试失败: {method} {path}")
# 0) 生成 BL 清单
lines = ["# 双固件板卡 BL bin 清单(来源:gd32e230_bootloader 仓库对应板卡分支)"]
for p in BLS:
md5 = hashlib.md5(p.read_bytes()).hexdigest()
lines.append(f"{md5} {p.stat().st_size:>6}B {p.name}")
MANIFEST.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(MANIFEST.read_text(encoding="utf-8"))
# 1) 创建 Release(tag 打在 main 顶端)
st, rel = api("POST", f"/repos/{REPO}/releases", {
"tag_name": "DAPLinkFlash-v2.6.1",
"target_commitish": "main",
"name": "DAPLinkFlash v2.6.1 — fw_pack 2.2(EDDY profile)+ 板卡 BL 分发",
"body": BODY,
})
print("release:", st, rel.get("id"), rel.get("html_url"))
rid = rel["id"]
# 2) 附件
boundary = "----zcodebl"
for p in BLS + [MANIFEST]:
blob = p.read_bytes()
parts = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{p.name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + blob + f"\r\n--{boundary}--\r\n".encode()
st, res = api("POST", f"/repos/{REPO}/releases/{rid}/assets?name={p.name}", parts,
ctype=f"multipart/form-data; boundary={boundary}")
print("asset:", st, p.name)
+728
View File
@@ -0,0 +1,728 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fw_pack / gd32e230_firmware_tool v2 回归测试套件
================================================================
覆盖 UPGRADE_PLAN_v2.md 第五节回归基准 + 自建边界用例:
1. CRC-16/XMODEM 测试向量;
2. profile 解析/校验(内置 + 变体 + 非法配置);
3. 向量表识别 A/B/BL 与显式冲突;
4. A 包整页扩展规则(恰整页 / 余量<4 / 余量==4 / 超槽);
5. B 包固定满槽 + 记录(满槽输入 / 小输入 / 超槽-8);
6. FULL 拼装 + 逐项字节校验 + 回读复核(含篡改检测);
7. FACTORY 工厂镜像(BL+B+flag 无 A, 锚点擦除态) + 篡改检测;
8. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现
2026-10-22 验收包, 文件缺失时自动跳过);
9. CLI 冒烟(v2 / legacy / 错误路径)。
运行: python test_fw_pack.py [-v]
全部通过 = v2 升级验收通过。
"""
import hashlib
import json
import os
import struct
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fw_pack # noqa: E402
ROOT = os.path.dirname(os.path.abspath(__file__))
CLI = os.path.join(ROOT, 'gd32e230_firmware_tool.py')
A_BASE, B_BASE, BL_BASE = 0x08002000, 0x0800B000, 0x08000000
PAGE = 1024
SLOT_A_SIZE, SLOT_B_SIZE = 36 * PAGE, 19 * PAGE
# -- CeramixNano 真机固件(回归基准, 缺文件自动跳过) --
CAP = r'E:\Hulk-Coding\capsule_mb_camke_vscode\Build'
A17 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.17_APP_2026-10-21.bin')
A18 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.18_APP_2026-10-21.bin')
B12 = os.path.join(CAP, 'Release', 'CeramixNano-MB_V1.0.12_APP_2026-10-20.bin')
U17_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.17_APP_2026-10-21_UPDATE.bin')
U18_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.18_APP_2026-10-21_UPDATE.bin')
FULL_REF = os.path.join(CAP, 'test', 'acceptance_2026-10-22',
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
HAVE_REAL = all(os.path.isfile(p) for p in (A17, A18, B12, U17_REF, FULL_REF))
LDC_APP = (r'E:\Hulk-Coding\ldc1612_cmake_vscode\Build\codex-bootloader-test'
r'\LDC1612_V1.0.1_APP_2026-09-26.bin')
LDC_UPD_REF = os.path.join(ROOT, 'LDC1612_V1.0.1_APP_2026-09-26_update.bin')
# ═══════════════════════════════════════════════════════════
# 测试辅助: 独立参考实现 + 合成固件
# ═══════════════════════════════════════════════════════════
def ref_crc16(data):
"""独立抄写的 XMODEM CRC (与 fw_pack 互为交叉验证)"""
crc = 0x0000
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
crc &= 0xFFFF
return crc
def ref_a_pack(body):
"""按规格直译的 A 包参考实现 (不经 fw_pack 代码路径)"""
n = (len(body) + PAGE - 1) // PAGE
if len(body) % PAGE == 0 or n * PAGE - len(body) < 4:
n += 1
buf = bytearray(body) + b'\xFF' * (n * PAGE - len(body))
crc = ref_crc16(bytes(buf[:n * PAGE - 4]))
struct.pack_into('<HH', buf, n * PAGE - 4, n, crc)
return bytes(buf), n, crc
def synth_bin(base, size, rst_off=0x101):
"""合成一个向量表合法的固件 bin (SP=0x20000800, 复位向量=base+rst_off|1)"""
buf = bytearray(size)
struct.pack_into('<II', buf, 0, 0x20000800, (base + rst_off) | 1)
for i in range(8, size):
buf[i] = (i * 7 + 13) & 0xFF
return bytes(buf)
def ceramix_profile():
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_Dual_FW']))
raw['slot_a']['size'] = f'{slot_a_k}K'
if with_b:
raw['slot_b']['size'] = f'{slot_b_k}K'
raw['slot_b']['addr'] = '0x%08X' % (A_BASE + slot_a_k * 1024) # B 紧跟 A
else:
raw['slot_b'] = None
return fw_pack.Profile(raw)
def run_cli(*argv, cwd=None):
env = dict(os.environ, PYTHONIOENCODING='utf-8', PYTHONUTF8='1')
p = subprocess.run([sys.executable, CLI, *argv], capture_output=True,
text=True, encoding='utf-8', errors='replace',
cwd=cwd or ROOT, env=env, timeout=120)
return p.returncode, p.stdout, p.stderr
def quiet():
return lambda *_a: None
# ═══════════════════════════════════════════════════════════
# 1. CRC 自检
# ═══════════════════════════════════════════════════════════
class TestCRC(unittest.TestCase):
def test_vectors(self):
self.assertEqual(fw_pack.crc16_xmodem(b'123456789'), 0x31C3)
self.assertEqual(ref_crc16(b'123456789'), 0x31C3)
self.assertEqual(fw_pack.crc16_xmodem(b''), 0x0000)
self.assertEqual(fw_pack.crc16_xmodem(b'\xFF' * 20476),
ref_crc16(b'\xFF' * 20476))
fw_pack.self_test() # 不抛即通过
def test_selftest_detects_broken_impl(self):
orig = fw_pack.crc16_xmodem
fw_pack.crc16_xmodem = lambda d: 0
try:
with self.assertRaises(fw_pack.FwPackError):
fw_pack.self_test()
finally:
fw_pack.crc16_xmodem = orig
# ═══════════════════════════════════════════════════════════
# 2. Profile 解析与校验
# ═══════════════════════════════════════════════════════════
class TestProfile(unittest.TestCase):
def test_builtin_ceramixnano(self):
p = ceramix_profile()
self.assertEqual((p.flash_base, p.flash_size, p.page_size),
(0x08000000, 64 * 1024, 1024))
self.assertEqual((p.bl_addr, p.bl_size), (0x08000000, 8 * 1024))
self.assertEqual((p.slot_a.addr, p.slot_a.size), (0x08002000, 36 * 1024))
self.assertEqual(p.slot_a.anchor_addr, 0x0800AFFC)
self.assertEqual((p.slot_b.addr, p.slot_b.size), (0x0800B000, 19 * 1024))
self.assertTrue(p.slot_b.fixed_slot)
self.assertEqual(p.slot_b.record_addr, 0x0800FBF8)
self.assertEqual(p.slot_b.record_area_len(1024), 8)
self.assertEqual((p.flag_addr, p.flag_value), (0x0800FFFC, 0xEEEE))
def test_builtin_single_slot(self):
p = fw_pack.load_profile('f8_single_slot')
self.assertIsNone(p.slot_b)
self.assertEqual(p.slot_a.size, 55 * 1024)
def test_variants_b8k_b28k_nob(self):
variant_profile(47, 8) # 保底槽 8K
variant_profile(27, 28) # 保底槽 28K
variant_profile(55, None, with_b=False) # 无保底槽
# 各变体都能完成一次 A 包打包
for a_k, b_k, with_b in ((47, 8, True), (27, 28, True), (55, None, False)):
p = variant_profile(a_k, b_k, with_b)
ap = fw_pack.build_a_pack(synth_bin(0x08002000, 5000), p)
self.assertEqual(ap.pages, 5)
def test_parse_num(self):
self.assertEqual(fw_pack.parse_num('0x08002000'), 0x08002000)
self.assertEqual(fw_pack.parse_num('64K'), 65536)
self.assertEqual(fw_pack.parse_num('19k'), 19456)
self.assertEqual(fw_pack.parse_num('1024'), 1024)
self.assertEqual(fw_pack.parse_num(2048), 2048)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.parse_num('abc')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.parse_num('')
def _invalid(self, mutate, needle):
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)
self.assertIn(needle, str(cm.exception))
def test_invalid_layouts(self):
self._invalid(lambda r: r['slot_a'].__setitem__('size', '37K'),
'重叠') # A 槽压到 B 槽
self._invalid(lambda r: r['slot_a'].__setitem__('size', '35K'),
'总和') # 分区和不等于 flash
self._invalid(lambda r: r['slot_a'].__setitem__('addr', '0x08002040'),
'页对齐')
self._invalid(lambda r: r['flag'].__setitem__('addr', '0x0800F000'),
'末页')
self._invalid(lambda r: r.__setitem__('crc', 'crc32'), 'xmodem')
self._invalid(lambda r: r['slot_b'].__setitem__('record_addr', 'slot_end-4'),
'记录区') # 6B 记录塞不进 4B 区
self._invalid(lambda r: r['slot_a'].__setitem__('record', '{pages16@+0}'),
'crc')
def test_load_profile_path_and_errors(self):
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_Dual_FW'], f)
p = fw_pack.load_profile(path)
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')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.load_profile(bad)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.load_profile('no_such_profile')
# ═══════════════════════════════════════════════════════════
# 3. 向量表识别与硬门槛
# ═══════════════════════════════════════════════════════════
class TestDetect(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def test_detect_a_b(self):
self.assertEqual(fw_pack.detect_slot(synth_bin(A_BASE, 1000), self.p), 'a')
self.assertEqual(fw_pack.detect_slot(synth_bin(B_BASE, 1000), self.p), 'b')
def test_bl_input_rejected_with_hint(self):
with self.assertRaises(fw_pack.FwPackError) as cm:
fw_pack.detect_slot(synth_bin(BL_BASE, 1000), self.p)
self.assertIn('bootloader', str(cm.exception))
def test_bad_vector(self):
bad_sp = bytearray(synth_bin(A_BASE, 1000))
struct.pack_into('<I', bad_sp, 0, 0x30000000) # SP 不在 SRAM
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(bytes(bad_sp), self.p)
bad_thumb = bytearray(synth_bin(A_BASE, 1000))
struct.pack_into('<I', bad_thumb, 4, A_BASE + 0x100) # bit0=0, 非 Thumb
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(bytes(bad_thumb), self.p)
tiny = struct.pack('<II', 0x20000800, A_BASE | 1) # 只有向量表也够识别
self.assertEqual(fw_pack.detect_slot(tiny, self.p), 'a')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.detect_slot(b'\x00\x01', self.p) # 太小
def test_validate_app_bin(self):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'b')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), self.p, 'a')
no_b = fw_pack.load_profile('f8_single_slot')
with self.assertRaises(fw_pack.FwPackError):
fw_pack.validate_app_bin(synth_bin(B_BASE, 1000), no_b, 'b')
# ═══════════════════════════════════════════════════════════
# 4. A 包: 整页扩展规则
# ═══════════════════════════════════════════════════════════
class TestAPack(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def _body(self, size):
return synth_bin(A_BASE, size)
def test_against_reference_impl(self):
for size in (8, 1024, 1025, 5000, 20476, 20477, 20480, 20481, 36860):
body = self._body(size)
got = fw_pack.build_a_pack(body, self.p)
exp, exp_n, exp_crc = ref_a_pack(body)
self.assertEqual(got.data, exp, f'size={size}')
self.assertEqual((got.pages, got.crc), (exp_n, exp_crc))
self.assertEqual(len(got.data), exp_n * PAGE)
self.assertEqual(got.trailer_value,
struct.unpack('<I', exp[-4:])[0])
def test_exact_page_expands(self):
"""恰整页(body=20480B) → 扩展为 21 页, trailer pages=21"""
ap = fw_pack.build_a_pack(self._body(20480), self.p)
self.assertEqual(ap.pages, 21)
self.assertEqual(len(ap.data), 21 * PAGE)
self.assertEqual(ap.data[-4:], struct.pack('<HH', 21, ap.crc))
# CRC 覆盖 21 页去掉末 4B (含 0xFF 填充)
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
def test_remainder_lt_record_expands(self):
"""余量<4B(body=20477B, 余量3) → 扩展为 21 页"""
ap = fw_pack.build_a_pack(self._body(20477), self.p)
self.assertEqual(ap.pages, 21)
self.assertEqual(ap.crc, ref_crc16(ap.data[:21 * PAGE - 4]))
def test_remainder_eq_record_no_expand(self):
"""余量恰好=4B(body=20476B) → 不扩展, 20 页"""
ap = fw_pack.build_a_pack(self._body(20476), self.p)
self.assertEqual(ap.pages, 20)
self.assertEqual(len(ap.data), 20480)
def test_overflow_rejected(self):
"""超槽: 恰 36 整页(36864B)扩 1 页即超 36K → 报错; 36861B 余量3扩页也超"""
for size in (36861, 36864, 36865, 40000):
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
fw_pack.build_a_pack(self._body(size), self.p)
def test_max_fit(self):
"""36860B: 余量4, 36 页正好放下"""
ap = fw_pack.build_a_pack(self._body(36860), self.p)
self.assertEqual(ap.pages, 36)
self.assertEqual(len(ap.data), SLOT_A_SIZE)
def test_empty_rejected(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_a_pack(b'', self.p)
# ═══════════════════════════════════════════════════════════
# 5. B 包: 固定满槽 + 记录
# ═══════════════════════════════════════════════════════════
class TestBPack(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
def test_full_slot_input_record_rewritten(self):
"""满槽输入(19456B, 尾部为旧格式 magic+FF) → 末 8B 重写为新记录"""
body = bytearray(synth_bin(B_BASE, SLOT_B_SIZE - 8))
body += b'\xde\xc0' + b'\xff' * 6 # 旧格式尾部
bp = fw_pack.build_b_pack(bytes(body), self.p)
self.assertEqual(len(bp.data), SLOT_B_SIZE)
self.assertEqual(bp.pages, 19)
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
rec = struct.pack('<HHH', 0xC0DE, 19, bp.crc) + b'\xff\xff'
self.assertEqual(bp.record_bytes, rec[:6])
self.assertEqual(bp.data[-8:], rec)
self.assertEqual(bp.record_addr, 0x0800FBF8)
# 记录之外的槽内容与输入一致
self.assertEqual(bp.data[:SLOT_B_SIZE - 8], bytes(body[:SLOT_B_SIZE - 8]))
def test_small_input_padded(self):
body = synth_bin(B_BASE, 10000)
bp = fw_pack.build_b_pack(body, self.p)
self.assertEqual(len(bp.data), SLOT_B_SIZE)
self.assertEqual(bp.data[:10000], body)
self.assertEqual(bp.data[10000:SLOT_B_SIZE - 8],
b'\xff' * (SLOT_B_SIZE - 8 - 10000))
self.assertEqual(bp.pages, 19)
self.assertEqual(bp.crc, ref_crc16(bp.data[:SLOT_B_SIZE - 8]))
def test_overflow_rejected(self):
"""B 固件 > 槽-8B 且非满槽镜像 → 报错(固定槽不可扩展)"""
for size in (SLOT_B_SIZE - 7, SLOT_B_SIZE - 1):
with self.assertRaises(fw_pack.FwPackError, msg=f'size={size}'):
fw_pack.build_b_pack(synth_bin(B_BASE, size), self.p)
def test_exact_body_fits(self):
"""恰好 19448B(槽-8B) → 正常"""
bp = fw_pack.build_b_pack(synth_bin(B_BASE, SLOT_B_SIZE - 8), self.p)
self.assertEqual(bp.pages, 19)
def test_no_slot_b_profile(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_b_pack(synth_bin(B_BASE, 1000),
fw_pack.load_profile('f8_single_slot'))
# ═══════════════════════════════════════════════════════════
# 6. FULL 拼装 / 复核 / 篡改检测
# ═══════════════════════════════════════════════════════════
class TestFull(unittest.TestCase):
def setUp(self):
self.p = ceramix_profile()
self.a_body = synth_bin(A_BASE, 5000)
self.ap = fw_pack.build_a_pack(self.a_body, self.p)
self.b_body = synth_bin(B_BASE, SLOT_B_SIZE - 8)
self.bp = fw_pack.build_b_pack(self.b_body, self.p)
self.bl = synth_bin(BL_BASE, 4044, rst_off=0xD00)
def test_full_layout_bytes(self):
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
self.assertEqual(len(img), 64 * 1024)
base = 0x08000000
self.assertEqual(img[:4044], self.bl) # BL
self.assertTrue(all(b == 0xFF for b in img[4044:0x2000])) # BL 余量
off = A_BASE - base
self.assertEqual(img[off:off + len(self.ap.data)], self.ap.data)
self.assertEqual(img[0xAFFC:0xB000],
self.ap.record_bytes[:4]) # 槽尾锚点
self.assertTrue(all(b == 0xFF for b in
img[off + len(self.ap.data):0xAFFC])) # 空隙
self.assertEqual(img[0xB000:0xFC00], self.bp.data) # B 满槽
self.assertEqual(img[0xFFFC:0x10000], b'\xee\xee\x00\x00') # flag
self.assertTrue(all(b == 0xFF for b in img[0xFC00:0xFFFC]))
def test_full_without_bl_or_b(self):
img = fw_pack.build_full(self.p, None, self.ap, None)
self.assertTrue(all(b == 0xFF for b in img[:0x2000]))
self.assertTrue(all(b == 0xFF for b in img[0xB000:0xFC00]))
checks = fw_pack.verify_full_blob(img, self.p, None, self.a_body, None)
self.assertTrue(all(ok for _n, ok, _t in checks), checks)
def test_verify_full_all_pass(self):
img = fw_pack.build_full(self.p, self.bl, self.ap, self.bp)
checks = fw_pack.verify_full_blob(img, self.p, self.bl,
self.a_body, self.b_body)
self.assertGreaterEqual(len(checks), 17) # 规格要求 17 项
bad = [(n, t) for n, ok, t in checks if not ok]
self.assertEqual(bad, [], checks)
def test_verify_full_detects_corruption(self):
img = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
img[0x2000 + 100] ^= 0xFF # 破坏 A 体
checks = fw_pack.verify_full_blob(bytes(img), self.p, self.bl,
self.a_body, self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
img2 = bytearray(fw_pack.build_full(self.p, self.bl, self.ap, self.bp))
img2[0xFFFC] = 0x00 # 破坏 flag
checks = fw_pack.verify_full_blob(bytes(img2), self.p, self.bl,
self.a_body, self.b_body)
self.assertFalse(all(ok for _n, ok, _t in checks))
def test_verify_blobs(self):
fw_pack.verify_a_blob(self.ap.data, self.p) # 正常不抛
fw_pack.verify_b_blob(self.bp.data, self.p)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.verify_a_blob(self.ap.data[:-1] + b'\x00', self.p)
with self.assertRaises(fw_pack.FwPackError):
fw_pack.verify_b_blob(self.bp.data[:-1], self.p)
def test_bl_too_large(self):
with self.assertRaises(fw_pack.FwPackError):
fw_pack.build_full(self.p, synth_bin(BL_BASE, 8 * 1024 + 1),
self.ap, self.bp)
# ═══════════════════════════════════════════════════════════
# 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 双固件 (字节级复现验收包)
# ═══════════════════════════════════════════════════════════
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件(见 test_fw_pack.py 头部路径)')
class TestRegressionCeramixNano(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.p = ceramix_profile()
cls.a17 = open(A17, 'rb').read()
cls.a18 = open(A18, 'rb').read()
cls.b12 = open(B12, 'rb').read()
cls.ref_full = open(FULL_REF, 'rb').read()
cls.bl = cls.ref_full[:0x2000] # BL 区(4044B + 0xFF)
def test_1017_update(self):
"""1.0.17: 20 页/20480B, trailer=锚点=0xD30F0014, 与验收包逐字节一致"""
ap = fw_pack.build_a_pack(self.a17, self.p)
self.assertEqual((len(self.a17), ap.pages, len(ap.data)), (19580, 20, 20480))
self.assertEqual(ap.trailer_value, 0xD30F0014)
self.assertEqual(ap.crc, 0xD30F)
self.assertEqual(ap.data, open(U17_REF, 'rb').read())
def test_1018_update(self):
"""1.0.18: 20 页, trailer=0x6F380014 (CRC 0x6F38)"""
ap = fw_pack.build_a_pack(self.a18, self.p)
self.assertEqual((ap.pages, ap.crc, ap.trailer_value),
(20, 0x6F38, 0x6F380014))
if os.path.isfile(U18_REF):
self.assertEqual(ap.data, open(U18_REF, 'rb').read())
def test_b_record(self):
"""V1.0.12: 满槽 19456B, 记录 DE C0 13 00 6F 18 FF FF (pages=19, CRC=0x186F)"""
bp = fw_pack.build_b_pack(self.b12, self.p)
self.assertEqual(len(self.b12), 19456)
self.assertEqual(bp.data[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff'))
self.assertEqual((bp.pages, bp.crc), (19, 0x186F))
self.assertEqual(bp.crc, ref_crc16(bp.data[:19448]))
def test_full_byte_identical(self):
"""FULL 65,536B 与 2026-10-22 验收包逐字节一致 (MD5 f704b837...)"""
ap = fw_pack.build_a_pack(self.a17, self.p)
bp = fw_pack.build_b_pack(self.b12, self.p)
img = fw_pack.build_full(self.p, self.bl, ap, bp)
self.assertEqual(len(img), 65536)
self.assertEqual(hashlib.md5(img).hexdigest(),
'f704b8374ccaff7a0f0f385c59897577')
self.assertEqual(img, self.ref_full)
def test_detect_real_bins(self):
self.assertEqual(fw_pack.detect_slot(self.a17, self.p), 'a')
self.assertEqual(fw_pack.detect_slot(self.a18, self.p), 'a')
self.assertEqual(fw_pack.detect_slot(self.b12, self.p), 'b')
def test_pack_all_pipeline(self):
"""pack_all 一站式: 产物/MD5 清单/复核全链路 (临时目录)"""
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_Dual_FW', A17, b_path=B12,
bl_path=bl_path, out_dir=td, progress=quiet())
upd = open(out.paths['update'], '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(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 与验收包一致, 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')
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', 'full', 'factory'):
blob = open(out.paths[key], 'rb').read()
self.assertIn(hashlib.md5(blob).hexdigest(), 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(
fw_pack.suggest_full_name(
r'x\CeramixNano-MB_V1.0.17_APP_2026-10-21.bin',
r'y\CeramixNano-MB_V1.0.12_APP_2026-10-20.bin'),
'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin')
self.assertEqual(fw_pack.suggest_full_name(r'x\app.bin', None),
'app_FULL.bin')
# ═══════════════════════════════════════════════════════════
# 8. CLI 冒烟: v2 / legacy / 错误路径
# ═══════════════════════════════════════════════════════════
class TestCLI(unittest.TestCase):
def setUp(self):
self.td = tempfile.TemporaryDirectory()
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()
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_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_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_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)
self.assertIn('复位向量', err + out)
def test_v2_positional_and_explicit_mix(self):
rc, _out, err = run_cli(self.a, '--b', self.b)
self.assertNotEqual(rc, 0)
self.assertIn('不能混用', err)
def test_v2_bl_region_input_rejected(self):
bl_like = os.path.join(self.dir, 'bl_like.bin')
open(bl_like, 'wb').write(synth_bin(BL_BASE, 4044, rst_off=0xD00))
rc, _out, err = run_cli(bl_like)
self.assertNotEqual(rc, 0)
self.assertIn('bootloader', err)
def test_v2_output_must_be_dir(self):
rc, _out, err = run_cli(self.a, '-o', os.path.join(self.dir, 'x.bin'))
self.assertNotEqual(rc, 0)
self.assertIn('输出目录', err)
def test_v2_duplicate_slot(self):
a2 = os.path.join(self.dir, 'app_a2.bin')
open(a2, 'wb').write(synth_bin(A_BASE, 9000))
rc, _out, err = run_cli(self.a, a2)
self.assertNotEqual(rc, 0)
self.assertIn('只能有一个输入', err)
@unittest.skipUnless(HAVE_REAL, '需要 CeramixNano 固件')
def test_v2_real_firmware(self):
with tempfile.TemporaryDirectory() as td:
bl_path = os.path.join(td, 'bl.bin')
open(bl_path, 'wb').write(open(FULL_REF, 'rb').read()[:0x2000])
rc, out, err = run_cli(A17, B12, '-b', bl_path, '-o', td)
self.assertEqual(rc, 0, err or out)
full = open(os.path.join(
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')
rc, _o, err = run_cli('-f', self.a, '-o', out)
self.assertEqual(rc, 0, err)
blob = open(out, 'rb').read()
self.assertEqual(len(blob), 19580 + 900) # 纯 0xFF 补页, 无 trailer
self.assertEqual(blob[:19580], synth_bin(A_BASE, 19580))
self.assertEqual(blob[19580:], b'\xff' * 900)
def test_legacy_merge(self):
bl = os.path.join(self.dir, 'bl.bin')
open(bl, 'wb').write(synth_bin(BL_BASE, 3512, rst_off=0xD1C))
out = os.path.join(self.dir, 'legacy_bl.bin')
rc, _o, err = run_cli('-m', self.a, '-b', bl, '-o', out)
self.assertEqual(rc, 0, err)
blob = open(out, 'rb').read()
self.assertEqual(len(blob), 65536)
self.assertEqual(blob[:3512], synth_bin(BL_BASE, 3512, rst_off=0xD1C))
self.assertEqual(blob[0x2000:0x2000 + 19580], synth_bin(A_BASE, 19580))
self.assertEqual(blob[0xFFFC:], b'\xee\xee\x00\x00')
self.assertTrue(all(x == 0xFF for x in blob[0xF800:0xFC00])) # 校准页
def test_legacy_args_conflict(self):
rc, _o, err = run_cli('-f', self.a, '--profile', 'f8_single_slot')
self.assertNotEqual(rc, 0)
@unittest.skipUnless(os.path.isfile(LDC_APP) and os.path.isfile(LDC_UPD_REF),
'需要 LDC1612 原始 APP bin')
def test_legacy_ldc1612_reference(self):
"""对存量 LDC1612 包复现: -f 输出与 2026-09-26 的 update.bin 逐字节一致"""
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, 'u.bin')
rc, _o, err = run_cli('-f', LDC_APP, '-o', out)
self.assertEqual(rc, 0, err)
self.assertEqual(open(out, 'rb').read(), open(LDC_UPD_REF, 'rb').read())
if __name__ == '__main__':
print(f'fw_pack v2 回归测试 | 真机固件: {"可用" if HAVE_REAL else "缺失(跳过)"}')
unittest.main(verbosity=2)
+62
View File
@@ -0,0 +1,62 @@
"""Independent byte-level verification of gd32e230_firmware_tool.py outputs.
Layout (GD32E230F8, 64KB):
0x08000000 Bootloader (8KB region)
0x08002000 APP (max 54KB = 55296B)
0x0800F800 Calibration page (1KB, must stay 0xFF)
0x0800FC00 Flag page (1KB); flag word 0x0000EEEE at 0x0800FFFC
0x08010000 Flash end
"""
import hashlib
import struct
import sys
ok = True
def check(cond, label):
global ok
print(('PASS ' if cond else 'FAIL ') + label)
if not cond:
ok = False
bl_img = open('LDC1612_V1.0.1_APP_2026-09-26_BL.bin', 'rb').read()
upd = open('LDC1612_V1.0.1_APP_2026-09-26_update.bin', 'rb').read()
boot = open('gd32e230f8_bootloader_hulk.bin', 'rb').read()
app = open(r'E:\Hulk-Coding\ldc1612_cmake_vscode\Build\codex-bootloader-test'
r'\LDC1612_V1.0.1_APP_2026-09-26.bin', 'rb').read()
print('=== sizes / SHA-256 ===')
for name, blob in [('BL image', bl_img), ('OTA update', upd),
('bootloader', boot), ('APP bin', app)]:
print('%-11s %6dB %s' % (name, len(blob), hashlib.sha256(blob).hexdigest()))
print('=== BL image (full-chip) ===')
check(len(bl_img) == 65536, 'BL image total length == 64KB (65536B)')
check(bl_img[0:len(boot)] == boot, 'offset 0x0000: bootloader image (3512B) matches source bin')
check(all(b == 0xFF for b in bl_img[len(boot):0x2000]),
'gap 0x0DB8-0x2000 filled with 0xFF (erased flash)')
check(bl_img[0x2000:0x2000 + len(app)] == app, 'offset 0x2000: APP image (12280B) matches source bin')
check(all(b == 0xFF for b in bl_img[0x2000 + len(app):0xF800]),
'APP tail up to cal page filled with 0xFF')
check(all(b == 0xFF for b in bl_img[0xF800:0xFC00]),
'calibration page 0x0800F800-0x0800FBFF untouched (0xFF)')
check(all(b == 0xFF for b in bl_img[0xFC00:0xFFFC]),
'flag page body 0x0800FC00-0x0800FFFB stays 0xFF')
flag = struct.unpack_from('<I', bl_img, 0xFFFC)[0]
check(flag == 0x0000EEEE, 'flag word @0x0800FFFC == 0x0000EEEE (LE bytes EE EE 00 00), got 0x%08X' % flag)
check(bl_img[0xFFFC:0x10000] == b'\xee\xee\x00\x00', 'flag raw bytes @0x0800FFFC == EE EE 00 00')
check(bl_img[0xFFF0:0xFFFC] == b'\xff' * 12, 'bytes before flag untouched (0xFF)')
print('=== OTA update package ===')
check(len(upd) == 12288, 'OTA size == 12288B (12 x 1KB, page aligned)')
check(len(upd) % 1024 == 0, 'OTA size 1KB aligned')
check(len(upd) - len(app) == 8, 'OTA pad length == 8B (12280 -> 12288)')
check(upd[0:len(app)] == app, 'OTA payload == APP bin')
check(all(b == 0xFF for b in upd[len(app):]), 'OTA pad bytes all 0xFF')
check(len(upd) <= 55296, 'OTA size within 54KB APP limit')
print()
print('OVERALL: ' + ('ALL CHECKS PASSED' if ok else 'FAILURES PRESENT'))
sys.exit(0 if ok else 1)