Files
gd32e230f8_firmware_merge_tool/fw_pack.py
T

978 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fw_pack — GD32E230 双固件打包公共库 (CLI 与 FlashTool GUI 共用)
================================================================
输入编译好的 APP bin, 按 layout profile 补页 / 算 CRC / 写记录, 产出:
*_UPDATE.bin A 槽 OTA/Ymodem 包 (整 N 页 + 末 4B trailer {pages16|crc16})
*_FULL.bin 整片镜像 (BL + A 包 + 槽尾锚点 + B 满槽 + 0xEEEE 标志)
*_FACTORY.bin 工厂烧录镜像 (BL + B 满槽 + flag, 无 A 内容; 上电判定链
锚点擦除态 → A 向量非法 → 落 B 记录校验 → 跳出厂固件)
*.md5.txt 产物 MD5 清单 + 布局摘要
规则与双固件 bootloader 侧逐字一致:
- CRC-16/XMODEM (poly 0x1021, init 0x0000, 不反转, 无 xorout),
自检向量 b"123456789" -> 0x31C3;
- A 包: 补 0xFF 至整 N 页; body 恰整页或 余量 < 记录长 R -> 向后扩 1 页;
CRC 范围 = N 页内容去掉末 R 字节;
- B 包 (fixed_slot): 恰好填满槽, 记录固定在槽末 record 区, CRC 范围 =
槽内容去掉记录区; 固件 > 槽-R 直接报错 (不可扩展);
- 槽尾锚点 = A 槽末 4B, 与 trailer 同值 (BL 收包后独立复算交叉验证)。
纯标准库, Windows 兼容。
"""
import datetime
import hashlib
import json
import os
import re
import struct
__all__ = [
'FwPackError', 'crc16_xmodem', 'self_test', 'parse_num',
'BUILTIN_PROFILES', 'load_profile', 'Profile',
'detect_slot', 'read_vector', 'validate_app_bin',
'build_a_pack', 'build_b_pack', 'build_full', 'build_factory',
'verify_a_blob', 'verify_b_blob', 'verify_full_blob', 'verify_factory_blob',
'pack_all', 'suggest_full_name', 'suggest_factory_name',
]
TOOL_VERSION = '2.2' # 2.2: 新增内置 profile CeramixNano_EDDY_Dual_FW
# GD32E230 家族 SRAM: F8 最大 8K, SP 必须落在其中 (F4/F6 的 4K 也在范围内)
SRAM_LO, SRAM_HI = 0x20000000, 0x20002000
class FwPackError(Exception):
"""打包过程中的所有可预期错误(参数/校验/尺寸), 消息可直接展示给用户"""
# ═══════════════════════════════════════════════════════════
# CRC-16/XMODEM
# ═══════════════════════════════════════════════════════════
def crc16_xmodem(data: bytes) -> int:
"""CRC-16/XMODEM: poly 0x1021, init 0x0000, 不反转, 无 xorout"""
crc = 0
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
def self_test():
"""启动自检: CRC 实现必须命中已知测试向量, 否则拒绝工作"""
if crc16_xmodem(b'123456789') != 0x31C3:
raise FwPackError('CRC-16/XMODEM 自检失败 (期望 b"123456789" -> 0x31C3)')
# ═══════════════════════════════════════════════════════════
# 数值 / 表达式解析
# ═══════════════════════════════════════════════════════════
def parse_num(text, what='数值'):
"""解析 '0x08002000' / '64K' / '19k' / '1M' / '1024'; 非法抛 FwPackError"""
if isinstance(text, bool):
raise FwPackError(f'{what}: {text!r} 不是数值')
if isinstance(text, int):
return text
s = str(text).strip().lower()
if not s:
raise FwPackError(f'{what}: 空值')
mult = 1
if s.endswith('k'):
mult, s = 1024, s[:-1]
elif s.endswith('m'):
mult, s = 1024 * 1024, s[:-1]
try:
if s.startswith('0x') or s.startswith('-0x'):
return int(s, 16) * mult
return int(s, 10) * mult
except ValueError:
raise FwPackError(f'{what}: 无法解析 {text!r}') from None
def _parse_addr_expr(text, slot_base, slot_end, what):
"""地址表达式: 绝对地址, 或 'slot_end-4' / 'slot_start+8'"""
s = str(text).strip().lower().replace(' ', '')
m = re.fullmatch(r'(slot_end|slot_start)([+-]\d+)?', s)
if m:
base = slot_end if m.group(1) == 'slot_end' else slot_base
return base + int(m.group(2) or 0)
return parse_num(s, what)
# ═══════════════════════════════════════════════════════════
# 记录(record)规格: "{pages16@+0, crc16@+2}"
# ═══════════════════════════════════════════════════════════
_RECORD_FIELD_RE = re.compile(
r'^(?P<name>magic|pages|crc)(?P<bits>16|32)?@(?P<off>\+\d+)'
r'(?:=(?P<val>0x[0-9a-fA-F]+|\d+))?$')
_KNOWN_FIELDS = ('magic', 'pages', 'crc')
class RecordField:
def __init__(self, name, bits, offset, fixed):
self.name, self.bits, self.offset, self.fixed = name, bits, offset, fixed
class RecordSpec:
"""从 "{magic16@+0=0xC0DE, pages16@+2, crc16@+4}" 解析出的记录布局"""
def __init__(self, fields):
self.fields = fields
@classmethod
def parse(cls, text, what='record'):
s = str(text).strip()
if not (s.startswith('{') and s.endswith('}')):
raise FwPackError(f'{what}: 必须形如 "{{pages16@+0, crc16@+2}}", 得到 {text!r}')
fields = []
for part in s[1:-1].split(','):
part = part.strip()
if not part:
continue
m = _RECORD_FIELD_RE.match(part)
if not m:
raise FwPackError(f'{what}: 无法解析字段 {part!r} '
f'(支持 magic16/pages16/crc16@+偏移[=固定值])')
fields.append(RecordField(m.group('name'), int(m.group('bits') or 16),
int(m.group('off')[1:]),
int(m.group('val'), 0) if m.group('val') else None))
if not fields:
raise FwPackError(f'{what}: 至少需要一个字段')
names = [f.name for f in fields]
for need in ('pages', 'crc'):
if need not in names:
raise FwPackError(f'{what}: 缺少 {need}16 字段')
return cls(fields)
@property
def length(self):
"""记录本体字节数(不含区内尾部保留字节)"""
return max(f.offset + f.bits // 8 for f in self.fields)
def pack(self, pages, crc, what='record'):
"""按布局写出记录字节; pages/crc 字段自动填入, 固定值字段校验存在"""
buf = bytearray(b'\xFF' * self.length)
for f in self.fields:
val = f.fixed if f.name == 'magic' else (
pages if f.name == 'pages' else crc if f.name == 'crc' else None)
if val is None:
raise FwPackError(f'{what}: 未知字段 {f.name}')
if f.name == 'crc':
val &= (1 << f.bits) - 1
if f.bits == 16:
struct.pack_into('<H', buf, f.offset, val & 0xFFFF)
else:
struct.pack_into('<I', buf, f.offset, val & 0xFFFFFFFF)
return bytes(buf)
class SlotSpec:
def __init__(self, name, addr, size, fixed_slot, record, record_addr, anchor_addr):
self.name = name
self.addr, self.size = addr, size
self.fixed_slot = fixed_slot # True: 固定满槽(B), 不可扩展
self.record = record # RecordSpec | None
self.record_addr = record_addr # 固定记录区地址(fixed_slot), None=记录随包尾
self.anchor_addr = anchor_addr # FULL 镜像槽尾锚点地址, None=无
@property
def end(self):
return self.addr + self.size
def record_area_len(self, page_size):
"""记录区长度: 固定槽取 [record_addr, slot_end); 动态槽取记录本体长"""
if self.fixed_slot and self.record_addr is not None:
return self.end - self.record_addr
return self.record.length
# ═══════════════════════════════════════════════════════════
# 内置 profile
# ═══════════════════════════════════════════════════════════
BUILTIN_PROFILES = {
# CeramixNano-MB 双固件 bootloader 的布局
'CeramixNano_MB_Dual_FW': {
'profile': 'CeramixNano_MB_Dual_FW',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '36K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
'record_addr': 'slot_end-8'},
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
# CeramixNano EDDY (LDC1612) 双固件布局——Flash 分配与 MB 完全相同;
# BL 用专属分支 bootloader_CeramixNano_EDDY 的 bin(LED=PB1,与 MB 分支并列、独立编号)
'CeramixNano_EDDY_Dual_FW': {
'profile': 'CeramixNano_EDDY_Dual_FW',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '36K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': {'addr': '0x0800B000', 'size': '19K', 'fixed_slot': True,
'record': '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}',
'record_addr': 'slot_end-8'},
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
# 无保底槽的单槽布局 (旧 BL 升级过渡 / 无 B 槽工程)
'f8_single_slot': {
'profile': 'f8_single_slot',
'chip': 'GD32E230F8',
'flash_base': '0x08000000',
'flash_size': '64K',
'page_size': 1024,
'bl': {'addr': '0x08000000', 'size': '8K'},
'slot_a': {'addr': '0x08002000', 'size': '55K',
'anchor': 'slot_end-4',
'record': '{pages16@+0, crc16@+2}'},
'slot_b': None,
'flag': {'page': 'last', 'addr': '0x0800FFFC', 'value': '0xEEEE'},
'crc': 'xmodem',
},
}
class Profile:
"""解析并校验后的 Flash 布局"""
def __init__(self, raw):
if not isinstance(raw, dict):
raise FwPackError('profile 必须是 JSON 对象')
self.name = str(raw.get('profile') or '(未命名)')
self.chip = str(raw.get('chip') or 'GD32E230')
self.flash_base = parse_num(raw.get('flash_base', '0x08000000'), 'flash_base')
self.flash_size = parse_num(raw.get('flash_size'), 'flash_size')
self.page_size = parse_num(raw.get('page_size', 1024), 'page_size')
crc_algo = str(raw.get('crc', 'xmodem')).lower()
if crc_algo != 'xmodem':
raise FwPackError(f'crc 算法仅支持 xmodem, profile 声明 {crc_algo!r}')
bl = raw.get('bl') or {}
self.bl_addr = parse_num(bl.get('addr', self.flash_base), 'bl.addr')
self.bl_size = parse_num(bl.get('size', 0), 'bl.size')
self.bl_end = self.bl_addr + self.bl_size
sa = raw.get('slot_a') or {}
self.slot_a = SlotSpec(
'slot_a', parse_num(sa.get('addr'), 'slot_a.addr'),
parse_num(sa.get('size'), 'slot_a.size'),
bool(sa.get('fixed_slot', False)),
RecordSpec.parse(sa.get('record', '{pages16@+0, crc16@+2}'), 'slot_a.record'),
None, None)
if sa.get('record_addr'):
self.slot_a.record_addr = _parse_addr_expr(
sa['record_addr'], self.slot_a.addr, self.slot_a.end, 'slot_a.record_addr')
if sa.get('anchor'):
self.slot_a.anchor_addr = _parse_addr_expr(
sa['anchor'], self.slot_a.addr, self.slot_a.end, 'slot_a.anchor')
sb = raw.get('slot_b')
self.slot_b = None
if sb:
self.slot_b = SlotSpec(
'slot_b', parse_num(sb.get('addr'), 'slot_b.addr'),
parse_num(sb.get('size'), 'slot_b.size'),
bool(sb.get('fixed_slot', False)),
RecordSpec.parse(sb.get('record', '{magic16@+0=0xC0DE, pages16@+2, crc16@+4}'),
'slot_b.record'),
None, None)
if sb.get('record_addr'):
self.slot_b.record_addr = _parse_addr_expr(
sb['record_addr'], self.slot_b.addr, self.slot_b.end, 'slot_b.record_addr')
elif self.slot_b.fixed_slot:
raise FwPackError('slot_b.fixed_slot 需要 record_addr (如 "slot_end-8")')
fl = raw.get('flag') or {}
self.flag_value = parse_num(fl.get('value', '0xEEEE'), 'flag.value') & 0xFFFFFFFF
self.flag_page = self.flash_base + self.flash_size - self.page_size # page:last
self.flag_addr = parse_num(fl.get('addr', self.flash_base + self.flash_size - 4),
'flag.addr')
self.validate()
# -- 校验: 页对齐 / 不重叠 / 恰好铺满 / 记录区合法 --
def validate(self):
pg, end = self.page_size, self.flash_base + self.flash_size
if self.page_size <= 0 or self.page_size & (self.page_size - 1):
raise FwPackError(f'page_size 必须是 2 的幂, 得到 {self.page_size}')
if self.flash_size <= 0 or self.flash_size % pg:
raise FwPackError(f'flash_size {self.flash_size} 不是页({pg})整数倍')
parts = [('BL', self.bl_addr, self.bl_end), ('slot_a', self.slot_a.addr, self.slot_a.end)]
if self.bl_addr != self.flash_base:
raise FwPackError(f'bl.addr 0x{self.bl_addr:08X} 必须等于 flash_base '
f'0x{self.flash_base:08X}')
for name, lo, hi in parts:
if lo % pg or hi % pg:
raise FwPackError(f'{name} 区间 [0x{lo:08X},0x{hi:08X}) 未页对齐({pg})')
if hi <= lo:
raise FwPackError(f'{name} 区间为空')
if self.slot_b:
parts.append(('slot_b', self.slot_b.addr, self.slot_b.end))
parts.append(('flag页', self.flag_page, end))
parts.sort(key=lambda p: p[1])
for (n1, l1, h1), (n2, l2, _h2) in zip(parts, parts[1:]):
if l2 < h1:
raise FwPackError(f'{n1} [0x{l1:08X},0x{h1:08X}) 与 {n2} '
f'[0x{l2:08X},...) 重叠')
if parts[0][1] != self.flash_base:
raise FwPackError(f'flash 起始 0x{self.flash_base:08X} 未被 BL 覆盖')
total = sum(hi - lo for _n, lo, hi in parts)
if total != self.flash_size:
missing = self.flash_size - total
raise FwPackError(f'分区总和 {total} != flash_size {self.flash_size} '
f'(缺口 {missing}B, 请调整槽大小补齐; 校准页等保留区'
f'需并入某个槽或单独声明)')
for i in range(len(parts) - 1):
if parts[i][2] != parts[i + 1][1]:
gap = parts[i + 1][1] - parts[i][2]
raise FwPackError(f'{parts[i][0]} 与 {parts[i + 1][0]} 之间有 {gap}B '
f'未分配空隙 (要求分区连续铺满)')
# flag
if not (self.flag_page <= self.flag_addr and self.flag_addr + 4 <= end):
raise FwPackError(f'flag.addr 0x{self.flag_addr:08X} 不在末页 '
f'[0x{self.flag_page:08X},0x{end:08X}) 内')
# 记录区
for slot in (self.slot_a, self.slot_b):
if slot is None or slot.record is None:
continue
area = slot.record_area_len(pg)
if area <= 0 or area > pg:
raise FwPackError(f'{slot.name}.record 记录区 {area}B 非法 (须 1~{pg}B)')
if slot.record.length > area:
raise FwPackError(f'{slot.name}.record 本体 {slot.record.length}B '
f'超过记录区 {area}B')
if slot.record_addr is not None and slot.record_addr + area != slot.end:
raise FwPackError(f'{slot.name}.record_addr+记录区 必须等于槽末'
f' (BL 约定记录位于槽尾)')
if slot.anchor_addr is not None and not (slot.addr <= slot.anchor_addr
and slot.anchor_addr + 4 <= slot.end):
raise FwPackError(f'{slot.name}.anchor 0x{slot.anchor_addr:08X} '
f'不在槽区间内')
def slot(self, which):
if which == 'a':
return self.slot_a
if which != 'b':
raise FwPackError(f'未知槽位 {which!r} (只能是 a/b)')
if self.slot_b is None:
raise FwPackError('当前 profile 未定义 slot_b (无保底槽布局)')
return self.slot_b
def describe(self):
pg = self.page_size
lines = [f'profile: {self.name} ({self.chip}, '
f'{self.flash_size // 1024}K, page {pg}B)']
first_pg = 0
rows = [('BL', self.bl_addr, self.bl_size, ''),
('FW_A', self.slot_a.addr, self.slot_a.size, 'OTA/Ymodem 接收槽')]
if self.slot_b:
rows.append(('FW_B', self.slot_b.addr, self.slot_b.size,
'出厂保底槽' + (' (固定满槽)' if self.slot_b.fixed_slot else '')))
rows.append(('FLAG', self.flag_page, pg,
f'0x{self.flag_value:04X} @ 0x{self.flag_addr:08X}'))
for name, addr, size, note in rows:
pages = f'页 {first_pg}~{first_pg + size // pg - 1}'
lines.append(f' {name:<5} 0x{addr:08X}-0x{addr + size - 1:08X} '
f'{size // 1024:>3}K {pages:<12}{note}')
first_pg += size // pg
return '\n'.join(lines)
def load_profile(spec):
"""内置名(CeramixNano_MB_Dual_FW / f8_single_slot) 或 profile JSON 文件路径"""
if not spec:
spec = 'CeramixNano_MB_Dual_FW'
if spec in BUILTIN_PROFILES:
return Profile(dict(BUILTIN_PROFILES[spec]))
if os.path.isfile(spec):
try:
with open(spec, 'r', encoding='utf-8') as f:
return Profile(json.load(f))
except ValueError as exc:
raise FwPackError(f'profile JSON 解析失败: {exc}') from None
raise FwPackError(f'profile {spec!r} 既不是内置名 ({"/".join(BUILTIN_PROFILES)}) '
f'也不是存在的 JSON 文件')
# ═══════════════════════════════════════════════════════════
# 向量表: 读取 / 校验 / 自动识别 A|B
# ═══════════════════════════════════════════════════════════
def read_vector(data):
if len(data) < 8:
raise FwPackError(f'文件只有 {len(data)}B, 连向量表(SP+复位向量)都放不下')
return struct.unpack_from('<II', data, 0)
def validate_app_bin(data, profile, which):
"""向量表硬门槛: SP 在 SRAM, 复位向量 Thumb 且落在声明槽内"""
slot = profile.slot(which)
sp, rst = read_vector(data)
if not (SRAM_LO <= sp <= SRAM_HI):
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 GD32E230 SRAM '
f'[0x{SRAM_LO:08X},0x{SRAM_HI:08X}] (大概不是 GD32E230 固件)')
if not rst & 1:
raise FwPackError(f'复位向量 0x{rst:08X} 不是 Thumb 地址 (bit0=0)')
if not (slot.addr <= rst & ~1 < slot.end):
raise FwPackError(f'复位向量 0x{rst:08X} 不在 {which.upper()} 槽 '
f'[0x{slot.addr:08X},0x{slot.end:08X}) 内 — '
f'固件链接基址与槽位不符')
return sp, rst
def detect_slot(data, profile):
"""从向量表自动识别 bin 属于 A 包还是 B 包"""
sp, rst = read_vector(data)
if not (SRAM_LO <= sp <= SRAM_HI):
raise FwPackError(f'初始 SP 0x{sp:08X} 不在 SRAM 范围, 无法识别 (不是 GD32E230 应用固件?)')
if not rst & 1:
raise FwPackError(f'复位向量 0x{rst:08X} 非 Thumb 地址, 无法识别')
target = rst & ~1
if profile.slot_a.addr <= target < profile.slot_a.end:
return 'a'
if profile.slot_b and profile.slot_b.addr <= target < profile.slot_b.end:
return 'b'
if profile.bl_addr <= target < profile.bl_end:
raise FwPackError('复位向量落在 BL 区 [0x%08X,0x%08X) — 传入的是 bootloader 或'
' 整片镜像, 请提供编译出的 APP bin' % (profile.bl_addr, profile.bl_end))
raise FwPackError(f'复位向量 0x{target:08X} 不落在任何槽区间, 无法识别 A/B 包')
# ═══════════════════════════════════════════════════════════
# A 包 (动态补页 + 末尾 trailer)
# ═══════════════════════════════════════════════════════════
class APackResult:
def __init__(self, data, body_len, pages, crc, record_bytes):
self.data = data # 整 N 页含 trailer
self.body_len, self.pages, self.crc = body_len, pages, crc
self.record_bytes = record_bytes
@property
def pack_len(self):
return len(self.data)
@property
def trailer_value(self):
"""trailer 按 u32 小端解释 (页数低16 | CRC 高16)"""
return struct.unpack('<I', self.record_bytes[:4])[0] if len(
self.record_bytes) >= 4 else None
def build_a_pack(data, profile, which='a'):
"""动态补页打包: 补 0xFF 至整 N 页, 末 R 字节写 {pages16|crc16}。
整页扩展规则: body 恰为整页倍数, 或 余量 < R → 向后扩一页;
扩展后须仍 ≤ 槽大小。CRC 范围 = N 页内容去掉末 R 字节(含 0xFF 填充)。
"""
slot = profile.slot(which)
if slot.fixed_slot:
raise FwPackError(f'{slot.name} 是固定满槽, 请按 B 包流程处理')
R = slot.record_area_len(profile.page_size)
body = bytes(data)
if not body:
raise FwPackError(f'{slot.name} 输入为空文件')
pages = (len(body) + profile.page_size - 1) // profile.page_size
if len(body) % profile.page_size == 0 or pages * profile.page_size - len(body) < R:
pages += 1 # 恰整页 / 记录放不下 → 扩一页
pack_len = pages * profile.page_size
if pack_len > slot.size:
raise FwPackError(f'{slot.name} 固件 {len(body)}B 需 {pages} 页({pack_len}B), '
f'超过槽容量 {slot.size}B({slot.size // profile.page_size} 页)')
buf = bytearray(body + b'\xFF' * (pack_len - len(body)))
crc = crc16_xmodem(bytes(buf[:pack_len - R]))
record = slot.record.pack(pages, crc, f'{slot.name}.record')
buf[pack_len - len(record):] = record
return APackResult(bytes(buf), len(body), pages, crc, record)
# ═══════════════════════════════════════════════════════════
# B 包 (固定满槽 + 槽末记录)
# ═══════════════════════════════════════════════════════════
class BPackResult:
def __init__(self, data, body_len, pages, crc, record_bytes, record_addr):
self.data = data # 满槽镜像含记录
self.body_len, self.pages, self.crc = body_len, pages, crc
self.record_bytes, self.record_addr = record_bytes, record_addr
def build_b_pack(data, profile):
"""固定满槽打包: 补 0xFF 恰好填满槽, 槽末记录区写 {magic|pages|crc|保留FF}。
输入恰好等于槽大小 → 视为已含旧记录的满槽镜像, 末记录区原样丢弃重写;
固件 > 槽-R → 报错 (固定槽不可向后扩展)。CRC 范围 = 槽内容去掉记录区。
"""
slot = profile.slot_b
if slot is None:
raise FwPackError('当前 profile 无 slot_b')
R = slot.record_area_len(profile.page_size) # 记录区(含尾部保留字节)
body = bytes(data)
if not body:
raise FwPackError('slot_b 输入为空文件')
if len(body) == slot.size:
body = body[:slot.size - R] # 满槽镜像: 丢弃旧记录区
if len(body) > slot.size - R:
raise FwPackError(f'B 固件 {len(body)}B 超过固定槽容量 槽{slot.size}B-{R}B记录 '
f'= {slot.size - R}B (固定槽不可扩展, 请裁剪固件或加大槽)')
image = bytearray(body + b'\xFF' * (slot.size - len(body)))
crc = crc16_xmodem(bytes(image[:slot.size - R]))
pages = slot.size // profile.page_size
record = slot.record.pack(pages, crc, 'slot_b.record')
image[slot.size - R:slot.size - R + len(record)] = record
return BPackResult(bytes(image), len(body), pages, crc, record,
slot.end - R)
# ═══════════════════════════════════════════════════════════
# FULL 整片镜像
# ═══════════════════════════════════════════════════════════
def build_full(profile, bl_data, a_pack, b_pack=None):
"""BL + A 包 + 槽尾锚点 + 空隙 0xFF + B 满槽 + flag → 整片镜像"""
image = bytearray(b'\xFF' * profile.flash_size)
if bl_data:
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
image[profile.bl_addr - profile.flash_base:
profile.bl_addr - profile.flash_base + len(bl_data)] = bl_data
sa = profile.slot_a
off = sa.addr - profile.flash_base
image[off:off + a_pack.pack_len] = a_pack.data
if sa.anchor_addr is not None and len(a_pack.record_bytes) >= 4:
anchor = a_pack.record_bytes[:4] # 锚点与 trailer 同值
image[sa.anchor_addr - profile.flash_base:
sa.anchor_addr - profile.flash_base + 4] = anchor
if b_pack is not None:
sb = profile.slot_b
off = sb.addr - profile.flash_base
image[off:off + sb.size] = b_pack.data
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
profile.flag_value)
return bytes(image)
def build_factory(profile, bl_data, b_pack):
"""工厂烧录镜像: BL + B 满槽(含记录) + flag, **无 A 包、无锚点**。
上电判定链(双固件 BL): A 锚点为擦除态(0xFFFF...) → legacy flag + A 向量校验
(A 槽全 0xFF → 向量非法) → 落 B 记录 {magic|pages|crc} 校验 → 跳出厂固件;
后续 OTA 写入 A 槽后即切运行固件。
"""
if b_pack is None:
raise FwPackError('FACTORY 镜像需要 B 底包')
image = bytearray(b'\xFF' * profile.flash_size)
if bl_data:
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
image[0:len(bl_data)] = bl_data
sb = profile.slot_b
off = sb.addr - profile.flash_base
image[off:off + sb.size] = b_pack.data
struct.pack_into('<I', image, profile.flag_addr - profile.flash_base,
profile.flag_value)
return bytes(image)
# ═══════════════════════════════════════════════════════════
# 回读复核 (从产物字节独立重算, 与构建结果交叉验证)
# ═══════════════════════════════════════════════════════════
def verify_a_blob(blob, profile, which='a'):
"""A 包复核: 页数/CRC/trailer 从文件字节重算; 返回 (pages, crc, trailer)"""
slot = profile.slot(which)
R = slot.record_area_len(profile.page_size)
if len(blob) <= R or len(blob) % profile.page_size:
raise FwPackError(f'A 包长度 {len(blob)}B 非法 (须为 >{R} 的整页倍数)')
pages = len(blob) // profile.page_size
crc = crc16_xmodem(blob[:-R])
expect = slot.record.pack(pages, crc)
if blob[-len(expect):] != expect:
raise FwPackError(f'A 包 trailer 复核失败: 文件 {blob[-len(expect):].hex(" ")} '
f'应为 {expect.hex(" ")} (pages={pages}, CRC=0x{crc:04X})')
return pages, crc, expect
def verify_b_blob(blob, profile):
"""B 包复核: 长度=满槽, 记录区从槽内容重算比对"""
slot = profile.slot_b
R = slot.record_area_len(profile.page_size)
if len(blob) != slot.size:
raise FwPackError(f'B 包长度 {len(blob)}B != 满槽 {slot.size}B')
crc = crc16_xmodem(blob[:slot.size - R])
expect = slot.record.pack(slot.size // profile.page_size, crc)
if blob[slot.size - R:slot.size - R + len(expect)] != expect:
raise FwPackError(f'B 包记录复核失败: 文件 '
f'{blob[slot.size - R:slot.size].hex(" ")} 应为 '
f'{expect.hex(" ")}')
return expect
def verify_full_blob(blob, profile, bl_data, a_body, b_body=None):
"""FULL 镜像逐项字节校验(规格 4.5: 分区内容/空隙/记录/flag, 17+ 项)。
全部从镜像字节独立重算, 与构建路径交叉验证。
返回 [(名称, 通过?, 说明)] 列表, 由调用方汇总展示。"""
base = profile.flash_base
sa = profile.slot_a
pg = profile.page_size
checks = []
def chk(name, ok, note=''):
checks.append((name, bool(ok), note))
# 1. 总长度
chk('总长度=flash_size', len(blob) == profile.flash_size,
f'{len(blob)}/{profile.flash_size}')
# 2-3. BL 区
if bl_data:
chk(f'BL 内容==输入({len(bl_data)}B)',
blob[:len(bl_data)] == bl_data)
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
else:
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
# 4-11. A 槽
a_off = sa.addr - base
R = sa.record_area_len(pg)
pages = max((len(a_body) + pg - 1) // pg, 1)
if len(a_body) % pg == 0 or pages * pg - len(a_body) < R:
pages += 1
pack_len = pages * pg
crc = crc16_xmodem(blob[a_off:a_off + pack_len - R])
trailer = sa.record.pack(pages, crc)
chk(f'A 体==输入固件({len(a_body)}B)', blob[a_off:a_off + len(a_body)] == a_body)
chk(f'A 补页 0xFF({pack_len - R - len(a_body)}B)',
all(b == 0xFF for b in blob[a_off + len(a_body):a_off + pack_len - R]))
_off = {f.name: f.offset for f in sa.record.fields}
rec_off = a_off + pack_len - R
t_pages = struct.unpack_from('<H', blob, rec_off + _off['pages'])[0]
chk(f'A trailer 页数=={pages}', t_pages == pages,
f'实为 {t_pages}')
t_crc = struct.unpack_from('<H', blob, rec_off + _off['crc'])[0]
chk(f'A trailer CRC==镜像重算(0x{crc:04X})', t_crc == crc, f'实为 0x{t_crc:04X}')
if sa.anchor_addr is not None:
chk(f'槽尾锚点==trailer @0x{sa.anchor_addr:08X}',
blob[sa.anchor_addr - base:sa.anchor_addr - base + 4]
== blob[a_off + pack_len - 4:a_off + pack_len])
chk('空隙(A 包尾~锚点)全 0xFF',
all(b == 0xFF for b in blob[a_off + pack_len:sa.anchor_addr - base]))
else:
chk('A 包尾~槽末全 0xFF',
all(b == 0xFF for b in blob[a_off + pack_len:sa.end - base]))
sp, rst = struct.unpack_from('<II', blob, a_off)
chk(f'A 向量表 SP∈SRAM(0x{sp:08X})', SRAM_LO <= sp <= SRAM_HI)
chk(f'A 复位向量∈A 槽(0x{rst:08X})', rst & 1 and sa.addr <= rst & ~1 < sa.end)
# 12-17. B 槽
if profile.slot_b:
sb = profile.slot_b
b_off = sb.addr - base
rb = sb.record_area_len(pg)
if b_body is not None:
body_n = min(len(b_body), sb.size - rb)
chk(f'B 体前缀==输入固件({body_n}B)',
blob[b_off:b_off + body_n] == b_body[:body_n])
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
rec_off = sb.end - rb - base
_bo = {f.name: f.offset for f in sb.record.fields}
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
f'实为 {m_pages}')
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc,
f'记录 0x{m_crc:04X}')
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
blob[rec_off + sb.record.length:rec_off + rb]
== b'\xFF' * (rb - sb.record.length))
else:
chk('B 槽全 0xFF(未提供底包)', all(b == 0xFF for b in blob[b_off:sb.end - base]))
# 18-19. flag
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
fp = profile.flag_page - base
chk('标志页其余字节 0xFF',
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
- profile.flag_addr - 4))
return checks
def verify_factory_blob(blob, profile, bl_data, b_body):
"""FACTORY 工厂镜像逐项字节校验: BL/B 记录/flag 在位, A 槽与锚点保持擦除态。
返回 [(名称, 通过?, 说明)] 列表。"""
base = profile.flash_base
sa, sb = profile.slot_a, profile.slot_b
pg = profile.page_size
checks = []
def chk(name, ok, note=''):
checks.append((name, bool(ok), note))
chk('总长度=flash_size', len(blob) == profile.flash_size,
f'{len(blob)}/{profile.flash_size}')
if bl_data:
chk(f'BL 内容==输入({len(bl_data)}B)', blob[:len(bl_data)] == bl_data)
chk(f'BL 余量 0xFF({profile.bl_size - len(bl_data)}B)',
all(b == 0xFF for b in blob[len(bl_data):profile.bl_size]))
else:
chk('BL 区全 0xFF(未提供 BL)', all(b == 0xFF for b in blob[:profile.bl_size]))
# A 槽必须整体擦除态: 无 A 包、无锚点 (判定链靠"锚点擦除+A 向量非法"落 B)
chk(f'A 槽全 0xFF·锚点擦除态({sa.size}B)',
all(b == 0xFF for b in blob[sa.addr - base:sa.end - base]))
# B 槽
b_off = sb.addr - base
rb = sb.record_area_len(pg)
body_n = min(len(b_body), sb.size - rb)
chk(f'B 体前缀==输入固件({body_n}B)',
blob[b_off:b_off + body_n] == b_body[:body_n])
chk(f'B 补页 0xFF({sb.size - rb - body_n}B)',
all(b == 0xFF for b in blob[b_off + body_n:sb.end - rb - base]))
rec_off = sb.end - rb - base
_bo = {f.name: f.offset for f in sb.record.fields}
m_magic = struct.unpack_from('<H', blob, rec_off + _bo['magic'])[0]
m_pages = struct.unpack_from('<H', blob, rec_off + _bo['pages'])[0]
m_crc = struct.unpack_from('<H', blob, rec_off + _bo['crc'])[0]
chk(f'B 记录 magic==0x{m_magic:04X} 存在', m_magic != 0xFFFF)
chk(f'B 记录 页数=={sb.size // pg}', m_pages == sb.size // pg,
f'实为 {m_pages}')
bcrc = crc16_xmodem(blob[b_off:sb.end - rb - base])
chk(f'B 记录 CRC==镜像重算(0x{bcrc:04X})', m_crc == bcrc, f'记录 0x{m_crc:04X}')
chk(f'B 记录区尾部保留 0xFF({rb - sb.record.length}B)',
blob[rec_off + sb.record.length:rec_off + rb]
== b'\xFF' * (rb - sb.record.length))
# flag
chk(f'flag 0x{profile.flag_value:04X} @0x{profile.flag_addr:08X}',
struct.unpack_from('<I', blob, profile.flag_addr - base)[0] == profile.flag_value)
fp = profile.flag_page - base
chk('标志页其余字节 0xFF',
blob[fp:profile.flag_addr - base] == b'\xFF' * (profile.flag_addr - base - fp)
and blob[profile.flag_addr - base + 4:] == b'\xFF' * (base + profile.flash_size
- profile.flag_addr - 4))
return checks
# ═══════════════════════════════════════════════════════════
# 产物命名 / 一站式打包 (CLI 与 GUI 共用入口)
# ═══════════════════════════════════════════════════════════
_VER_RE = re.compile(r'^(?P<proj>.+?)_?V(?P<ver>\d+\.\d+\.\d+)', re.IGNORECASE)
_DATE_RE = re.compile(r'_(\d{4}-\d{2}-\d{2})$')
def suggest_full_name(a_path, b_path=None):
"""从输入名推导 FULL 镜像名: 项目前缀 + 双版本号 (+共同日期后缀)。"""
a_base = os.path.splitext(os.path.basename(a_path))[0]
date = _DATE_RE.search(a_base)
date_sfx = ('_' + date.group(1)) if date else ''
ma, mb = _VER_RE.match(a_base), None
if b_path:
mb = _VER_RE.match(os.path.splitext(os.path.basename(b_path))[0])
if ma and mb:
return f'{ma.group("proj")}_FULL_A-V{ma.group("ver")}_B-V{mb.group("ver")}{date_sfx}.bin'
return a_base + '_FULL' + '.bin'
def suggest_factory_name(b_path):
"""从 B 包名推导 FACTORY 镜像名: 项目前缀 + B 版本号 (+日期后缀)。"""
b_base = os.path.splitext(os.path.basename(b_path))[0]
date = _DATE_RE.search(b_base)
m = _VER_RE.match(b_base)
if m:
return (f'{m.group("proj")}_FACTORY_B-V{m.group("ver")}'
+ (f'_{date.group(1)}' if date else '') + '.bin')
return b_base + '_FACTORY.bin'
class PackOutcome:
def __init__(self):
self.paths = {} # update/full/factory/md5 -> 文件路径
self.summary = [] # 展示用文本行
self.checks = [] # FULL 逐项校验 (名称, ok, note)
self.factory_checks = [] # FACTORY 逐项校验
@property
def ok(self):
pools = self.checks + self.factory_checks
return all(ok for _n, ok, _t in pools) if pools else True
def pack_all(profile_spec, a_path, b_path=None, bl_path=None, out_dir=None,
make_full=True, make_factory=True, progress=print):
"""一站式打包: 自动识别/校验 → A包/FULL/FACTORY → 写盘 → 回读复核 → MD5 清单。
a_path 可为 None (仅 B 底包 → 只出 *_FACTORY.bin 工厂镜像, 不拼 FULL ——
没有 A 运行槽的整片镜像无法启动, 不允许生成)。progress: 每行输出的回调
(CLI 用 print, GUI 转日志)。返回 PackOutcome; 任何硬错误抛 FwPackError。
"""
self_test()
profile = load_profile(profile_spec)
out = PackOutcome()
log = []
def say(line=''):
log.append(line)
progress(line)
say(profile.describe())
# -- 读取与校验 --
a_body = None
if a_path is not None:
if not os.path.isfile(a_path):
raise FwPackError(f'找不到 A 包输入文件: {a_path!r}')
a_body = open(a_path, 'rb').read()
validate_app_bin(a_body, profile, 'a')
say(f'A 包输入: {os.path.basename(a_path)} ({len(a_body)}B) — '
f'向量表校验通过 (A 槽)')
b_body = None
if b_path:
if not os.path.isfile(b_path):
raise FwPackError(f'找不到 B 包输入文件: {b_path!r}')
b_body = open(b_path, 'rb').read()
validate_app_bin(b_body, profile, 'b')
say(f'B 包输入: {os.path.basename(b_path)} ({len(b_body)}B) — '
f'向量表校验通过 (B 槽)')
if a_body is None and b_body is None:
raise FwPackError('至少提供一个输入 bin (A 包或 B 包)')
bl_data = None
if bl_path:
if not os.path.isfile(bl_path):
raise FwPackError(f'找不到 BL 输入文件: {bl_path!r}')
bl_data = open(bl_path, 'rb').read()
if len(bl_data) > profile.bl_size:
raise FwPackError(f'BL {len(bl_data)}B 超过 BL 区 {profile.bl_size}B')
sp, rst = read_vector(bl_data)
if not (SRAM_LO <= sp <= SRAM_HI and rst & 1
and profile.bl_addr <= rst & ~1 < profile.bl_end):
say(f'警告: BL 向量表异常 (SP=0x{sp:08X}, RST=0x{rst:08X}), 请确认这是 '
f'链接在 0x{profile.bl_addr:08X} 的 bootloader')
out_dir = out_dir or os.path.dirname(os.path.abspath(a_path or b_path))
os.makedirs(out_dir, exist_ok=True)
a_base = os.path.splitext(os.path.basename(a_path))[0] if a_path else None
# -- A 包 --
ap = None
if a_body is not None:
ap = build_a_pack(a_body, profile)
upd_path = os.path.join(out_dir, a_base + '_UPDATE.bin')
with open(upd_path, 'wb') as f:
f.write(ap.data)
out.paths['update'] = upd_path
vp, vcrc, _rec = verify_a_blob(open(upd_path, 'rb').read(), profile)
if (vp, vcrc) != (ap.pages, ap.crc):
raise FwPackError('A 包回读复核失败')
say(f'A 包: {len(a_body)}B -> {ap.pages} 页({ap.pack_len}B) '
f'trailer=0x{ap.trailer_value:08X} (CRC 0x{ap.crc:04X}) — 回读复核通过')
say(f' {upd_path}')
# -- B 包 (仅作为镜像组成, 不再单独落盘) --
bp = None
if b_body is not None:
bp = build_b_pack(b_body, profile)
verify_b_blob(bp.data, profile)
area = profile.slot_b.record_area_len(profile.page_size)
rec_disp = (bp.record_bytes + b'\xFF' * (area - len(bp.record_bytes))) \
.hex(' ').upper()
say(f'B 包: 满槽 {bp.pages} 页({len(bp.data)}B) 记录 {rec_disp} '
f'(CRC 0x{bp.crc:04X}) — 复核通过')
elif profile.slot_b and make_full and a_body is not None:
say('提示: 未提供 B 底包, FULL 镜像中 B 槽保持 0xFF (擦除态)')
# -- FULL (A 运行槽是必需的; 仅 B 输入时不拼 FULL) --
if make_full and a_body is not None:
if bl_data is None:
say('警告: 未提供 BL bin, FULL 镜像 BL 区为 0xFF — 该镜像烧录后设备'
' 无法启动, 仅用于槽区预烧/分析!')
full_name = suggest_full_name(a_path, b_path)
full_path = os.path.join(out_dir, full_name)
image = build_full(profile, bl_data, ap, bp)
with open(full_path, 'wb') as f:
f.write(image)
out.paths['full'] = full_path
blob = open(full_path, 'rb').read()
out.checks = verify_full_blob(blob, profile, bl_data, a_body,
b_body if b_body is not None else None)
bad = [n for n, ok, _t in out.checks if not ok]
if bad:
raise FwPackError('FULL 镜像复核未通过: ' + '; '.join(bad))
say(f'FULL: {len(image)}B — {len(out.checks)} 项字节校验全部通过')
for n, ok, note in out.checks:
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
say(f' {full_path}')
# -- FACTORY (工厂烧录镜像: BL + B 满槽 + flag, 无 A; 需 B 底包) --
if make_factory and b_body is not None:
if bl_data is None:
say('警告: 未提供 BL bin, FACTORY 镜像 BL 区为 0xFF — 工厂烧录镜像'
' 必须带 BL, 请用 -b 提供!')
fac_name = suggest_factory_name(b_path)
fac_path = os.path.join(out_dir, fac_name)
fac_image = build_factory(profile, bl_data, bp)
with open(fac_path, 'wb') as f:
f.write(fac_image)
out.paths['factory'] = fac_path
fac_blob = open(fac_path, 'rb').read()
out.factory_checks = verify_factory_blob(fac_blob, profile, bl_data, b_body)
bad = [n for n, ok, _t in out.factory_checks if not ok]
if bad:
raise FwPackError('FACTORY 镜像复核未通过: ' + '; '.join(bad))
say(f'FACTORY: {len(fac_image)}B — {len(out.factory_checks)} 项字节校验'
f'全部通过 (A 槽保持擦除态, 上电跳 B)')
for n, ok, note in out.factory_checks:
say(f' [{"PASS" if ok else "FAIL"}] {n}' + (f' ({note})' if note else ''))
say(f' {fac_path}')
# -- MD5 清单 (挂在 FULL, 无 FULL 则挂在 FACTORY) --
anchor_path = out.paths.get('full') or out.paths.get('factory')
if anchor_path:
md5_path = anchor_path + '.md5.txt'
with open(md5_path, 'w', encoding='utf-8', newline='\n') as f:
f.write(f'# 布局摘要 — gd32e230_firmware_merge_tool v{TOOL_VERSION}\n')
f.write('# ' + profile.describe().replace('\n', '\n# ') + '\n')
f.write(f'# 生成: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n')
if ap:
f.write('# A 包: %s -> %d 页, trailer=0x%08X (CRC 0x%04X)\n'
% (os.path.basename(a_path), ap.pages,
ap.trailer_value, ap.crc))
if bp:
f.write('# B 包: %s -> 满槽 %d 页, 记录 %s (CRC 0x%04X)\n'
% (os.path.basename(b_path), bp.pages,
(bp.record_bytes + b'\xFF'
* (profile.slot_b.record_area_len(profile.page_size)
- len(bp.record_bytes))).hex(' ').upper(), bp.crc))
f.write('#\n')
for _k, p in out.paths.items():
if _k == 'md5':
continue
data = open(p, 'rb').read()
f.write('%s %8dB %s\n' % (hashlib.md5(data).hexdigest(),
len(data), os.path.basename(p)))
out.paths['md5'] = md5_path
say(f'MD5 清单: {md5_path}')
out.summary = log
return out