From 031ee4efdfb1b2ac8bb539235c96480518aa85f2 Mon Sep 17 00:00:00 2001 From: hulk Date: Thu, 22 Oct 2026 00:53:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(pack):=20v2=20=E5=8F=8C=E5=9B=BA=E4=BB=B6?= =?UTF-8?q?=20profile=20=E6=89=93=E5=8C=85=20=E2=80=94=20fw=5Fpack=20?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E5=BA=93=20+=20CLI=20=E9=87=8D=E6=9E=84=20+?= =?UTF-8?q?=20GUI=20=E6=89=93=E5=8C=85=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 91 ++++ FlashTool/README.md | 54 ++- FlashTool/src/app_gui.py | 119 +++++- FlashTool/src/build_exe.py | 15 +- FlashTool/src/config.py | 7 + FlashTool/src/flash_gui.py | 54 ++- README.md | 277 ++++++------ UPGRADE_PLAN_v2.md | 108 +++++ fw_pack.py | 847 +++++++++++++++++++++++++++++++++++++ gd32e230_firmware_tool.py | 355 ++++++++++------ test_fw_pack.py | 648 ++++++++++++++++++++++++++++ verify_v101_packages.py | 62 +++ 12 files changed, 2345 insertions(+), 292 deletions(-) create mode 100644 AGENTS.md create mode 100644 UPGRADE_PLAN_v2.md create mode 100644 fw_pack.py create mode 100644 test_fw_pack.py create mode 100644 verify_v101_packages.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2f06b42 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# 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/回读复核/MD5 清单), + `test_fw_pack.py` 回归测试套件(49 项,含 CeramixNano 实测基准); + `merge_bin.py`/`fill_bin.py` 旧版脚本保留兼容, + `gd32e230f8_bootloader_hulk.bin` 出厂 bootloader; + - `FlashTool/`:DAPLink Flash GUI v2.6.0(烧录 + 打包 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。 + 回归基准(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_v51` / + `f8_single_slot` + JSON 自定义,完整性校验)、向量表自动识别 A/B 包 + (显式指定冲突报错)、A 包动态补页+trailer+整页扩展、B 包固定满槽+记录、 + FULL 整片镜像+槽尾锚点+flag、CRC-16/XMODEM 启动自检、写盘回读复核、 + 19 项 FULL 字节校验、MD5 清单+布局摘要、FULL 自动命名(项目/双版本/日期); + - `gd32e230_firmware_tool.py` v2.0.0 重构:默认 v2 模式(位置参数自动识别、 + `--a/--b/--bl/--profile/-o 目录/--no-full`),`-f/-m` 保留 legacy 行为不变; + - `test_fw_pack.py`:49 项回归全绿——规格第五节基准逐项复现(1.0.17 UPDATE + 与验收包逐字节一致、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`)。 + +### 计划 / TODO + +- skill `hulk-mcu-firmware` 缓存与 SKILL.md 同步至 v2 流程(若本次同步未完成); +- GUI v2.6.0 打包发布(build_exe → package_release → publish_gitea,需新 tag + `DAPLinkFlash-v2.6.0`); +- (待用户物理排查)当前目标板对 SWD 完全无应答(`cannot read IDR`,复位下 + 100k~4000k 全失败,同探针同工具链曾可连):需确认目标板供电、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`(源码版)。 diff --git a/FlashTool/README.md b/FlashTool/README.md index b127405..6fc5399 100644 --- a/FlashTool/README.md +++ b/FlashTool/README.md @@ -1,8 +1,9 @@ -# DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 +# DAPLink Flash — GD32 固件烧录 · 双固件打包 · 补齐 · 拼接 > GD32E230 系列的桌面烧录/固件处理工具:**OpenOCD 烧录**(DAPLink / GD-Link / -> ST-Link / J-Link)+ **补齐 Pad**(Y-Modem 页对齐)+ **拼接 Merge** -> (Bootloader + APP + 有效标志 → 整片镜像)。 +> ST-Link / J-Link)+ **打包 v2**(双固件 profile 布局 → UPDATE / B / FULL 镜像 +> + MD5 清单,与命令行工具共用 `fw_pack.py` 公共库)+ **补齐 Pad**(Y-Modem +> 页对齐)+ **拼接 Merge**(Bootloader + APP + 有效标志 → 整片镜像)。 > > - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases) > 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe` @@ -10,7 +11,7 @@ > - **要改代码/重新打包**:见 §5、§6。 > > 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色卡片仪表盘 + -> PyInstaller onefile + Gitea Release 发布)。更新:2026-09-14 +> PyInstaller onedir + Gitea Release 发布)。更新:2026-10-22 ## 1. 功能总览 @@ -20,10 +21,11 @@ | 固件格式 | BIN / HEX / ELF——HEX/ELF 自动用文件内地址,仅 BIN 需填基地址(完整镜像 `0x08000000`,单独 APP `0x08002000`) | | 芯片型号 | GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小与 target 配置 | | 烧录器 | DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP(TCP)、Custom;支持适配器速度与探针序列号 | +| 打包 v2 | 双固件 profile 布局(默认 `ceramixnano_mb_v51`):A/B 包向量表自动识别、补页+trailer/满槽+记录、CRC-16/XMODEM、FULL 整片镜像、MD5 清单、写盘回读复核 | | 补齐 Pad | BIN 尾部补 `0xFF` 到 1K/2K/4K 页边界;bootloader 的 Y-Modem 只按 1K 整包写 Flash,最后一包不满必须先补齐 | | 拼接 Merge | Bootloader + APP + 有效标志(`0xEEEEEEEE` @`0x0800FFFC`) → 整片 Flash 镜像,逻辑与 `merge_bin.py` 一致 | | 内置工具链 | 发布包自带 OpenOCD(`toolchain/openocd/`),GUI 自动定位,免安装免配置 | -| 命令行模式 | 全部功能可脚本化调用(`--flash / --pad / --merge ...`,见 §4) | +| 命令行模式 | 全部功能可脚本化调用(`--flash / --pack / --pad / --merge ...`,见 §4) | | 外观 | 跟随系统深/浅色,顶栏可切换 Dark / Light / System | ## 2. 下载与安装 @@ -54,15 +56,15 @@ DAPLinkFlash/ ## 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),左卡右上 @@ -76,12 +78,16 @@ DAPLinkFlash/ 单独 APP `0x08002000`); 4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区; 5. **固件工具**(顶栏按钮弹出的独立窗口,非模态): + - 打包 v2:选 profile(内置下拉或浏览 JSON)+ BL/A 包/B 包 bin(至少一个)+ + 输出目录,点"执行打包"→ `*_UPDATE.bin` / `*_B.bin` / `*_FULL.bin` + MD5 清单; + A/B 槽按向量表自动校验,逐项字节校验与回读复核结果流进主日志; - 补齐:选输入 BIN 后输出名自动带出(`*_update.bin`,Y-Modem 升级包),对齐默认 1K,点"执行补齐"; - 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`,整片镜像),地址按典型布局 预填,点"执行拼接"。 -典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC` -有效标志 `0xEEEEEEEE` | `0x08010000` Flash 结束 (64KB)。 +v2 双固件布局(默认 profile `ceramixnano_mb_v51`)与打包规则详见仓库根 +[README.md](../README.md);legacy 单槽布局:`0x08000000` Bootloader 8KB | +`0x08002000` APP | `0x0800FFFC` 有效标志 `0xEEEEEEEE`。 配置(全部输入框的值)自动保存在 exe 旁的 `flash_gui_settings.json`,下次启动 自动恢复;旧版 `config/flash_gui_<主机名>.json` 在首次启动时自动迁移。 @@ -102,8 +108,11 @@ python src/flash_gui.py --read dump.bin 读取整片 Flash python src/flash_gui.py --reset 复位运行 python src/flash_gui.py --pad app.bin 补齐到 1K → app_pad.bin python src/flash_gui.py --merge bl.bin app.bin 拼接 → app_BL.bin - --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/B/FULL+MD5 + --align N / --pad-byte 0xFF / -o OUT pad 参数(-o 对 pack 为目录) --app-addr / --flag-addr / --flash-size / --flag-value merge 参数 + --pack-profile ceramixnano_mb_v51|JSON / --pack-no-full pack 参数 --speed 4000 / --serial SN / --interface cfg / --target cfg --openocd PATH / --scripts DIR 手动指定工具链(默认自动定位) ``` @@ -116,7 +125,7 @@ python src/flash_gui.py --merge bl.bin app.bin 拼接 → app_BL.bin FlashTool/ ├── src/ # 源代码(人工维护区) │ ├── flash_gui.py # 入口与命令行模式(argparse 分发) -│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘) +│ ├── app_gui.py # CustomTkinter 主界面(单列仪表盘 + 打包/补齐/拼接弹窗) │ ├── openocd_runner.py # 工具链定位 / OpenOCD 命令构建与子进程 │ ├── fw_image.py # 补齐(pad)/拼接(merge) + 芯片参数表 │ ├── config.py # 运行目录与 flash_gui_settings.json 配置 @@ -134,8 +143,13 @@ FlashTool/ 从源码运行:`python src/flash_gui.py` 或双击 `start_flash_gui.bat`, 需 Python ≥3.10 + customtkinter(`py -m pip install customtkinter`); -OpenOCD 用本机安装(默认找 `D:/toolchain/openocd`)或先跑一次 -`package_release.py` 把工具链放进 `release/toolchain/`。 +OpenOCD 默认找本机 `D:/toolchain/openocd`,要 vendor 的话把工具链放到 +`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. 构建与发布 @@ -159,6 +173,16 @@ python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip --insecure 处理见 §2 的三种绕过方式;要彻底消除需购买代码签名证书对 exe 签名; - **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/` 目录和 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 固件试试; - **exe 放只读目录**:`flash_gui_settings.json` 写在 exe 旁边,只读目录存不了配置; diff --git a/FlashTool/src/app_gui.py b/FlashTool/src/app_gui.py index 2a80b89..89ba9b4 100644 --- a/FlashTool/src/app_gui.py +++ b/FlashTool/src/app_gui.py @@ -28,12 +28,20 @@ import openocd_runner from fw_image import (CHIP_PROFILES, FLAG_VALUE_DEFAULT, FLASH_BASE_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 + MONO = ('Consolas', 12) BOLD = ('Microsoft YaHei UI', 13, 'bold') TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') APP_NAME = 'DAPLink Flash' -APP_VERSION = '2.5.2' +APP_VERSION = '2.6.0' REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool' OK_BLUE = '#3B8ED0' @@ -110,6 +118,7 @@ class App(ctk.CTk): self._op_buttons = [] self.v_pad_result = tk.StringVar(value='') self.v_merge_result = tk.StringVar(value='') + self.v_pack_result = tk.StringVar(value='') self._build() 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='GD32 固件烧录', 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, - 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)) ctk.CTkButton(top, text='拼接 (Merge)', width=100, height=26, 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) # ============================================================ - # 固件工具弹窗: 补齐 / 拼接 + # 固件工具弹窗: 打包(v2) / 补齐 / 拼接 # ============================================================ 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) 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 / B / FULL + 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(OA/Ymodem) B 包→*_B.bin(满槽+记录) ' + 'FULL→整片镜像(@0x08000000)+*.md5.txt', + text_color='gray50', anchor='w').pack(fill='x', pady=(2, 0)) + self._center_dialog(dlg, 720, 400) + def _open_pad_dialog(self): dlg = ctk.CTkToplevel(self) dlg.title('固件工具 — 补齐 (Pad)') @@ -501,6 +548,38 @@ class App(ctk.CTk): if 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): 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())) @@ -634,8 +713,39 @@ class App(ctk.CTk): 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', 'b': 'B 包', 'full': 'FULL', '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): src = self.v['pad_in'].get().strip() dst = self.v['pad_out'].get().strip() @@ -737,6 +847,7 @@ class App(ctk.CTk): ctk.CTkLabel(box, text=f'v{APP_VERSION} — GD32E230 固件烧录与打包工具', text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12)) for line in ('OpenOCD 烧录: 检测 / 烧录 / 擦除 / 读取 / 复位 (DAPLink·GD-Link·ST-Link·J-Link)', + '打包 v2: 双固件 profile 布局 → UPDATE / B / FULL 镜像 + MD5 清单', '补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem', '拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像', '发布包内置 OpenOCD 工具链, 免安装解压即用', diff --git a/FlashTool/src/build_exe.py b/FlashTool/src/build_exe.py index 21b3920..16866b5 100644 --- a/FlashTool/src/build_exe.py +++ b/FlashTool/src/build_exe.py @@ -28,6 +28,9 @@ BASE = os.path.dirname(SRC) DIST = os.path.join(BASE, 'release') WORK = os.path.join(SRC, 'build') 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(): @@ -41,13 +44,19 @@ def ensure_pyinstaller(): def extra_collect_args(): - """CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错""" + """CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错; + fw_pack 是仓库根的公共库, 需进 module 搜索路径 + hiddenimport""" + args = [] try: import customtkinter # noqa: F401 - return ['--collect-all', 'customtkinter'] + args += ['--collect-all', 'customtkinter'] except ImportError: 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): diff --git a/FlashTool/src/config.py b/FlashTool/src/config.py index 03119ab..108270a 100644 --- a/FlashTool/src/config.py +++ b/FlashTool/src/config.py @@ -56,6 +56,13 @@ DEFAULTS = { "flag_addr": "0x0800FFFC", "merge_flash": "0x10000", "flag_value": "0xEEEEEEEE", + # 打包 (v2 双固件, fw_pack) + "pack_profile": "ceramixnano_mb_v51", + "pack_bl": "", + "pack_a": "", + "pack_b": "", + "pack_out": "", + "pack_full": True, } diff --git a/FlashTool/src/flash_gui.py b/FlashTool/src/flash_gui.py index eba187c..1e26e91 100644 --- a/FlashTool/src/flash_gui.py +++ b/FlashTool/src/flash_gui.py @@ -26,6 +26,8 @@ DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 — 入口与命 python flash_gui.py --reset 复位运行 python flash_gui.py --pad app.bin 补齐到 1K 边界(app_pad.bin) python flash_gui.py --merge bl.bin app.bin 拼接整片镜像(*_BL.bin) + python flash_gui.py --pack a.bin b.bin --pack-bl bl.bin + v2 双固件打包(UPDATE/B/FULL+MD5) 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, 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): """命令行工具链解析: 参数 > 配置文件 > 自动定位(内置/本机/PATH)""" @@ -121,6 +131,37 @@ def run_merge(args): 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 包)') + 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) + except fw_pack.FwPackError as exc: + sys.exit(f'打包失败: {exc}') + return 0 + + def _ensure_parent(path): parent = os.path.dirname(os.path.abspath(path)) if parent: @@ -145,9 +186,18 @@ def main(): op.add_argument('--pad', metavar='FILE', dest='pad', help='补齐 BIN 到页边界') op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'), help='拼接 Bootloader + APP → 整片镜像') + op.add_argument('--pack', nargs='+', metavar='BIN', + help='v2 双固件打包: A/B 包自动识别 → UPDATE/B/FULL+MD5') + ap.add_argument('--pack-profile', default='ceramixnano_mb_v51', + help='--pack 布局 profile(内置名或 JSON 路径)') + ap.add_argument('--pack-bl', default=None, metavar='BIN', + help='--pack 使用的 Bootloader bin') + ap.add_argument('--pack-no-full', action='store_true', + help='--pack 只出 UPDATE/B 包, 不拼 FULL') ap.add_argument('--align', default='1024', help='--pad 对齐字节(默认 1024)') 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, help='--merge APP 地址(默认 0x08002000)') ap.add_argument('--flag-addr', default='0x%08X' % FLAG_ADDR_DEFAULT, @@ -174,6 +224,8 @@ def main(): ap.add_argument('--scripts', help='OpenOCD scripts 目录(默认自动定位)') args = ap.parse_args() + if args.pack: + sys.exit(run_pack(args)) if args.pad: sys.exit(run_pad(args)) if args.merge: diff --git a/README.md b/README.md index dad5906..1495bd2 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,126 @@ # GD32E230 固件工具 -本仓库提供 GD32E230 系列固件的处理与烧录工具: +本仓库提供 GD32E230 系列固件的打包与烧录工具: -- **DAPLink Flash GUI(推荐)**:图形界面烧录工具,位于 `FlashTool/` -- **命令行固件工具**:`gd32e230_firmware_tool.py`(合成 + 补齐二合一) -- **旧版脚本**:`merge_bin.py`、`fill_bin.py`(保留兼容) +- **v2 双固件打包(默认)**:`gd32e230_firmware_tool.py` + `fw_pack.py` 公共库—— + 布局 profile 化,输入编译好的 bin 自动识别 A/B 槽,产出 OTA 包 / B 底包 / + 整片镜像 + MD5 清单,写盘后回读复核; +- **DAPLink Flash GUI(推荐)**:图形界面烧录工具(含 v2 打包弹窗),位于 `FlashTool/`; +- **legacy 单槽模式(过渡兼容)**:`-f` 纯 0xFF 补页 / `-m` 拼 BL+APP+flag; +- **旧版脚本**:`merge_bin.py`、`fill_bin.py`(保留兼容)。 -## Flash 布局 +## v2 双固件打包 + +### 布局(默认 profile `ceramixnano_mb_v51`,GD32E230F8/64K) ```text 0x08000000 ┌──────────────────┐ - │ Bootloader │ 8KB + │ Bootloader │ 8K 页 0~7 0x08002000 ├──────────────────┤ - │ │ - │ Application │ 最大 54KB (F8) / 22KB (F6) / 6KB (F4) - │ │ -0x0800F800 ├──────────────────┤ - │ 校准数据 │ 1KB (预留) + │ FW_A 运行槽 │ 36K 页 8~43 OTA/Ymodem 唯一接收区 +0x0800AFFC │ [槽尾锚点]──────│ ← 与 trailer 同值 {页数16|CRC16} +0x0800B000 ├──────────────────┤ + │ FW_B 出厂保底槽 │ 19K 页 44~62 固定满槽,产线冻结 +0x0800FBF8 │ [B 记录 8B]─────│ ← magic|页数|CRC|2B 保留 0x0800FC00 ├──────────────────┤ - │ 0xEEEE 标志 │ 1KB + │ 0xEEEE 标志 │ 1K 页 63 @0x0800FFFC 0x08010000 └──────────────────┘ ``` -- 空白区域填充 `0xFF`(Flash 擦除默认值) -- 标志位 `0xEEEE` 告诉 Bootloader:APP 有效,直接跳转 -- 校准页当前为预留空间 +### 计算规则(与 BL dev_fact v5.1 逐字一致) + +- **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, B 包 → *_B.bin, 整片 → *_FULL.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 + +# 仅 B 底包(产线备用,不拼 FULL) +python gd32e230_firmware_tool.py app_b.bin +``` + +输入 bin 从**向量表自动识别** A/B 包(初始 SP 须在 SRAM `[0x20000000,0x20002000]`、 +复位向量 Thumb 且落在对应槽区间);也可 `--a`/`--b` 显式指定,与向量表判定冲突时报错。 +写盘后回读复核(重算 trailer/记录/锚点一致才报成功),并输出布局摘要 + 19 项字节校验。 + +### 产物 + +| 产物 | 用途 | +|---|---| +| `*_UPDATE.bin` | A 包(补页+trailer),Ymodem/OTA 推送 | +| `*_B.bin` | B 底包(满槽+记录),产线单独烧 B 槽备用 | +| `*_FULL.bin` | 整片镜像,`@0x08000000` 整片烧录(底包) | +| `*.md5.txt` | 全部产物 MD5 清单 + 布局摘要 | + +FULL 镜像自动命名 `<项目>_FULL_A-V<版本>_B-V<版本>[_日期].bin`(从输入文件名推导)。 + +### 布局 profile(可复用到任意工程) + +内置 `ceramixnano_mb_v51`(默认)与 `f8_single_slot`(无保底槽)。其他工程写一份 +JSON 即可(保底槽 8K/28K、无保底槽、BL 更大等都支持),工具做完整性校验 +(分区不重叠、页对齐、总和=flash_size): + +```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" +} +``` + +```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_v51` | 内置名或 profile JSON 路径 | +| `-o, --output` | 输入所在目录 | **输出目录**(产物自动命名) | +| `--no-full` | — | 只出 UPDATE/B 包,不拼 FULL | + +### 回归测试 + +```bash +python test_fw_pack.py # 49 项: 规格基准 + 边界 + 篡改检测 + CLI 冒烟 +``` + +覆盖 UPGRADE_PLAN_v2.md 第五节全部基准(CeramixNano 1.0.17/1.0.18/V1.0.12 +实测值逐字节复现、恰整页/余量<4 扩页等);真机固件不在本仓库时相关用例自动跳过。 ## DAPLink Flash GUI(推荐) 日常烧录优先使用图形界面工具,不需要打开 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) 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压后进入 `DAPLinkFlash` 文件夹 @@ -36,165 +128,50 @@ (`toolchain/openocd/`),目标机器无需任何环境。 - **源码运行**:`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat` (需 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、复位芯片、停止 -- 固件格式:BIN / HEX / ELF - - 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 升级包发送前请先用“补齐”功能处理。 - -## 命令行固件工具 - -### 快速开始 +旧单槽方案(LDC1612 等,布局:BL 8K + APP 54K + 校准页 1K + 标志页 1K), +`-f`/`-m` 触发: ```bash -# 合成完整镜像(整片烧录用) -python gd32e230_firmware_tool.py -m app.bin - -# 补齐升级包(Ymodem 升级用) -python gd32e230_firmware_tool.py -f app.bin +python gd32e230_firmware_tool.py -f app.bin # 纯 0xFF 补页, 无 trailer → *_UPDATE.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 芯片 +python gd32e230_firmware_tool.py -m app.bin -a 0x0800B000 -b bl.bin # FW_B 槽落位 ``` -### 模式说明 - -#### `-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 镜像 | -| `-f, --fill` | — | 补齐到整页边界 | +| `-m, --merge` | — | 合成完整 Flash 镜像(不补齐) | +| `-f, --fill` | — | 补齐到整页边界(纯 0xFF) | | `-s, --flash-size` | `F8` | 芯片型号:`F4`(16K) `F6`(32K) `F8`(64K) | -| `-b, --bootloader` | `gd32e230f8_bootloader_hulk.bin` | Bootloader .bin 路径 | -| `-o, --output` | 自动生成 | 输出文件路径 | -| `-h, --help` | — | 查看帮助 | +| `-b, --bootloader` | 内置 `gd32e230f8_bootloader_hulk.bin` | Bootloader .bin 路径 | +| `-o, --output` | 自动生成 | **输出文件**路径 | +| `-a, --app-addr` | `0x08002000` | APP 落位地址(1K 页对齐) | -> **`-m` 和 `-f` 互斥**,每次只能选一种模式。 - -### 使用示例 - -```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 -``` +> `-m` 与 `-f` 互斥;新工程请用 v2 模式(不带 `-f/-m`)。 ### 旧版脚本 -`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)。v5 格式临时脚本 `make_v5_update.py` +已于 v2 落地后删除(能力并入 `fw_pack.py`)。 ### 烧录 ```bash # OpenOCD 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 -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 MIT diff --git a/UPGRADE_PLAN_v2.md b/UPGRADE_PLAN_v2.md new file mode 100644 index 0000000..df63cbc --- /dev/null +++ b/UPGRADE_PLAN_v2.md @@ -0,0 +1,108 @@ +# gd32e230f8_firmware_merge_tool v2 升级计划(需求规格 / 提示词) + +> 本文可直接作为 AI 辅助重构的提示词使用;也是本次彻底升级的需求基线。 +> 背景仓库:本仓库(main 分支);配套 skill:hulk-mcu-firmware(SKILL.md + tool/ 缓存)。 +> 首个目标工程:CeramixNano-MB(GD32E230F8/64K,双固件 v5.1 布局,BL 侧 dev_fact@512bf7a)。 + +--- + +## 一、背景与目标 + +现有 `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。" diff --git a/fw_pack.py b/fw_pack.py new file mode 100644 index 0000000..131f937 --- /dev/null +++ b/fw_pack.py @@ -0,0 +1,847 @@ +#!/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}) + *_B.bin B 槽底包 (固定满槽 + 槽末 8B 记录 {magic16|pages16|crc16|2B FF}) + *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志) + *.md5.txt 产物 MD5 清单 + 布局摘要 + +规则与 BL dev_fact 侧 (v5.1) 逐字一致: + - 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', + 'verify_a_blob', 'verify_b_blob', 'verify_full_blob', + 'pack_all', 'suggest_full_name', +] + +TOOL_VERSION = '2.0' + +# GD32E230 家族 SRAM: F8 最大 8K, SP 必须落在其中 (F4/F6 的 4K 也在范围内) +SRAM_LO, SRAM_HI = 0x20000000, 0x20002000 + + +class FwPackError(Exception): + """打包过程中的所有可预期错误(参数/校验/尺寸), 消息可直接展示给用户""" + + +# ═══════════════════════════════════════════════════════════ +# CRC-16/XMODEM +# ═══════════════════════════════════════════════════════════ + +def crc16_xmodem(data: bytes) -> int: + """CRC-16/XMODEM: poly 0x1021, init 0x0000, 不反转, 无 xorout""" + crc = 0 + for b in data: + crc ^= b << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF + return crc + + +def self_test(): + """启动自检: CRC 实现必须命中已知测试向量, 否则拒绝工作""" + if crc16_xmodem(b'123456789') != 0x31C3: + raise FwPackError('CRC-16/XMODEM 自检失败 (期望 b"123456789" -> 0x31C3)') + + +# ═══════════════════════════════════════════════════════════ +# 数值 / 表达式解析 +# ═══════════════════════════════════════════════════════════ + +def parse_num(text, what='数值'): + """解析 '0x08002000' / '64K' / '19k' / '1M' / '1024'; 非法抛 FwPackError""" + if isinstance(text, bool): + raise FwPackError(f'{what}: {text!r} 不是数值') + if isinstance(text, int): + return text + s = str(text).strip().lower() + if not s: + raise FwPackError(f'{what}: 空值') + mult = 1 + if s.endswith('k'): + mult, s = 1024, s[:-1] + elif s.endswith('m'): + mult, s = 1024 * 1024, s[:-1] + try: + if s.startswith('0x') or s.startswith('-0x'): + return int(s, 16) * mult + return int(s, 10) * mult + except ValueError: + raise FwPackError(f'{what}: 无法解析 {text!r}') from None + + +def _parse_addr_expr(text, slot_base, slot_end, what): + """地址表达式: 绝对地址, 或 'slot_end-4' / 'slot_start+8'""" + s = str(text).strip().lower().replace(' ', '') + m = re.fullmatch(r'(slot_end|slot_start)([+-]\d+)?', s) + if m: + base = slot_end if m.group(1) == 'slot_end' else slot_base + return base + int(m.group(2) or 0) + return parse_num(s, what) + + +# ═══════════════════════════════════════════════════════════ +# 记录(record)规格: "{pages16@+0, crc16@+2}" +# ═══════════════════════════════════════════════════════════ + +_RECORD_FIELD_RE = re.compile( + r'^(?Pmagic|pages|crc)(?P16|32)?@(?P\+\d+)' + r'(?:=(?P0x[0-9a-fA-F]+|\d+))?$') +_KNOWN_FIELDS = ('magic', 'pages', 'crc') + + +class RecordField: + def __init__(self, name, bits, offset, fixed): + self.name, self.bits, self.offset, self.fixed = name, bits, offset, fixed + + +class RecordSpec: + """从 "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}" 解析出的记录布局""" + + def __init__(self, fields): + self.fields = fields + + @classmethod + def parse(cls, text, what='record'): + s = str(text).strip() + if not (s.startswith('{') and s.endswith('}')): + raise FwPackError(f'{what}: 必须形如 "{{pages16@+0, crc16@+2}}", 得到 {text!r}') + fields = [] + for part in s[1:-1].split(','): + part = part.strip() + if not part: + continue + m = _RECORD_FIELD_RE.match(part) + if not m: + raise FwPackError(f'{what}: 无法解析字段 {part!r} ' + f'(支持 magic16/pages16/crc16@+偏移[=固定值])') + fields.append(RecordField(m.group('name'), int(m.group('bits') or 16), + int(m.group('off')[1:]), + int(m.group('val'), 0) if m.group('val') else None)) + if not fields: + raise FwPackError(f'{what}: 至少需要一个字段') + names = [f.name for f in fields] + for need in ('pages', 'crc'): + if need not in names: + raise FwPackError(f'{what}: 缺少 {need}16 字段') + return cls(fields) + + @property + def length(self): + """记录本体字节数(不含区内尾部保留字节)""" + return max(f.offset + f.bits // 8 for f in self.fields) + + def pack(self, pages, crc, what='record'): + """按布局写出记录字节; pages/crc 字段自动填入, 固定值字段校验存在""" + buf = bytearray(b'\xFF' * self.length) + for f in self.fields: + val = f.fixed if f.name == 'magic' else ( + pages if f.name == 'pages' else crc if f.name == 'crc' else None) + if val is None: + raise FwPackError(f'{what}: 未知字段 {f.name}') + if f.name == 'crc': + val &= (1 << f.bits) - 1 + if f.bits == 16: + struct.pack_into(' pg: + raise FwPackError(f'{slot.name}.record 记录区 {area}B 非法 (须 1~{pg}B)') + if slot.record.length > area: + raise FwPackError(f'{slot.name}.record 本体 {slot.record.length}B ' + f'超过记录区 {area}B') + if slot.record_addr is not None and slot.record_addr + area != slot.end: + raise FwPackError(f'{slot.name}.record_addr+记录区 必须等于槽末' + f' (BL 约定记录位于槽尾)') + if slot.anchor_addr is not None and not (slot.addr <= slot.anchor_addr + and slot.anchor_addr + 4 <= slot.end): + raise FwPackError(f'{slot.name}.anchor 0x{slot.anchor_addr:08X} ' + f'不在槽区间内') + + def slot(self, which): + if which == 'a': + return self.slot_a + if which != 'b': + raise FwPackError(f'未知槽位 {which!r} (只能是 a/b)') + if self.slot_b is None: + raise FwPackError('当前 profile 未定义 slot_b (无保底槽布局)') + return self.slot_b + + def describe(self): + pg = self.page_size + lines = [f'profile: {self.name} ({self.chip}, ' + f'{self.flash_size // 1024}K, page {pg}B)'] + first_pg = 0 + rows = [('BL', self.bl_addr, self.bl_size, ''), + ('FW_A', self.slot_a.addr, self.slot_a.size, 'OTA/Ymodem 接收槽')] + if self.slot_b: + rows.append(('FW_B', self.slot_b.addr, self.slot_b.size, + '出厂保底槽' + (' (固定满槽)' if self.slot_b.fixed_slot else ''))) + rows.append(('FLAG', self.flag_page, pg, + f'0x{self.flag_value:04X} @ 0x{self.flag_addr:08X}')) + for name, addr, size, note in rows: + pages = f'页 {first_pg}~{first_pg + size // pg - 1}' + lines.append(f' {name:<5} 0x{addr:08X}-0x{addr + size - 1:08X} ' + f'{size // 1024:>3}K {pages:<12}{note}') + first_pg += size // pg + return '\n'.join(lines) + + +def load_profile(spec): + """内置名(ceramixnano_mb_v51 / f8_single_slot) 或 profile JSON 文件路径""" + if not spec: + spec = 'ceramixnano_mb_v51' + if spec in BUILTIN_PROFILES: + return Profile(dict(BUILTIN_PROFILES[spec])) + if os.path.isfile(spec): + try: + with open(spec, 'r', encoding='utf-8') as f: + return Profile(json.load(f)) + except ValueError as exc: + raise FwPackError(f'profile JSON 解析失败: {exc}') from None + raise FwPackError(f'profile {spec!r} 既不是内置名 ({"/".join(BUILTIN_PROFILES)}) ' + f'也不是存在的 JSON 文件') + + +# ═══════════════════════════════════════════════════════════ +# 向量表: 读取 / 校验 / 自动识别 A|B +# ═══════════════════════════════════════════════════════════ + +def read_vector(data): + if len(data) < 8: + raise FwPackError(f'文件只有 {len(data)}B, 连向量表(SP+复位向量)都放不下') + return struct.unpack_from('= 4 else None + + +def build_a_pack(data, profile, which='a'): + """动态补页打包: 补 0xFF 至整 N 页, 末 R 字节写 {pages16|crc16}。 + + 整页扩展规则: body 恰为整页倍数, 或 余量 < R → 向后扩一页; + 扩展后须仍 ≤ 槽大小。CRC 范围 = N 页内容去掉末 R 字节(含 0xFF 填充)。 + """ + slot = profile.slot(which) + if slot.fixed_slot: + raise FwPackError(f'{slot.name} 是固定满槽, 请按 B 包流程处理') + R = slot.record_area_len(profile.page_size) + body = bytes(data) + if not body: + raise FwPackError(f'{slot.name} 输入为空文件') + pages = (len(body) + profile.page_size - 1) // profile.page_size + if len(body) % profile.page_size == 0 or pages * profile.page_size - len(body) < R: + pages += 1 # 恰整页 / 记录放不下 → 扩一页 + pack_len = pages * profile.page_size + if pack_len > slot.size: + raise FwPackError(f'{slot.name} 固件 {len(body)}B 需 {pages} 页({pack_len}B), ' + f'超过槽容量 {slot.size}B({slot.size // profile.page_size} 页)') + buf = bytearray(body + b'\xFF' * (pack_len - len(body))) + crc = crc16_xmodem(bytes(buf[:pack_len - R])) + record = slot.record.pack(pages, crc, f'{slot.name}.record') + buf[pack_len - len(record):] = record + return APackResult(bytes(buf), len(body), pages, crc, record) + + +# ═══════════════════════════════════════════════════════════ +# B 包 (固定满槽 + 槽末记录) +# ═══════════════════════════════════════════════════════════ + +class BPackResult: + def __init__(self, data, body_len, pages, crc, record_bytes, record_addr): + self.data = data # 满槽镜像含记录 + self.body_len, self.pages, self.crc = body_len, pages, crc + self.record_bytes, self.record_addr = record_bytes, record_addr + + +def build_b_pack(data, profile): + """固定满槽打包: 补 0xFF 恰好填满槽, 槽末记录区写 {magic|pages|crc|保留FF}。 + + 输入恰好等于槽大小 → 视为已含旧记录的满槽镜像, 末记录区原样丢弃重写; + 固件 > 槽-R → 报错 (固定槽不可向后扩展)。CRC 范围 = 槽内容去掉记录区。 + """ + slot = profile.slot_b + if slot is None: + raise FwPackError('当前 profile 无 slot_b') + R = slot.record_area_len(profile.page_size) # 记录区(含尾部保留字节) + body = bytes(data) + if not body: + raise FwPackError('slot_b 输入为空文件') + if len(body) == slot.size: + body = body[:slot.size - R] # 满槽镜像: 丢弃旧记录区 + if len(body) > slot.size - R: + raise FwPackError(f'B 固件 {len(body)}B 超过固定槽容量 槽{slot.size}B-{R}B记录 ' + f'= {slot.size - R}B (固定槽不可扩展, 请裁剪固件或加大槽)') + image = bytearray(body + b'\xFF' * (slot.size - len(body))) + crc = crc16_xmodem(bytes(image[:slot.size - R])) + pages = slot.size // profile.page_size + record = slot.record.pack(pages, crc, 'slot_b.record') + image[slot.size - R:slot.size - R + len(record)] = record + return BPackResult(bytes(image), len(body), pages, crc, record, + slot.end - R) + + +# ═══════════════════════════════════════════════════════════ +# FULL 整片镜像 +# ═══════════════════════════════════════════════════════════ + +def build_full(profile, bl_data, a_pack, b_pack=None): + """BL + A 包 + 槽尾锚点 + 空隙 0xFF + B 满槽 + flag → 整片镜像""" + image = bytearray(b'\xFF' * profile.flash_size) + if bl_data: + if len(bl_data) > profile.bl_size: + raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B') + image[profile.bl_addr - profile.flash_base: + profile.bl_addr - profile.flash_base + len(bl_data)] = bl_data + sa = profile.slot_a + off = sa.addr - profile.flash_base + image[off:off + a_pack.pack_len] = a_pack.data + if sa.anchor_addr is not None and len(a_pack.record_bytes) >= 4: + anchor = a_pack.record_bytes[:4] # 锚点与 trailer 同值 + image[sa.anchor_addr - profile.flash_base: + sa.anchor_addr - profile.flash_base + 4] = anchor + if b_pack is not None: + sb = profile.slot_b + off = sb.addr - profile.flash_base + image[off:off + sb.size] = b_pack.data + struct.pack_into('{R} 的整页倍数)') + pages = len(blob) // profile.page_size + crc = crc16_xmodem(blob[:-R]) + expect = slot.record.pack(pages, crc) + if blob[-len(expect):] != expect: + raise FwPackError(f'A 包 trailer 复核失败: 文件 {blob[-len(expect):].hex(" ")} ' + f'应为 {expect.hex(" ")} (pages={pages}, CRC=0x{crc:04X})') + return pages, crc, expect + + +def verify_b_blob(blob, profile): + """B 包复核: 长度=满槽, 记录区从槽内容重算比对""" + slot = profile.slot_b + R = slot.record_area_len(profile.page_size) + if len(blob) != slot.size: + raise FwPackError(f'B 包长度 {len(blob)}B != 满槽 {slot.size}B') + crc = crc16_xmodem(blob[:slot.size - R]) + expect = slot.record.pack(slot.size // profile.page_size, crc) + if blob[slot.size - R:slot.size - R + len(expect)] != expect: + raise FwPackError(f'B 包记录复核失败: 文件 ' + f'{blob[slot.size - R:slot.size].hex(" ")} 应为 ' + f'{expect.hex(" ")}') + return expect + + +def verify_full_blob(blob, profile, bl_data, a_body, b_body=None): + """FULL 镜像逐项字节校验(规格 4.5: 分区内容/空隙/记录/flag, 17+ 项)。 + 全部从镜像字节独立重算, 与构建路径交叉验证。 + 返回 [(名称, 通过?, 说明)] 列表, 由调用方汇总展示。""" + base = profile.flash_base + sa = profile.slot_a + pg = profile.page_size + checks = [] + + def chk(name, ok, note=''): + checks.append((name, bool(ok), note)) + + # 1. 总长度 + chk('总长度=flash_size', len(blob) == profile.flash_size, + f'{len(blob)}/{profile.flash_size}') + # 2-3. BL 区 + if bl_data: + chk(f'BL 内容==输入({len(bl_data)}B)', + blob[:len(bl_data)] == bl_data) + chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)', + all(b == 0xFF for b in blob[len(bl_data):profile.bl_size])) + else: + chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size])) + # 4-11. A 槽 + a_off = sa.addr - base + R = sa.record_area_len(pg) + pages = max((len(a_body) + pg - 1) // pg, 1) + if len(a_body) % pg == 0 or pages * pg - len(a_body) < R: + pages += 1 + pack_len = pages * pg + crc = crc16_xmodem(blob[a_off:a_off + pack_len - R]) + trailer = sa.record.pack(pages, crc) + chk(f'A 体==输入固件({len(a_body)}B)', blob[a_off:a_off + len(a_body)] == a_body) + chk(f'A 补页 0xFF({pack_len - R - len(a_body)}B)', + all(b == 0xFF for b in blob[a_off + len(a_body):a_off + pack_len - R])) + _off = {f.name: f.offset for f in sa.record.fields} + rec_off = a_off + pack_len - R + t_pages = struct.unpack_from('.+?)_?V(?P\d+\.\d+\.\d+)', re.IGNORECASE) +_DATE_RE = re.compile(r'_(\d{4}-\d{2}-\d{2})$') + + +def suggest_full_name(a_path, b_path=None): + """从输入名推导 FULL 镜像名: 项目前缀 + 双版本号 (+共同日期后缀)。""" + a_base = os.path.splitext(os.path.basename(a_path))[0] + date = _DATE_RE.search(a_base) + date_sfx = ('_' + date.group(1)) if date else '' + ma, mb = _VER_RE.match(a_base), None + if b_path: + mb = _VER_RE.match(os.path.splitext(os.path.basename(b_path))[0]) + if ma and mb: + return f'{ma.group("proj")}_FULL_A-V{ma.group("ver")}_B-V{mb.group("ver")}{date_sfx}.bin' + return a_base + '_FULL' + '.bin' + + +class PackOutcome: + def __init__(self): + self.paths = {} # update/b/full/md5 -> 文件路径 + self.summary = [] # 展示用文本行 + self.checks = [] # FULL 逐项校验 (名称, ok, note) + + @property + def ok(self): + return all(ok for _n, ok, _t in self.checks) if self.checks else True + + +def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None, + make_full=True, progress=print): + """一站式打包: 自动识别/校验 → A包/B包/FULL → 写盘 → 回读复核 → MD5 清单。 + + a_path 可为 None (仅 B 底包 → 只出 *_B.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 + b_base = os.path.splitext(os.path.basename(b_path))[0] if b_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) + b_out = os.path.join(out_dir, b_base + '_B.bin') + with open(b_out, 'wb') as f: + f.write(bp.data) + out.paths['b'] = b_out + verify_b_blob(open(b_out, 'rb').read(), profile) + area = profile.slot_b.record_area_len(profile.page_size) + rec_disp = (bp.record_bytes + b'\xFF' * (area - len(bp.record_bytes))) \ + .hex(' ').upper() + say(f'B 包: 满槽 {bp.pages} 页({len(bp.data)}B) 记录 {rec_disp} ' + f'(CRC 0x{bp.crc:04X}) — 回读复核通过') + say(f' {b_out}') + 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)) + n_ok = len(out.checks) + say(f'FULL: {len(image)}B — {n_ok} 项字节校验全部通过') + for n, ok, note in out.checks: + say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else '')) + say(f' {full_path}') + + # MD5 清单 + md5_path = full_path + '.md5.txt' + products = [(k, p) for k, p in out.paths.items()] + with open(md5_path, 'w', encoding='utf-8', newline='\n') as f: + f.write(f'# 布局摘要 — gd32e230_firmware_merge_tool v{TOOL_VERSION}\n') + f.write('# ' + profile.describe().replace('\n', '\n# ') + '\n') + f.write(f'# 生成: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n') + f.write('# A 包: %s -> %d 页, trailer=0x%08X (CRC 0x%04X)\n' + % (os.path.basename(a_path), ap.pages, ap.trailer_value, ap.crc)) + if bp: + f.write('# B 包: %s -> 满槽 %d 页, 记录 %s (CRC 0x%04X)\n' + % (os.path.basename(b_path), bp.pages, + bp.record_bytes.hex(' ').upper(), bp.crc)) + f.write('#\n') + for _k, p in products: + 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 diff --git a/gd32e230_firmware_tool.py b/gd32e230_firmware_tool.py index 17cdcfd..75d4573 100644 --- a/gd32e230_firmware_tool.py +++ b/gd32e230_firmware_tool.py @@ -1,32 +1,110 @@ #!/usr/bin/env python3 """ -GD32E230 固件工具:整页补齐 + 合成烧录镜像,二合一。 +GD32E230 固件打包工具 v2 — 布局 profile 化双固件打包 (A/B 槽 + 记录/CRC) +================================================================ -模式(互斥): - -m / --merge 合成完整 Flash 镜像 (bootloader + APP + FLAG) - → 用于 OpenOCD / J-Link 整片烧录,不补齐 - -f / --fill 仅补齐 APP 到整页边界 - → 用于 Bootloader Ymodem 升级 +v2 模式(默认): 输入编译好的 APP bin, 自动识别 A/B 槽, 产出 + *_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {页数16|CRC16}) + *_B.bin B 槽底包 (固定满槽 + 槽末 8B 记录 {magic|页数|CRC|2B FF}) + *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志) + *.md5.txt 产物 MD5 清单 + 布局摘要 (随 FULL 生成) +打包规则与 BL (dev_fact v5.1) 逐字一致, 写盘后回读复核。计算与校验在 +fw_pack.py 公共库 (FlashTool GUI 共用)。 -Flash 布局: - 0x08000000 ┌──────────────┐ - │ Bootloader │ 8KB - APP_ADDR ├──────────────┤ - │ Application │ 最大 54KB (F8) / 22KB (F6) / 6KB (F4) - │ ... │ - CAL_ADDR │ 校准数据 │ 1KB (预留) - FLAG_ADDR │ 0xEEEE 标志 │ 1KB - FLASH_END └──────────────┘ +legacy 模式(过渡期兼容, -f/-m 二选一时启用): 旧单槽方案 + -f/--fill 纯 0xFF 补页, 无 trailer (旧 BL 的 Ymodem 升级包) + -m/--merge BL + APP + 0xEEEE 标志 拼整片镜像 (不补齐) + +v2 Flash 布局(默认 profile ceramixnano_mb_v51, GD32E230F8/64K): + 0x08000000 Bootloader 8K 页 0~7 + 0x08002000 FW_A 运行槽 36K 页 8~43 OTA 唯一接收区, 槽尾锚点@0x0800AFFC + 0x0800B000 FW_B 保底槽 19K 页 44~62 固定满槽, B 记录@0x0800FBF8 + 0x0800FC00 标志页 1K 页 63 0xEEEE@0x0800FFFC +其他工程换 profile 即可(保底槽 8K/28K/无保底槽等), 见 --profile。 """ import argparse +import os import struct 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.0.0' # ═══════════════════════════════════════════════════════════ -# 常量 +# 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) + except fw_pack.FwPackError as exc: + print(f'\n错误:{exc}', file=sys.stderr) + return 1 + return 0 + + +# ═══════════════════════════════════════════════════════════ +# legacy 模式: 旧单槽方案 (纯 0xFF 补页 / 拼接, 无 trailer) # ═══════════════════════════════════════════════════════════ FLASH_BASE = 0x08000000 @@ -44,33 +122,34 @@ CHIP_CONFIG = { } DEFAULT_CHIP = "F8" -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_BOOTLOADER = os.path.join(SCRIPT_DIR, "gd32e230f8_bootloader_hulk.bin") -# ═══════════════════════════════════════════════════════════ -# 地址计算 -# ═══════════════════════════════════════════════════════════ - -def calc_layout(flash_size): - app_addr = FLASH_BASE + BOOTLOADER_SIZE # 0x08002000 - cal_addr = FLASH_BASE + flash_size - CAL_PAGE_SIZE - FLAG_PAGE_SIZE - flag_addr = FLASH_BASE + flash_size - 4 # 最后一页末尾 - 4B - app_max = cal_addr - app_addr +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 + app_max = cal_addr - app_addr + cal_reserved = True + else: + # 自定义落位(如 FW_B 槽 0x0800B000):APP 可用到标志页前,校准页让渡给 APP + 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 { "flash_size": flash_size, "flash_end": FLASH_BASE + flash_size, "app_addr": app_addr, "app_max": app_max, "cal_addr": cal_addr, + "cal_reserved": cal_reserved, + "flag_page": flag_page, "flag_addr": flag_addr, } -# ═══════════════════════════════════════════════════════════ -# 补齐 -# ═══════════════════════════════════════════════════════════ - def pad_to_page(data, page_size=PAGE_SIZE, fill_byte=FILL_BYTE): remainder = len(data) % page_size if remainder == 0: @@ -79,42 +158,39 @@ def pad_to_page(data, page_size=PAGE_SIZE, fill_byte=FILL_BYTE): return data + bytes([fill_byte]) * pad_len, pad_len -# ═══════════════════════════════════════════════════════════ -# 合成模式 -# ═══════════════════════════════════════════════════════════ - def merge(bootloader_path, app_path, output_path, layout): - # 读取 with open(bootloader_path, "rb") as f: bl = f.read() with open(app_path, "rb") as f: app = f.read() - # 校验 app_offset = layout["app_addr"] - FLASH_BASE if len(bl) > app_offset: print(f"错误:Bootloader ({len(bl)}B) 超过 APP 偏移 ({app_offset}B)", file=sys.stderr) return False 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( f"错误:APP ({len(app)}B / {len(app)/1024:.1f}KB) 超过最大容量 " - f"({layout['app_max']}B / {layout['app_max']/1024:.0f}KB)\n" - f" 芯片 {layout['flash_size']//1024}KB: " - f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) " - f"= {layout['app_max']//1024}KB", + f"({layout['app_max']}B / {layout['app_max']//1024:.0f}KB)\n" + f" 芯片 {layout['flash_size']//1024}KB: {limit_desc}", file=sys.stderr, ) return False - # 构建镜像 image = bytearray(b'\xFF' * layout["flash_size"]) image[0:len(bl)] = bl image[app_offset:app_offset + len(app)] = app flag_offset = layout["flag_addr"] - FLASH_BASE struct.pack_into("6}B @ 0x{FLASH_BASE: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" 校准预留 : 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" 总大小 : {layout['flash_size']:>6}B ({layout['flash_size']//1024}KB)") return True -# ═══════════════════════════════════════════════════════════ -# 补齐模式 -# ═══════════════════════════════════════════════════════════ - def fill_only(input_path, output_path, layout): with open(input_path, "rb") as f: data = f.read() padded, pad_len = pad_to_page(data) - # 校验补齐后不超过 APP 最大容量 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( f"错误:补齐后 ({len(padded)}B / {len(padded)/1024:.1f}KB) 超过 APP 最大容量 " - f"({layout['app_max']}B / {layout['app_max']/1024:.0f}KB)\n" - f" 芯片 {layout['flash_size']//1024}KB: " - f"{layout['flash_size']//1024}KB - 8KB(BL) - 1KB(校准) - 1KB(Flag) " - f"= {layout['app_max']//1024}KB", + f"({layout['app_max']}B / {layout['app_max']//1024:.0f}KB)\n" + f" 芯片 {layout['flash_size']//1024}KB: {limit_desc}", file=sys.stderr, ) sys.exit(1) @@ -166,86 +243,126 @@ def fill_only(input_path, output_path, layout): 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(): parser = argparse.ArgumentParser( - description="GD32E230 固件工具 — 整页补齐 & 合成烧录镜像", + prog='gd32e230_firmware_tool', + description=f"GD32E230 固件打包工具 v{TOOL_VERSION} — 双固件 profile 打包" + "(默认)/ 旧单槽补齐·拼接(-f/-m)", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - python %(prog)s -m app.bin # F8 合成镜像 (默认) - python %(prog)s -m app.bin -s F6 # F6 合成镜像 - python %(prog)s -f app.bin # F8 补齐升级包 (默认) - python %(prog)s -f app.bin -s F4 # F4 补齐升级包 - python %(prog)s -m app.bin -b my_bl.bin -o out.bin # 自定义 bootloader/输出 + epilog=f""" +v2 双固件模式(默认, 规格: UPGRADE_PLAN_v2.md): + python %(prog)s app_a.bin # 识别 A 包 → *_UPDATE.bin (+FULL) + python %(prog)s app_a.bin app_b.bin -b bl.bin # 全产物: UPDATE/B/FULL/md5 + python %(prog)s --a a.bin --b b.bin # 显式指定槽位(与向量表冲突报错) + python %(prog)s a.bin --profile f8_single_slot # 换布局(无保底槽) + python %(prog)s a.bin --profile my_layout.json # 自定义 profile JSON + python %(prog)s b.bin # 仅 B 底包 → *_B.bin + +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( - "app", help="Application 的 .bin 文件路径" - ) - - # 模式 (互斥) - mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument( - "-m", "--merge", - action="store_true", - help="合成完整 Flash 镜像 (bootloader + APP + FLAG),用于整片烧录", - ) - mode.add_argument( - "-f", "--fill", - action="store_true", - help="仅补齐 APP 到整页边界,用于 Bootloader Ymodem 升级", - ) - - # 选项 - parser.add_argument( - "-s", "--flash-size", - default=DEFAULT_CHIP, - choices=list(CHIP_CONFIG.keys()), - help=f"芯片型号: F4(16K) F6(32K) F8(64K) (默认: {DEFAULT_CHIP})", - ) - parser.add_argument( - "-b", "--bootloader", - default=DEFAULT_BOOTLOADER, - help=f"Bootloader bin 路径 (默认: {DEFAULT_BOOTLOADER})", - ) - parser.add_argument( - "-o", "--output", - default=None, - help="输出文件路径 (默认: APP名_BL.bin 或 APP名_UPDATE.bin)", - ) + "bins", nargs="*", + help="v2: 输入 bin(A/B 包自动识别, 可多个);legacy: APP 路径") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("-m", "--merge", action="store_true", + help="legacy: 合成完整 Flash 镜像 (bootloader + APP + FLAG)") + mode.add_argument("-f", "--fill", action="store_true", + help="legacy: 仅补齐 APP 到整页边界 (纯 0xFF, 无 trailer)") + parser.add_argument("--legacy", action="store_true", + help="显式声明 legacy 模式(需配合 -f/-m)") + parser.add_argument("--profile", default="ceramixnano_mb_v51", + help="v2 布局 profile: 内置名(%s)或 JSON 文件路径" + % "/".join(fw_pack.BUILTIN_PROFILES)) + parser.add_argument("--a", metavar="BIN", default=None, + help="v2: 显式指定 A 包(运行槽固件)") + parser.add_argument("--b", metavar="BIN", default=None, + help="v2: 显式指定 B 包(出厂保底固件)") + parser.add_argument("-b", "--bootloader", default=None, + help="BL bin (v2: 参与 FULL 拼接; legacy -m: 默认内置 BL)") + parser.add_argument("-o", "--output", default=None, + help="输出路径: v2=输出目录(自动命名); legacy=输出文件") + parser.add_argument("--no-full", action="store_true", + help="v2: 只出 UPDATE/B 包, 不拼 FULL") + parser.add_argument("-s", "--flash-size", default=DEFAULT_CHIP, + choices=list(CHIP_CONFIG.keys()), + help="legacy 芯片型号: F4(16K) F6(32K) F8(64K)") + parser.add_argument("-a", "--app-addr", default=None, + help="legacy: APP 落位地址, 如 0x0800B000 (默认 0x08002000)") args = parser.parse_args() + print(f"gd32e230_firmware_tool v{TOOL_VERSION} (fw_pack {fw_pack.TOOL_VERSION})") - chip = CHIP_CONFIG[args.flash_size] - layout = calc_layout(chip["size"]) + legacy = args.fill or args.merge + if args.legacy and not legacy: + parser.error("--legacy 需配合 -f/--fill 或 -m/--merge 使用") + if legacy and (args.profile != "ceramixnano_mb_v51" or args.a or args.b + or args.no_full or args.app_addr): + parser.error("--profile/--a/--b/--no-full/-a 是 v2/legacy 专属参数, " + "不能与 -f/-m 同用") + if legacy and len(args.bins) != 1: + parser.error("legacy 模式需要一个 APP 位置参数, 例: python %(prog)s -f app.bin" + % {'prog': parser.prog}) - print(f"芯片: {chip['label']}") - - # 输出文件名 - 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) + rc = run_legacy(args) if legacy else run_v2(args) + sys.exit(rc) if __name__ == "__main__": diff --git a/test_fw_pack.py b/test_fw_pack.py new file mode 100644 index 0000000..840ae66 --- /dev/null +++ b/test_fw_pack.py @@ -0,0 +1,648 @@ +#!/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. 真机固件回归(CeramixNano 1.0.17/1.0.18/V1.0.12, 字节级复现 + 2026-10-22 验收包, 文件缺失时自动跳过); + 8. 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_V5.bin') +U18_REF = os.path.join(CAP, 'test', 'CeramixNano-MB_V1.0.18_APP_2026-10-21_UPDATE_V5.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(' 槽-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. 真机回归: CeramixNano v5.1 (字节级复现验收包) +# ═══════════════════════════════════════════════════════════ + +@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_v51', A17, b_path=B12, + bl_path=bl_path, out_dir=td, progress=quiet()) + upd = open(out.paths['update'], 'rb').read() + bpk = open(out.paths['b'], 'rb').read() + full = open(out.paths['full'], 'rb').read() + self.assertEqual(upd, open(U17_REF, 'rb').read()) + self.assertEqual(bpk[-8:], bytes.fromhex('de c0 13 00 6f 18 ff ff')) + self.assertEqual(full, self.ref_full) + self.assertTrue(out.ok) + # FULL 自动命名与验收包一致 + self.assertEqual(os.path.basename(out.paths['full']), + 'CeramixNano-MB_FULL_A-V1.0.17_B-V1.0.12_2026-10-21.bin') + # MD5 清单包含全部产物与正确摘要 + mani = open(out.paths['md5'], encoding='utf-8').read() + for key in ('update', 'b', 'full'): + blob = open(out.paths[key], 'rb').read() + self.assertIn(hashlib.md5(blob).hexdigest(), mani) + self.assertIn('ceramixnano_mb_v51', mani) + self.assertIn('0xD30F0014', 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') + open(self.a, 'wb').write(synth_bin(A_BASE, 19580)) + open(self.b, 'wb').write(synth_bin(B_BASE, SLOT_B_SIZE)) + + 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_b_B.bin', 'app_a_FULL.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) + self.assertIn('回读复核通过', out) + + def test_v2_b_only_no_full(self): + """仅 B 输入 → 只出 *_B.bin, 不拼 FULL (无 A 运行槽的整片镜像不可启动)""" + rc, out, err = run_cli(self.b, '-o', self.dir) + self.assertEqual(rc, 0, err or out) + self.assertTrue(os.path.isfile(os.path.join(self.dir, 'app_b_B.bin'))) + self.assertFalse([f for f in os.listdir(self.dir) if 'FULL' 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()) + self.assertIn('0xD30F0014', out) + self.assertIn('DE C0 13 00 6F 18 FF FF', out) + + 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) diff --git a/verify_v101_packages.py b/verify_v101_packages.py new file mode 100644 index 0000000..4e58be6 --- /dev/null +++ b/verify_v101_packages.py @@ -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(' 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)