feat(pack): v2 双固件 profile 打包 — fw_pack 公共库 + CLI 重构 + GUI 打包弹窗

This commit is contained in:
2026-10-22 00:53:52 +08:00
parent d776979a5a
commit 031ee4efdf
12 changed files with 2345 additions and 292 deletions
+115 -4
View File
@@ -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 工具链, 免安装解压即用',
+12 -3
View File
@@ -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):
+7
View File
@@ -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,
}
+53 -1
View File
@@ -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: