#!/usr/bin/env python3 """ GD32E230 固件打包工具 v2 — 布局 profile 化双固件打包 (A/B 槽 + 记录/CRC) ================================================================ v2 模式(默认): 输入编译好的 APP bin, 自动识别 A/B 槽, 产出 *_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {页数16|CRC16}) *_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志) *_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电跳 B) *.md5.txt 产物 MD5 清单 + 布局摘要 (随主镜像生成) 打包规则与 BL (dev_fact v5.1) 逐字一致, 写盘后回读复核。计算与校验在 fw_pack.py 公共库 (FlashTool GUI 共用)。 legacy 模式(过渡期兼容, -f/-m 二选一时启用): 旧单槽方案 -f/--fill 纯 0xFF 补页, 无 trailer (旧 BL 的 Ymodem 升级包) -m/--merge BL + APP + 0xEEEE 标志 拼整片镜像 (不补齐) v2 Flash 布局(默认 profile CeramixNano_MB_Dual_FW, GD32E230F8/64K): 0x08000000 Bootloader 8K 页 0~7 0x08002000 FW_A 运行槽 36K 页 8~43 OTA 唯一接收区, 槽尾锚点@0x0800AFFC 0x0800B000 FW_B 保底槽 19K 页 44~62 固定满槽, B 记录@0x0800FBF8 0x0800FC00 标志页 1K 页 63 0xEEEE@0x0800FFFC 其他工程换 profile 即可(保底槽 8K/28K/无保底槽等), 见 --profile。 """ import argparse import os import struct import sys 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 BOOTLOADER_SIZE = 8 * 1024 # 8KB CAL_PAGE_SIZE = 1 * 1024 # 1KB 校准数据预留 FLAG_PAGE_SIZE = 1 * 1024 # 1KB Flag 页 PAGE_SIZE = 1024 # Flash 页大小 (1KB) FILL_BYTE = 0xFF # 补齐字节 FLAG_VALUE = 0xEEEE # 与 bootloader 统一 CHIP_CONFIG = { "F4": {"size": 16 * 1024, "label": "GD32E230F4 (16KB)"}, "F6": {"size": 32 * 1024, "label": "GD32E230F6 (32KB)"}, "F8": {"size": 64 * 1024, "label": "GD32E230F8 (64KB)"}, } DEFAULT_CHIP = "F8" 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 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: return data, 0 pad_len = page_size - remainder 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: {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}") 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) 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: {limit_desc}", file=sys.stderr, ) sys.exit(1) with open(output_path, "wb") as f: f.write(padded) print(f"补齐完成: {output_path}") print(f" 原始大小: {len(data)}B") print(f" 页大小 : {PAGE_SIZE}B (1KB)") print(f" 补齐字节: {pad_len}B") print(f" 输出大小: {len(padded)}B") print(f" APP 上限: {layout['app_max']}B ({layout['app_max']//1024}KB) → " f"剩余 {layout['app_max'] - len(padded)}B") if pad_len == 0: 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( prog='gd32e230_firmware_tool', description=f"GD32E230 固件打包工具 v{TOOL_VERSION} — 双固件 profile 打包" "(默认)/ 旧单槽补齐·拼接(-f/-m)", formatter_class=argparse.RawDescriptionHelpFormatter, 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/FULL/FACTORY/md5 python %(prog)s app_a.bin --no-full # 仅升级包 python %(prog)s app_b.bin -b bl.bin # 仅工厂镜像 → *_FACTORY.bin python %(prog)s --a a.bin --b b.bin # 显式指定槽位(与向量表冲突报错) python %(prog)s a.bin --profile 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( "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=DEFAULT_PROFILE, 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: 不拼 FULL 整片镜像") parser.add_argument("--no-factory", action="store_true", help="v2: 不出 FACTORY 工厂镜像") parser.add_argument("-s", "--flash-size", default=DEFAULT_CHIP, choices=list(CHIP_CONFIG.keys()), help="legacy 芯片型号: F4(16K) F6(32K) F8(64K)") 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})") legacy = args.fill or args.merge 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}) rc = run_legacy(args) if legacy else run_v2(args) sys.exit(rc) if __name__ == "__main__": main()