10 changed files with 530 additions and 176 deletions
+29 -15
View File
@@ -9,7 +9,7 @@
> (Windows 10/11 x64,免安装、无需 Python,**内置 OpenOCD 工具链**)。 > (Windows 10/11 x64,免安装、无需 Python,**内置 OpenOCD 工具链**)。
> - **要改代码/重新打包**:见 §5、§6。 > - **要改代码/重新打包**:见 §5、§6。
> >
> 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色双栏仪表盘 + > 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色卡片仪表盘 +
> PyInstaller onefile + Gitea Release 发布)。更新:2026-09-14 > PyInstaller onefile + Gitea Release 发布)。更新:2026-09-14
## 1. 功能总览 ## 1. 功能总览
@@ -31,40 +31,53 @@
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases), 1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases),
取最新的 `DAPLinkFlash-vX.Y.Z-win64.zip`; 取最新的 `DAPLinkFlash-vX.Y.Z-win64.zip`;
2. 解压到**任意可写目录**(配置 `flash_gui_settings.json` 写在 exe 旁边); 2. 解压到**任意可写目录**(配置 `flash_gui_settings.json` 写在 exe 旁边);
3. 双击 `DAPLinkFlash.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。 3. 进入解压出的 `DAPLinkFlash` 文件夹,双击 `DAPLinkFlash.exe`。
目录版(onedir)无临时解压等待,启动接近秒开。
包内包含: 包内包含:
``` ```
DAPLinkFlash/ DAPLinkFlash/
├── DAPLinkFlash.exe 主程序(单文件自包含) ├── DAPLinkFlash.exe 主程序(启动器)
├── _internal/ 运行库(PyInstaller onedir, 须与 exe 同目录)
├── toolchain/openocd/ 内置 OpenOCD(GUI 自动定位) ├── toolchain/openocd/ 内置 OpenOCD(GUI 自动定位)
│ ├── bin/openocd.exe + libftdi1.dll / libusb-1.0.dll │ ├── bin/openocd.exe + libftdi1.dll / libusb-1.0.dll
│ └── openocd/scripts/ interface / target 配置脚本 │ └── openocd/scripts/ interface / target 配置脚本
└── README.md 本说明 └── README.md 本说明
``` ```
> **SmartScreen 拦截("Windows 已保护你的电脑")**:exe 未做代码签名,
> 浏览器下载的压缩包带"来自网络"标记。任选其一绕过:
> ① 点"更多信息 → 仍要运行"(每台电脑每个文件仅一次);
> ② 下载后**先右键 zip → 属性 → 勾选"解除锁定"→ 确定**,再解压,包内所有文件都不会再拦;
> ③ 用 7-Zip / WinRAR 解压(不继承网络标记)。
## 3. GUI 使用 ## 3. GUI 使用
主窗口为双栏仪表盘: 主窗口为单列仪表盘,补齐/拼接经顶栏按钮弹出独立窗口:
``` ```
左列 右列 顶栏 DAPLink Flash [补齐 (Pad)] [拼接 (Merge)] 关于 Dark/Light/System
┌ 探针与工具链 [自动定位] ┐ ┌ 补齐 (Pad) — Y-Modem 对齐 ┐ ┌ 探针与工具链 [自动定位] ┐
├ 芯片与固件(型号/固件/基地址)┤ ├ 拼接 (Merge) — BL+APP+标志 ┤ ├ 芯片与固件(型号/固件/基地址) ┤
└ 操作: 检测/烧录/擦除/读取/复位/停止 ┘ └ 运行日志 — OpenOCD 输出 ┘ ├ 运行日志 — OpenOCD 输出 ┤
└ 操作: 检测/烧录/擦除/读取/复位/停止 ┘
弹窗 补齐 (Pad) / 拼接 (Merge) — 非模态, 配置与主窗口共享
``` ```
1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上 1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上
"自动定位"可重新探测;也可手动浏览指定 `openocd.exe` 与 scripts 目录; "自动定位"可重新探测;也可手动浏览指定 `openocd.exe` 与 scripts 目录;
2. **选芯片与烧录器**:芯片型号下拉自动带出 Flash 大小和 target;烧录器下拉 2. **选芯片与烧录器**:芯片型号下拉自动带出 Flash 大小和 target;烧录器下拉
自动带出 interface cfg;多探针时填序列号; 自动带出 interface cfg;多探针时填序列号;
**自定义 cfg**:把自己的 OpenOCD cfg 文件(如工程定制的 target 配置)放进
程序旁的 `cfgs/` 目录,"Target 配置"/"Interface 配置"下拉框即列出
`cfgs/<文件名>` 供选用;`cfgs/` 会随发布包分发;
3. **选固件**:点"浏览"选 BIN/HEX/ELF;BIN 确认基地址(完整镜像 `0x08000000`, 3. **选固件**:点"浏览"选 BIN/HEX/ELF;BIN 确认基地址(完整镜像 `0x08000000`,
单独 APP `0x08002000`); 单独 APP `0x08002000`);
4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区; 4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区;
5. **固件工具**(右列): 5. **固件工具**(顶栏按钮弹出的独立窗口,非模态):
- 补齐:选输入 BIN 后输出名自动带出(`*_pad.bin`),对齐默认 1K,点"执行补齐"; - 补齐:选输入 BIN 后输出名自动带出(`*_update.bin`,Y-Modem 升级包),对齐默认 1K,点"执行补齐";
- 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`),地址按典型布局 - 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`,整片镜像),地址按典型布局
预填,点"执行拼接"。 预填,点"执行拼接"。
典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC` 典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC`
@@ -112,8 +125,7 @@ FlashTool/
│ ├── publish_gitea.py # 上传到 Gitea Release │ ├── publish_gitea.py # 上传到 Gitea Release
│ └── logos/ # 图标源(make_ico.py + png/ico) │ └── logos/ # 图标源(make_ico.py + png/ico)
├── release/ # 构建与发布产物(机器生成区, 不手改/不入库) ├── release/ # 构建与发布产物(机器生成区, 不手改/不入库)
│ ├── DAPLinkFlash.exe # 打包单文件版 │ ├── DAPLinkFlash/ # onedir 程序目录: exe + _internal/ + toolchain/
│ ├── toolchain/openocd/ # package_release.py 复制的内置工具链
│ └── *.zip # 发布包(上传 Gitea Release) │ └── *.zip # 发布包(上传 Gitea Release)
├── toolchain/openocd/ # (可选) vendor 进仓库的工具链, 不入库 ├── toolchain/openocd/ # (可选) vendor 进仓库的工具链, 不入库
├── start_flash_gui.bat # 源码模式双击启动器 ├── start_flash_gui.bat # 源码模式双击启动器
@@ -130,8 +142,8 @@ OpenOCD 用本机安装(默认找 `D:/toolchain/openocd`)或先跑一次
```powershell ```powershell
cd src cd src
python logos/make_ico.py # (可选) 改图标后重新生成 png/ico, 需 Pillow python logos/make_ico.py # (可选) 改图标后重新生成 png/ico, 需 Pillow
python build_exe.py # 打包 → ../release/DAPLinkFlash.exe(约 19MB; python build_exe.py # 打包 → ../release/DAPLinkFlash/(onedir 目录版,
# 自动装 PyInstaller, CTk 主题数据自动收集) # 启动秒开; 自动装 PyInstaller, CTk 主题数据自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(命令行模式可见输出) python build_exe.py --debug # 额外出一个带控制台的调试变体(命令行模式可见输出)
python package_release.py 0.1.0 # 组装 release/DAPLinkFlash-v0.1.0-win64.zip python package_release.py 0.1.0 # 组装 release/DAPLinkFlash-v0.1.0-win64.zip
# 含内置 OpenOCD 工具链(来源见脚本说明) # 含内置 OpenOCD 工具链(来源见脚本说明)
@@ -143,6 +155,8 @@ python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip --insecure
## 7. 注意事项 / FAQ ## 7. 注意事项 / FAQ
- **"Windows 已保护你的电脑"(SmartScreen)**:exe 无代码签名所致,
处理见 §2 的三种绕过方式;要彻底消除需购买代码签名证书对 exe 签名;
- **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/` - **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/`
目录和 exe 在一起; 目录和 exe 在一起;
- **烧录失败排查**:①"检测设备"先验链路;② 探针序列号留空可排除序列号问题; - **烧录失败排查**:①"检测设备"先验链路;② 探针序列号留空可排除序列号问题;
@@ -0,0 +1,72 @@
#
# GigaDevice GD32E23x target
#
adapter driver cmsis-dap
# adapter driver jlink
source [find target/swj-dp.tcl]
source [find mem_helper.tcl]
if { [info exists CHIPNAME] } {
set _CHIPNAME $CHIPNAME
} else {
set _CHIPNAME gd32e23x
}
# Work-area is a space in RAM used for flash programming
# By default use 4kB (as found on some GD32E230s)
if { [info exists WORKAREASIZE] } {
set _WORKAREASIZE $WORKAREASIZE
} else {
set _WORKAREASIZE 0x1000
}
# Allow overriding the Flash bank size
if { [info exists FLASH_SIZE] } {
set _FLASH_SIZE $FLASH_SIZE
} else {
# autodetect size
set _FLASH_SIZE 0
}
#jtag scan chain
if { [info exists CPUTAPID] } {
set _CPUTAPID $CPUTAPID
} else {
# this is the SW-DP tap id not the jtag tap id
set _CPUTAPID 0x0BF11477
}
swj_newdap $_CHIPNAME cpu -irlen 4 -ircapture 0x1 -irmask 0xf -expected-id $_CPUTAPID
dap create $_CHIPNAME.dap -chain-position $_CHIPNAME.cpu
set _TARGETNAME $_CHIPNAME.cpu
target create $_TARGETNAME cortex_m -dap $_CHIPNAME.dap
$_TARGETNAME configure -work-area-phys 0x20000000 -work-area-size $_WORKAREASIZE -work-area-backup 0
# flash size will be probed
set _FLASHNAME $_CHIPNAME.flash
flash bank $_FLASHNAME gd32e23x 0x08000000 $_FLASH_SIZE 0 0 $_TARGETNAME
# SWD speed (may be updated to higher value in board config file)
adapter speed 1000
# use the hardware reset line (DAPLink RST pin -> GD32 NRST);
# connect_assert_srst: hold the chip in reset while connecting, so SWD pins
# (shared with LED in firmware) revert to SWD function before any access
adapter srst delay 100
reset_config srst_only srst_nogate connect_assert_srst
if {![using_hla]} {
# if srst is not fitted use SYSRESETREQ to
# perform a soft reset
cortex_m reset_config sysresetreq
}
$_TARGETNAME configure -event examine-end {
mmw 0x40021018 0x00400000 0
mmw 0x40015804 0x00000307 0
}
+179 -77
View File
@@ -33,19 +33,25 @@ BOLD = ('Microsoft YaHei UI', 13, 'bold')
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold') TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
APP_NAME = 'DAPLink Flash' APP_NAME = 'DAPLink Flash'
APP_VERSION = '2.0.0' APP_VERSION = '2.5.2'
REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool' REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool'
OK_BLUE = '#3B8ED0' OK_BLUE = '#3B8ED0'
def get_icon_path(): def get_icon_path():
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" """工具图标: 源码模式取 logos/ 下, 打包模式取资源目录
(onefile 在 _MEIPASS 临时目录, onedir 在 exe 旁 _internal/)"""
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_flash.ico') exe_dir = os.path.dirname(os.path.abspath(sys.executable))
else: candidates = (os.path.join(exe_dir, '_internal', 'logo_flash.ico'),
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.join(exe_dir, 'logo_flash.ico'))
'logos', 'logo_flash.ico') for p in candidates:
if os.path.exists(p):
return p
return None
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'logos', 'logo_flash.ico')
return p if os.path.exists(p) else None return p if os.path.exists(p) else None
@@ -73,8 +79,11 @@ class App(ctk.CTk):
'hulk.DAPLinkFlash') 'hulk.DAPLinkFlash')
except Exception: except Exception:
pass pass
self.geometry('1360x880') self.geometry('960x950')
self.minsize(1200, 780) self.minsize(860, 800)
# 构建完成前先隐藏窗口: CTk 控件是逐个绘制显示的, 不隐藏会出现
# "界面一项一项加载出来"的观感, 构建完 deiconify 一次性呈现
self.withdraw()
ic = get_icon_path() ic = get_icon_path()
if ic: if ic:
try: try:
@@ -99,16 +108,26 @@ class App(ctk.CTk):
self.proc = None self.proc = None
self._poll_job = None self._poll_job = None
self._op_buttons = [] self._op_buttons = []
self.v_pad_result = tk.StringVar(value='')
self.v_merge_result = tk.StringVar(value='')
self._build() self._build()
self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接') self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接')
ver = openocd_runner.version(self.cfg['openocd']) self._log('工具链: %s (%s)' % (self.cfg['openocd'], self._toolchain_source))
self._log('工具链: %s (%s)%s' % (self.cfg['openocd'], self._toolchain_source, # openocd --version 要起子进程, 杀毒软件下可能阻塞数秒, 挪到主循环空闲后补记
' ' + ver if ver else '')) self.after(300, self._log_toolchain_version)
self._poll() self._poll()
self.protocol('WM_DELETE_WINDOW', self._on_close) self.protocol('WM_DELETE_WINDOW', self._on_close)
if auto_close > 0: if auto_close > 0:
self.after(auto_close * 1000, self._on_close) self.after(auto_close * 1000, self._on_close)
# 显示窗口必须等事件循环跑起来: 对从未映射过的 withdrawn 窗口,
# 在 __init__ 里同步 deiconify 不会完成映射 → 窗口永远不出现(看似闪退)
self.after(120, self.deiconify)
def _log_toolchain_version(self):
ver = openocd_runner.version(self.cfg['openocd'])
if ver:
self._log(' ' + ver)
# ============================================================ # ============================================================
# 布局 # 布局
@@ -138,8 +157,13 @@ class App(ctk.CTk):
top = ctk.CTkFrame(self, fg_color='transparent') top = ctk.CTkFrame(self, fg_color='transparent')
top.pack(fill='x', padx=12, pady=(10, 2)) top.pack(fill='x', padx=12, pady=(10, 2))
ctk.CTkLabel(top, text=APP_NAME, font=TITLE_FONT).pack(side='left') ctk.CTkLabel(top, text=APP_NAME, font=TITLE_FONT).pack(side='left')
ctk.CTkLabel(top, text='GD32 固件烧录 · 补齐 · 镜像拼接', ctk.CTkLabel(top, text='GD32 固件烧录',
text_color='gray60').pack(side='left', padx=10, pady=(6, 0)) text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
ctk.CTkButton(top, text='补齐 (Pad)', width=88, height=26,
command=self._open_pad_dialog).pack(side='left', padx=(16, 6),
pady=(4, 0))
ctk.CTkButton(top, text='拼接 (Merge)', width=100, height=26,
command=self._open_merge_dialog).pack(side='left', pady=(4, 0))
seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'], seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'],
command=self._set_appearance) command=self._set_appearance)
seg.set('System') seg.set('System')
@@ -152,15 +176,9 @@ class App(ctk.CTk):
body = ctk.CTkFrame(self, fg_color='transparent') body = ctk.CTkFrame(self, fg_color='transparent')
body.pack(fill='both', expand=True, padx=12, pady=(4, 12)) body.pack(fill='both', expand=True, padx=12, pady=(4, 12))
body.grid_columnconfigure(0, weight=1, uniform='col') body.grid_rowconfigure(2, weight=1) # 运行日志吃掉剩余高度
body.grid_columnconfigure(1, weight=1, uniform='col') body.grid_columnconfigure(0, weight=1)
body.grid_rowconfigure(0, weight=1) self._build_main(body)
left = ctk.CTkFrame(body, fg_color='transparent')
left.grid(row=0, column=0, sticky='nsew')
right = ctk.CTkFrame(body, fg_color='transparent')
right.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
self._build_left(left)
self._build_right(right)
status = ctk.CTkFrame(self, fg_color='transparent') status = ctk.CTkFrame(self, fg_color='transparent')
status.pack(fill='x', padx=14, pady=(0, 8)) status.pack(fill='x', padx=14, pady=(0, 8))
@@ -169,9 +187,9 @@ class App(ctk.CTk):
ctk.CTkLabel(status, text=f'v{APP_VERSION}', ctk.CTkLabel(status, text=f'v{APP_VERSION}',
text_color='gray60').pack(side='right') text_color='gray60').pack(side='right')
# -- 左列: 探针与工具链 / 芯片与固件 / 操作 -- # -- 主列: 探针与工具链 / 芯片与固件 / 运行日志 / 操作 --
def _build_left(self, col): def _build_main(self, col):
col.grid_rowconfigure(2, weight=1) # 操作卡片吸底 col.grid_rowconfigure(2, weight=1) # 日志卡片吃掉剩余高度
col.grid_columnconfigure(0, weight=1) col.grid_columnconfigure(0, weight=1)
self.v_toolchain = tk.StringVar( self.v_toolchain = tk.StringVar(
@@ -182,18 +200,31 @@ class App(ctk.CTk):
card.grid(row=0, column=0, sticky='ew') card.grid(row=0, column=0, sticky='ew')
self._field(c, 'OpenOCD 路径', self.v['openocd'], self._browse_openocd) self._field(c, 'OpenOCD 路径', self.v['openocd'], self._browse_openocd)
self._field(c, '脚本目录', self.v['scripts'], self._browse_scripts) self._field(c, '脚本目录', self.v['scripts'], self._browse_scripts)
probe, _w = self._field_pair( custom = ['cfgs/' + n for n in openocd_runner.list_custom_cfgs()]
probe, iface_cbo = self._field_pair(
c, '烧录器', self.v['probe'], 'Interface', self.v['interface'], c, '烧录器', self.v['probe'], 'Interface', self.v['interface'],
cls1=(ctk.CTkComboBox, {'state': 'readonly', cls1=(ctk.CTkComboBox, {'state': 'readonly',
'values': list(openocd_runner.PROBE_PROFILES)}), 'values': list(openocd_runner.PROBE_PROFILES)}),
cls2=(ctk.CTkComboBox, cls2=(ctk.CTkComboBox, {'values': custom}))
{'values': sorted(set(openocd_runner.PROBE_PROFILES.values()) - {''})}))
probe.configure(command=lambda _=None: self._on_probe_change()) probe.configure(command=lambda _=None: self._on_probe_change())
self.probe_combo = probe
self.cbo_interface = iface_cbo
self._field_pair(c, '速度 (kHz)', self.v['speed'], '探针序列号', self._field_pair(c, '速度 (kHz)', self.v['speed'], '探针序列号',
self.v['serial'], width2=150) self.v['serial'], width2=150)
self._field_pair( target_cbo, _extra = self._field_pair(
c, 'Target', self.v['target'], '额外命令', self.v['extra'], c, 'Target', self.v['target'], '额外命令', self.v['extra'],
cls1=(ctk.CTkComboBox, {'values': ['target/gd32e23x.cfg']})) cls1=(ctk.CTkComboBox, {'values': custom}))
self.cbo_target = target_cbo
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x', pady=(4, 0))
ctk.CTkButton(row, text='打开 cfgs 目录', width=110, height=24,
fg_color='transparent', border_width=1,
border_color=('gray55', 'gray35'),
text_color='gray60', hover_color=('gray82', 'gray22'),
command=self._open_cfgs_dir).pack(side='left')
ctk.CTkLabel(row, text='自定义 cfg 放 cfgs/(下拉自动列出); 换工具链后 Target/Interface 列表自动跟随其 scripts 目录',
text_color='gray50').pack(side='left', padx=(8, 0))
self._refresh_cfg_options()
card, c = self._card(col, '芯片与固件') card, c = self._card(col, '芯片与固件')
card.grid(row=1, column=0, sticky='ew', pady=(10, 0)) card.grid(row=1, column=0, sticky='ew', pady=(10, 0))
@@ -210,16 +241,37 @@ class App(ctk.CTk):
'单独烧 APP 0x08002000', '单独烧 APP 0x08002000',
text_color='gray50', justify='left', anchor='w', text_color='gray50', justify='left', anchor='w',
).pack(fill='x', pady=(2, 0)) ).pack(fill='x', pady=(2, 0))
opts = ctk.CTkFrame(c, fg_color='transparent') opts1 = ctk.CTkFrame(c, fg_color='transparent')
opts.pack(fill='x', pady=(6, 0)) opts1.pack(fill='x', pady=(6, 0))
for key, name in (('erase', '烧录前擦除'), ('verify', '烧录后校验'), ctk.CTkCheckBox(opts1, text='烧录前硬件复位', variable=self.v['pre_reset'],
('reset', '烧录后复位运行')): checkbox_width=18, checkbox_height=18).pack(
ctk.CTkCheckBox(opts, text=name, variable=self.v[key], side='left', padx=(0, 14))
checkbox_width=18, checkbox_height=18).pack( ctk.CTkCheckBox(opts1, text='烧录前擦除', variable=self.v['erase'],
side='left', padx=(0, 14)) checkbox_width=18, checkbox_height=18).pack(
side='left', padx=(0, 14))
ctk.CTkCheckBox(opts1, text='烧录后校验', variable=self.v['verify'],
checkbox_width=18, checkbox_height=18).pack(
side='left', padx=(0, 14))
opts2 = ctk.CTkFrame(c, fg_color='transparent')
opts2.pack(fill='x', pady=(4, 0))
ctk.CTkCheckBox(opts2, text='烧录后硬件复位', variable=self.v['post_reset'],
checkbox_width=18, checkbox_height=18).pack(
side='left', padx=(0, 14))
ctk.CTkCheckBox(opts2, text='烧录后复位运行', variable=self.v['reset'],
checkbox_width=18, checkbox_height=18).pack(
side='left', padx=(0, 14))
ctk.CTkLabel(opts2, text='硬件复位 = 复位前先释放 SRST 并等待 200ms',
text_color='gray50').pack(side='left')
card, c = self._card(col, '运行日志 — OpenOCD 输出与固件工具结果',
button='清空', command=self._clear_log)
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
self.txt_log = ctk.CTkTextbox(c, font=MONO)
self.txt_log.pack(fill='both', expand=True)
self.txt_log.configure(state='disabled')
card, c = self._card(col, '操作') card, c = self._card(col, '操作')
card.grid(row=2, column=0, sticky='sew', pady=(10, 0)) card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
grid = ctk.CTkFrame(c, fg_color='transparent') grid = ctk.CTkFrame(c, fg_color='transparent')
grid.pack(fill='x') grid.pack(fill='x')
grid.grid_columnconfigure((0, 1, 2), weight=1, uniform='op') grid.grid_columnconfigure((0, 1, 2), weight=1, uniform='op')
@@ -235,18 +287,30 @@ class App(ctk.CTk):
command=self._on_stop, state='disabled') command=self._on_stop, state='disabled')
self.btn_stop.grid(row=1, column=2, sticky='ew', padx=3, pady=3) self.btn_stop.grid(row=1, column=2, sticky='ew', padx=3, pady=3)
# -- 右列: 补齐 / 拼接 / 日志 -- # ============================================================
def _build_right(self, col): # 固件工具弹窗: 补齐 / 拼接
col.grid_rowconfigure(2, weight=1) # 日志卡片吃掉剩余高度 # ============================================================
col.grid_columnconfigure(0, weight=1) def _center_dialog(self, dlg, w, h):
"""弹窗叠在主窗口中上部"""
dlg.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - w) // 2)
y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 4)
dlg.geometry(f'{w}x{h}+{x}+{y}')
card, c = self._card(col, '补齐 (Pad) — 尾部补 0xFF 到页边界, ' def _open_pad_dialog(self):
'适配 Bootloader Y-Modem 整包接收') dlg = ctk.CTkToplevel(self)
card.grid(row=0, column=0, sticky='ew') dlg.title('固件工具 — 补齐 (Pad)')
self._field(c, '输入 BIN', self.v['pad_in'], self._browse_pad_in) dlg.resizable(False, False)
self._field(c, '输出 BIN', self.v['pad_out'], self._browse_pad_out) dlg.transient(self)
row = ctk.CTkFrame(c, fg_color='transparent') box = ctk.CTkFrame(dlg, fg_color='transparent')
row.pack(fill='x', pady=(6, 0)) box.pack(fill='both', expand=True, padx=18, pady=(14, 12))
ctk.CTkLabel(box, text='补齐 (Pad)', font=BOLD, anchor='w').pack(fill='x')
ctk.CTkLabel(box, text='尾部补 0xFF 到页边界, 适配 Bootloader Y-Modem 整包接收',
text_color='gray60', anchor='w').pack(fill='x', pady=(0, 6))
self._field(box, '输入 BIN', self.v['pad_in'], self._browse_pad_in)
self._field(box, '输出 BIN', self.v['pad_out'], self._browse_pad_out)
row = ctk.CTkFrame(box, fg_color='transparent')
row.pack(fill='x', pady=(8, 0))
ctk.CTkLabel(row, text='对齐:', width=40, anchor='e').pack(side='left') ctk.CTkLabel(row, text='对齐:', width=40, anchor='e').pack(side='left')
ctk.CTkComboBox(row, width=86, state='readonly', ctk.CTkComboBox(row, width=86, state='readonly',
values=['1024', '2048', '4096'], values=['1024', '2048', '4096'],
@@ -256,17 +320,27 @@ class App(ctk.CTk):
).pack(side='left', padx=(4, 12)) ).pack(side='left', padx=(4, 12))
ctk.CTkButton(row, text='执行补齐', width=96, ctk.CTkButton(row, text='执行补齐', width=96,
command=self._on_pad).pack(side='left') command=self._on_pad).pack(side='left')
self.v_pad_result = tk.StringVar(value='') ctk.CTkLabel(box, textvariable=self.v_pad_result, text_color=OK_BLUE,
ctk.CTkLabel(c, textvariable=self.v_pad_result, text_color=OK_BLUE, justify='left', anchor='w').pack(fill='x', pady=(8, 0))
justify='left', anchor='w').pack(fill='x', pady=(4, 0)) ctk.CTkLabel(box, text='Bootloader 的 Y-Modem 按 1K 整包接收, OTA 升级包发送前先补齐',
text_color='gray50', anchor='w').pack(fill='x', pady=(2, 0))
self._center_dialog(dlg, 680, 300)
card, c = self._card(col, '拼接 (Merge) — Bootloader + APP + 有效标志 → 整片镜像') def _open_merge_dialog(self):
card.grid(row=1, column=0, sticky='ew', pady=(10, 0)) dlg = ctk.CTkToplevel(self)
self._field(c, 'Bootloader', self.v['bootloader'], self._browse_bl) dlg.title('固件工具 — 拼接 (Merge)')
self._field(c, 'Application', self.v['app'], self._browse_app) dlg.resizable(False, False)
self._field(c, '输出文件', self.v['merge_out'], self._browse_merge_out) dlg.transient(self)
row = ctk.CTkFrame(c, fg_color='transparent') box = ctk.CTkFrame(dlg, fg_color='transparent')
row.pack(fill='x', pady=(6, 0)) box.pack(fill='both', expand=True, padx=18, pady=(14, 12))
ctk.CTkLabel(box, text='拼接 (Merge)', font=BOLD, anchor='w').pack(fill='x')
ctk.CTkLabel(box, text='Bootloader + APP + 有效标志 → 整片 Flash 镜像',
text_color='gray60', anchor='w').pack(fill='x', pady=(0, 6))
self._field(box, 'Bootloader', self.v['bootloader'], self._browse_bl)
self._field(box, 'Application', self.v['app'], self._browse_app)
self._field(box, '输出文件', self.v['merge_out'], self._browse_merge_out)
row = ctk.CTkFrame(box, fg_color='transparent')
row.pack(fill='x', pady=(8, 0))
for label, key, w in (('APP 地址', 'app_addr', 104), for label, key, w in (('APP 地址', 'app_addr', 104),
('标志地址', 'flag_addr', 104), ('标志地址', 'flag_addr', 104),
('Flash', 'merge_flash', 84), ('Flash', 'merge_flash', 84),
@@ -274,22 +348,14 @@ class App(ctk.CTk):
ctk.CTkLabel(row, text=label + ':', anchor='e').pack(side='left') ctk.CTkLabel(row, text=label + ':', anchor='e').pack(side='left')
ctk.CTkEntry(row, width=w, textvariable=self.v[key] ctk.CTkEntry(row, width=w, textvariable=self.v[key]
).pack(side='left', padx=(4, 10)) ).pack(side='left', padx=(4, 10))
ctk.CTkButton(row, text='执行拼接', width=96, ctk.CTkButton(box, text='执行拼接', width=96,
command=self._on_merge).pack(side='left') command=self._on_merge).pack(anchor='w', pady=(8, 0))
self.v_merge_result = tk.StringVar(value='') ctk.CTkLabel(box, textvariable=self.v_merge_result, text_color=OK_BLUE,
ctk.CTkLabel(c, textvariable=self.v_merge_result, text_color=OK_BLUE, justify='left', anchor='w').pack(fill='x', pady=(2, 0))
justify='left', anchor='w').pack(fill='x', pady=(4, 0)) ctk.CTkLabel(box, text='典型布局: 0x08000000 Bootloader 8KB | 0x08002000 APP | '
ctk.CTkLabel(c, text='典型布局: 0x08000000 Bootloader 8KB | 0x08002000 APP | ' '0x0800FFFC 有效标志 0xEEEEEEEE | Flash 64KB',
'0x0800FFFC 有效标志 0xEEEEEEEE | Flash 64KB', text_color='gray50', anchor='w').pack(fill='x', pady=(2, 0))
text_color='gray50', justify='left', anchor='w', self._center_dialog(dlg, 720, 420)
).pack(fill='x', pady=(2, 0))
card, c = self._card(col, '运行日志 — OpenOCD 输出与固件工具结果',
button='清空', command=self._clear_log)
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
self.txt_log = ctk.CTkTextbox(c, font=MONO)
self.txt_log.pack(fill='both', expand=True)
self.txt_log.configure(state='disabled')
# ============================================================ # ============================================================
# 输入行辅助 # 输入行辅助
@@ -337,11 +403,38 @@ class App(ctk.CTk):
title='选择 OpenOCD', filetypes=[('OpenOCD', 'openocd.exe'), ('所有文件', '*.*')]) title='选择 OpenOCD', filetypes=[('OpenOCD', 'openocd.exe'), ('所有文件', '*.*')])
if path: if path:
self.v['openocd'].set(path) self.v['openocd'].set(path)
# 换工具链: scripts 目录与 cfg 下拉跟随当前版本
root = os.path.dirname(os.path.dirname(path))
scripts = openocd_runner._find_scripts(root)
if scripts:
self.v['scripts'].set(scripts)
self._refresh_cfg_options()
def _browse_scripts(self): def _browse_scripts(self):
path = filedialog.askdirectory(title='选择 OpenOCD scripts 目录') path = filedialog.askdirectory(title='选择 OpenOCD scripts 目录')
if path: if path:
self.v['scripts'].set(path) self.v['scripts'].set(path)
self._refresh_cfg_options()
def _refresh_cfg_options(self):
"""按当前 scripts 目录重建 Target/Interface 下拉列表(自定义 cfgs/ 优先在前)"""
custom = ['cfgs/' + n for n in openocd_runner.list_custom_cfgs()]
scripts = self.v['scripts'].get()
targets = openocd_runner.scan_cfgs(scripts, 'target')
interfaces = openocd_runner.scan_cfgs(scripts, 'interface')
cur_t, cur_i = self.v['target'].get(), self.v['interface'].get()
self.cbo_target.configure(values=custom + targets)
self.cbo_interface.configure(values=custom + interfaces)
self.v['target'].set(cur_t)
self.v['interface'].set(cur_i)
def _open_cfgs_dir(self):
"""创建并打开自定义 cfg 目录, 方便直接丢 cfg 文件进来"""
import subprocess
d = openocd_runner.custom_cfg_dir()
os.makedirs(d, exist_ok=True)
subprocess.Popen(['explorer', os.path.normpath(d)])
self._refresh_cfg_options()
def _relocate_toolchain(self): def _relocate_toolchain(self):
exe, scripts, source = openocd_runner.locate_toolchain() exe, scripts, source = openocd_runner.locate_toolchain()
@@ -351,6 +444,7 @@ class App(ctk.CTk):
self.v_toolchain.set('工具链: ' + source) self.v_toolchain.set('工具链: ' + source)
ver = openocd_runner.version(exe) ver = openocd_runner.version(exe)
self._log('工具链定位: %s (%s)%s' % (exe, source, ' ' + ver if ver else '')) self._log('工具链定位: %s (%s)%s' % (exe, source, ' ' + ver if ver else ''))
self._refresh_cfg_options()
def _mark_custom_toolchain(self, *_a): def _mark_custom_toolchain(self, *_a):
if self._toolchain_source != '自定义': if self._toolchain_source != '自定义':
@@ -370,7 +464,7 @@ class App(ctk.CTk):
if path: if path:
self.v['pad_in'].set(path) self.v['pad_in'].set(path)
name, ext = os.path.splitext(path) name, ext = os.path.splitext(path)
self.v['pad_out'].set(name + '_pad' + ext) self.v['pad_out'].set(name + '_update' + ext)
def _browse_pad_out(self): def _browse_pad_out(self):
path = filedialog.asksaveasfilename( path = filedialog.asksaveasfilename(
@@ -397,7 +491,8 @@ class App(ctk.CTk):
if self.v['app'].get(): if self.v['app'].get():
name, ext = os.path.splitext(os.path.basename(self.v['app'].get())) name, ext = os.path.splitext(os.path.basename(self.v['app'].get()))
self.v['merge_out'].set( self.v['merge_out'].set(
os.path.join(os.path.dirname(self.v['app'].get()), name + '_BL' + ext)) os.path.join(os.path.dirname(self.v['app'].get()),
name + '_BL' + ext))
def _browse_merge_out(self): def _browse_merge_out(self):
path = filedialog.asksaveasfilename( path = filedialog.asksaveasfilename(
@@ -411,6 +506,10 @@ class App(ctk.CTk):
self.v['target'].set(openocd_runner.chip_target(self.v['chip'].get())) self.v['target'].set(openocd_runner.chip_target(self.v['chip'].get()))
def _on_probe_change(self, *_a): def _on_probe_change(self, *_a):
if self.v['probe'].get() == 'Custom':
# Custom: interface 留空(配合 cfgs/ 里自带 adapter driver 的 cfg)
self.v['interface'].set('')
return
cfg = openocd_runner.PROBE_PROFILES.get(self.v['probe'].get(), '') cfg = openocd_runner.PROBE_PROFILES.get(self.v['probe'].get(), '')
if cfg: if cfg:
self.v['interface'].set(cfg) self.v['interface'].set(cfg)
@@ -472,7 +571,9 @@ class App(ctk.CTk):
script = openocd_runner.script_flash( script = openocd_runner.script_flash(
filepath, self.v['base'].get(), filepath, self.v['base'].get(),
erase=self.v['erase'].get(), verify=self.v['verify'].get(), erase=self.v['erase'].get(), verify=self.v['verify'].get(),
reset=self.v['reset'].get()) reset=self.v['reset'].get(),
pre_reset=self.v['pre_reset'].get(),
post_reset=self.v['post_reset'].get())
except ValueError as exc: except ValueError as exc:
messagebox.showerror('参数错误', str(exc)) messagebox.showerror('参数错误', str(exc))
return return
@@ -515,6 +616,7 @@ class App(ctk.CTk):
else: else:
self._log('[失败] %s (退出码 %d)' % (title, code)) self._log('[失败] %s (退出码 %d)' % (title, code))
self._set_status('%s 失败' % title) self._set_status('%s 失败' % title)
self.proc = None # 释放占用, 允许下一次操作
self._busy(False) self._busy(False)
self._save() self._save()
except queue.Empty: except queue.Empty:
+32 -26
View File
@@ -1,27 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
把 flash_gui.py 打包成单文件 exe(工具名: DAPLinkFlash) 把 flash_gui.py 打包成目录版 exe(工具名: DAPLinkFlash)
================================================================ ================================================================
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter + 使用 PyInstaller --onedir --noconsole: 解释器 + 标准库 + tkinter +
customtkinter 全部打进一个 exe, 目标机器无需安装任何依赖。 customtkinter 进 exe + _internal/ 目录, 目标机器无需安装任何依赖。
OpenOCD 工具链不进 exe, 由 package_release.py 以 toolchain/ 目录随包分发 不用 --onefile: onefile 每次启动都要把几十 MB 解压到临时目录, 杀毒
(scripts 目录含 1000+ 文件, 进 onefile 会拖慢每次启动的解压)。 软件下明显拖慢开窗; 本工具本来就以文件夹形式发布(toolchain/ 随包),
onedir 解压一次即用, 启动接近秒开。
用法: 用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包 python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体(命令行模式可见输出) python build_exe.py --debug 额外打一个带控制台的变体(命令行模式可见输出)
产物: release/DAPLinkFlash.exe(及 --debug 时的 DAPLinkFlash-console.exe) 产物: release/DAPLinkFlash/(DAPLinkFlash.exe + _internal/)
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
""" """
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import time import time
# 本脚本位于 src/, 打包产物输出到 FlashTool/release/ # 本脚本位于 src/, 打包产物输出到 FlashTool/release/DAPLinkFlash/
SRC = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC) BASE = os.path.dirname(SRC)
DIST = os.path.join(BASE, 'release') DIST = os.path.join(BASE, 'release')
@@ -49,38 +50,43 @@ def extra_collect_args():
return [] return []
def remove_stale(exe_path): def remove_stale(out_dir, name):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试""" """删除旧输出目录; exe 被占用(上次验证的进程还在)先结束同名进程再重试"""
if not os.path.exists(exe_path): if not os.path.isdir(out_dir):
return return
try: exe = os.path.join(out_dir, name + '.exe')
os.remove(exe_path) if os.path.exists(exe):
return try:
except PermissionError: shutil.rmtree(out_dir)
pass return
print(f'{exe_path} 被占用, 结束残留进程后重试...') except PermissionError:
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)], pass
capture_output=True) print(f'{exe} 被占用, 结束残留进程后重试...')
time.sleep(1.0) subprocess.run(['taskkill', '/F', '/IM', name + '.exe'],
os.remove(exe_path) capture_output=True)
time.sleep(1.0)
shutil.rmtree(out_dir, ignore_errors=True)
def build(name, extra_args): def build(name, extra_args):
remove_stale(os.path.join(DIST, name + '.exe')) out_dir = os.path.join(DIST, name)
remove_stale(out_dir, name)
cmd = [sys.executable, '-m', 'PyInstaller', cmd = [sys.executable, '-m', 'PyInstaller',
'--onefile', '--clean', '--name', name, '--onedir', '--clean', '--name', name,
'--distpath', DIST, '--workpath', WORK, '--specpath', WORK, '--distpath', DIST, '--workpath', WORK, '--specpath', WORK,
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'flash_gui.py')] ] + extra_args + extra_collect_args() + [os.path.join(SRC, 'flash_gui.py')]
if os.path.exists(ICO): if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取) # exe 文件图标 + 运行时窗口图标资源(onedir 落在 _internal/, GUI 从那里读)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.'] cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
else: else:
print('警告: 未找到 logos/logo_flash.ico(先运行 logos/make_ico.py), ' print('警告: 未找到 logos/logo_flash.ico(先运行 logos/make_ico.py), '
'本次打包不带图标') '本次打包不带图标')
print('\n>>', ' '.join(cmd)) print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd) subprocess.check_call(cmd)
out = os.path.join(DIST, name + '.exe') out = os.path.join(out_dir, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)') total = sum(os.path.getsize(os.path.join(r, f))
for r, _d, fs in os.walk(out_dir) for f in fs)
print(f'完成: {out} (目录共 {total / 1024 / 1024:.1f} MB)')
def main(): def main():
+5 -3
View File
@@ -30,19 +30,21 @@ SETTINGS_PATH = os.path.join(BASE_DIR, SETTINGS_NAME)
DEFAULTS = { DEFAULTS = {
"openocd": "", # 空 = 启动时按 locate_toolchain() 自动定位 "openocd": "", # 空 = 启动时按 locate_toolchain() 自动定位
"scripts": "", "scripts": "",
"speed": "4000", "speed": "2000", # kHz; 空则用 cfg 内的值
"serial": "", "serial": "",
"extra": "", "extra": "",
"chip": "GD32E230F8", "chip": "GD32E230F8",
"probe": "DAPLink / CMSIS-DAP", "probe": "DAPLink / CMSIS-DAP",
"interface": "interface/cmsis-dap.cfg", "interface": "interface/cmsis-dap.cfg", # 与 cfgs/ 定制 cfg 内重复声明 driver 无害
"target": "target/gd32e23x.cfg", "target": "cfgs/openocd_gdlink_gd32e23x.cfg", # 随包分发的工程定制 cfg
"flash_size": "0x10000", "flash_size": "0x10000",
"firmware": "", "firmware": "",
"base": "0x08000000", "base": "0x08000000",
"erase": True, "erase": True,
"verify": True, "verify": True,
"reset": True, "reset": True,
"pre_reset": True,
"post_reset": True,
"pad_in": "", "pad_in": "",
"pad_out": "", "pad_out": "",
"pad_align": "1024", "pad_align": "1024",
+24 -4
View File
@@ -33,6 +33,7 @@ import argparse
import os import os
import subprocess import subprocess
import sys import sys
import time
import config import config
import openocd_runner import openocd_runner
@@ -86,7 +87,7 @@ def run_pad(args):
if pad_byte is None: if pad_byte is None:
pad_byte = 0xFF pad_byte = 0xFF
stem, ext = os.path.splitext(args.pad) stem, ext = os.path.splitext(args.pad)
out = args.output or stem + '_pad' + ext out = args.output or stem + '_update' + ext
_ensure_parent(out) _ensure_parent(out)
original = os.path.getsize(args.pad) original = os.path.getsize(args.pad)
padded = pad_bin(args.pad, out, align, pad_byte) padded = pad_bin(args.pad, out, align, pad_byte)
@@ -161,6 +162,10 @@ def main():
ap.add_argument('--no-erase', action='store_true', help='烧录前不擦除') ap.add_argument('--no-erase', action='store_true', help='烧录前不擦除')
ap.add_argument('--no-verify', action='store_true', help='烧录后不校验') ap.add_argument('--no-verify', action='store_true', help='烧录后不校验')
ap.add_argument('--no-reset', action='store_true', help='烧录后不复位运行') ap.add_argument('--no-reset', action='store_true', help='烧录后不复位运行')
ap.add_argument('--no-pre-reset', action='store_true',
help='烧录前不做硬件复位(SRST 释放 + halt)')
ap.add_argument('--no-post-reset', action='store_true',
help='烧录后复位前不释放 SRST(仅软件复位)')
ap.add_argument('--speed', type=int, help='适配器速度 kHz(默认取配置 4000)') ap.add_argument('--speed', type=int, help='适配器速度 kHz(默认取配置 4000)')
ap.add_argument('--serial', help='探针序列号(多探针时用)') ap.add_argument('--serial', help='探针序列号(多探针时用)')
ap.add_argument('--interface', help='interface cfg(默认 interface/cmsis-dap.cfg)') ap.add_argument('--interface', help='interface cfg(默认 interface/cmsis-dap.cfg)')
@@ -186,7 +191,9 @@ def main():
sys.exit(run_openocd(args, '烧录固件', openocd_runner.script_flash( sys.exit(run_openocd(args, '烧录固件', openocd_runner.script_flash(
args.flash, args.base, args.flash, args.base,
erase=not args.no_erase, verify=not args.no_verify, erase=not args.no_erase, verify=not args.no_verify,
reset=not args.no_reset))) reset=not args.no_reset,
pre_reset=not args.no_pre_reset,
post_reset=not args.no_post_reset)))
if args.read: if args.read:
sys.exit(run_openocd(args, '读取 Flash', sys.exit(run_openocd(args, '读取 Flash',
openocd_runner.script_read(args.read, args.flash_size))) openocd_runner.script_read(args.read, args.flash_size)))
@@ -194,8 +201,21 @@ def main():
sys.exit(run_openocd(args, '复位芯片', openocd_runner.script_reset())) sys.exit(run_openocd(args, '复位芯片', openocd_runner.script_reset()))
# 无操作参数 → 启动 GUI(实现在 app_gui.py) # 无操作参数 → 启动 GUI(实现在 app_gui.py)
import app_gui try:
app_gui.run_gui(args.seconds) import app_gui
app_gui.run_gui(args.seconds)
except SystemExit:
raise
except Exception:
# --noconsole 打包后异常无处可见, 落一份 crash.log 到程序目录便于远程定位
import traceback
try:
with open(os.path.join(config.BASE_DIR, 'crash.log'), 'w',
encoding='utf-8') as f:
f.write(time.strftime('%Y-%m-%d %H:%M:%S\n') + traceback.format_exc())
except OSError:
pass
raise
if __name__ == '__main__': if __name__ == '__main__':
+91 -17
View File
@@ -43,15 +43,54 @@ def _app_base():
return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _find_scripts(root):
"""在工具链根目录下找 scripts 目录, 兼容两种布局:
xpack 0.11(脚本在根: scripts/) 与 0.12(脚本在 openocd/scripts/)"""
for sub in ("openocd/scripts", "scripts"):
p = os.path.join(root, sub)
if os.path.isdir(p):
return p
return None
def bundled_toolchain(): def bundled_toolchain():
"""内置工具链路径, 不存在返回 None""" """内置工具链路径, 不存在返回 None"""
exe = os.path.join(_app_base(), "toolchain", "openocd", "bin", "openocd.exe") root = os.path.join(_app_base(), "toolchain", "openocd")
exe = os.path.join(root, "bin", "openocd.exe")
if os.path.isfile(exe): if os.path.isfile(exe):
scripts = os.path.join(_app_base(), "toolchain", "openocd", "openocd", "scripts") scripts = _find_scripts(root)
return exe, scripts if os.path.isdir(scripts) else DEFAULT_SCRIPTS return exe, (scripts or DEFAULT_SCRIPTS)
return None return None
def custom_cfg_dir():
"""用户自定义 cfg 目录: 程序目录旁 cfgs/(exe 模式在 exe 旁, 源码模式在 FlashTool/)"""
return os.path.join(_app_base(), "cfgs")
def list_custom_cfgs():
"""cfgs/ 下的 *.cfg 文件名列表(供界面下拉选择), 目录不存在返回空表"""
d = custom_cfg_dir()
if not os.path.isdir(d):
return []
return sorted(f for f in os.listdir(d)
if f.lower().endswith(".cfg") and os.path.isfile(os.path.join(d, f)))
def scan_cfgs(scripts_dir, kind):
"""扫描 scripts 目录下 kind(target/interface) 子目录里的 cfg 文件名。
返回 ['target/gd32e23x.cfg', ...]; scripts 无效/子目录不存在返回空表。
"""
if not scripts_dir or not os.path.isdir(scripts_dir):
return []
d = os.path.join(scripts_dir, kind)
if not os.path.isdir(d):
return []
return sorted(kind + "/" + f for f in os.listdir(d)
if f.lower().endswith(".cfg") and os.path.isfile(os.path.join(d, f)))
def locate_toolchain(): def locate_toolchain():
"""返回 (openocd_exe, scripts_dir, source),source ∈ 内置/本机/PATH""" """返回 (openocd_exe, scripts_dir, source),source ∈ 内置/本机/PATH"""
bundled = bundled_toolchain() bundled = bundled_toolchain()
@@ -83,11 +122,25 @@ def ensure_cfg_path(value, folder):
return folder + "/" + value return folder + "/" + value
def resolve_cfg(value, folder):
"""界面里的 cfg 引用 → OpenOCD -f 参数。
cfgs/<name>.cfg 是自定义 cfg, 转成绝对路径(不依赖 cwd);
其余(裸名/带路径/绝对路径)按原逻辑处理。
"""
value = value.strip().replace("\\", "/")
if value.startswith("cfgs/"):
p = os.path.join(custom_cfg_dir(), os.path.basename(value))
if os.path.isfile(p):
return p
return ensure_cfg_path(value, folder)
def version(exe): def version(exe):
"""openocd --version 首行(如 Open On-Chip Debugger 0.12.0), 失败返回 None""" """openocd --version 首行(如 Open On-Chip Debugger 0.12.0), 失败返回 None"""
try: try:
out = subprocess.run([exe, "--version"], capture_output=True, text=True, out = subprocess.run([exe, "--version"], capture_output=True, text=True,
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW timeout=5, creationflags=subprocess.CREATE_NO_WINDOW
if os.name == "nt" else 0) if os.name == "nt" else 0)
line = (out.stdout or out.stderr or "").strip().splitlines() line = (out.stdout or out.stderr or "").strip().splitlines()
return line[0].strip() if line else None return line[0].strip() if line else None
@@ -99,12 +152,14 @@ def version(exe):
# 烧录脚本构建(GUI 与 CLI 共用) # 烧录脚本构建(GUI 与 CLI 共用)
# ============================================================ # ============================================================
def build_cmd(openocd, scripts, interface, target, speed="", serial="", extra="", script=""): def build_cmd(openocd, scripts, interface, target, speed="", serial="", extra="", script=""):
"""拼 OpenOCD 命令行: -s 搜索目录 + interface/target cfg + -c 选项""" """拼 OpenOCD 命令行: -s 搜索目录 + interface/target cfg + -c 选项。
interface 留空时跳过(配合自带 adapter driver 的自定义 target cfg 使用)"""
cmd = [openocd.strip()] cmd = [openocd.strip()]
if scripts and scripts.strip(): if scripts and scripts.strip():
cmd += ["-s", scripts.strip()] cmd += ["-s", scripts.strip()]
cmd += ["-f", ensure_cfg_path(interface, "interface"), if interface and interface.strip():
"-f", ensure_cfg_path(target, "target")] cmd += ["-f", resolve_cfg(interface, "interface")]
cmd += ["-f", resolve_cfg(target, "target")]
if speed and speed.strip(): if speed and speed.strip():
cmd += ["-c", "adapter speed " + speed.strip()] cmd += ["-c", "adapter speed " + speed.strip()]
if serial and serial.strip() and "cmsis-dap" in interface.lower(): if serial and serial.strip() and "cmsis-dap" in interface.lower():
@@ -117,21 +172,38 @@ def build_cmd(openocd, scripts, interface, target, speed="", serial="", extra=""
def script_detect(): def script_detect():
return "init; reset halt; flash list; exit" return "init; %s; flash list; exit" % CONNECT_SEQ
def script_erase(flash_size_text): def script_erase(flash_size_text):
size = parse_int(flash_size_text) size = parse_int(flash_size_text)
if size is None or size <= 0: if size is None or size <= 0:
raise ValueError("Flash 大小无效: %s" % flash_size_text) raise ValueError("Flash 大小无效: %s" % flash_size_text)
return ("init; reset halt; flash erase_address 0x%08X 0x%X; reset halt; exit" return ("init; %s; flash erase_address 0x%08X 0x%X; reset halt; exit"
% (FLASH_BASE_DEFAULT, size)) % (CONNECT_SEQ, FLASH_BASE_DEFAULT, size))
def script_flash(firmware, base_text="", erase=True, verify=True, reset=True): # 复刻 VSCode tasks.json 的复位序列(0.11 语法, 与内置 xpack 0.11.0-3 匹配):
"""HEX/ELF 用文件内地址(base_text 留空), BIN 必须给基地址""" # init 连接(connect_assert_srst 由 cfg 声明)后显式释放 SRST 并等复位完成再 halt。
# 注意: 0.11 只认 "adapter deassert srst"; 0.12 的 "adapter srst deassert"
# 在 0.11 上是无效命令, 包 catch 会静默吞掉 → SRST 一直按住 → cannot read IDR。
# 全部操作(检测/烧录/擦除/读取/复位)都必须带本序列: 本目标必须按住复位连接,
# 否则 exam 失败(PARTNO 0x0), 后续任何访问都会 "Target not examined" 失败。
CONNECT_SEQ = "adapter deassert srst; sleep 200; catch {reset halt}; reset halt"
POST_RESET_PREFIX = "adapter deassert srst; sleep 200; "
def script_flash(firmware, base_text="", erase=True, verify=True, reset=True,
pre_reset=True, post_reset=True):
"""HEX/ELF 用文件内地址(base_text 留空), BIN 必须给基地址。
pre_reset - 烧录前硬件复位序列(SRST 释放 + halt), 复刻 VSCode 任务链
post_reset - 烧录后复位前先释放 SRST(硬件复位), 否则仅软件 reset
reset - 复位后运行(reset run); 不勾则复位后保持暂停(reset halt)
"""
ext = os.path.splitext(firmware)[1].lower() ext = os.path.splitext(firmware)[1].lower()
script = "init; reset halt; " script = "init; "
script += (CONNECT_SEQ if pre_reset else "reset halt") + "; "
addr = "" addr = ""
if ext == ".bin": if ext == ".bin":
base = parse_int(base_text) base = parse_int(base_text)
@@ -143,7 +215,8 @@ def script_flash(firmware, base_text="", erase=True, verify=True, reset=True):
else "flash write_image %s%s; ") % (quoted, addr) else "flash write_image %s%s; ") % (quoted, addr)
if verify: if verify:
script += "verify_image %s%s; " % (quoted, addr) script += "verify_image %s%s; " % (quoted, addr)
script += ("reset run" if reset else "reset halt") + "; exit" tail = "reset run" if reset else "reset halt"
script += (POST_RESET_PREFIX + tail if post_reset else tail) + "; exit"
return script return script
@@ -151,12 +224,13 @@ def script_read(out_path, flash_size_text):
size = parse_int(flash_size_text) size = parse_int(flash_size_text)
if size is None or size <= 0: if size is None or size <= 0:
raise ValueError("Flash 大小无效: %s" % flash_size_text) raise ValueError("Flash 大小无效: %s" % flash_size_text)
return "init; reset halt; dump_image %s 0x%08X 0x%X; exit" % ( return "init; %s; dump_image %s 0x%08X 0x%X; exit" % (
tcl_quote(out_path), FLASH_BASE_DEFAULT, size) CONNECT_SEQ, tcl_quote(out_path), FLASH_BASE_DEFAULT, size)
def script_reset(): def script_reset():
return "init; reset run; exit" """对齐 VSCode Reset 任务: 释放 SRST 后复位运行"""
return "init; adapter deassert srst; sleep 200; reset; exit"
# ============================================================ # ============================================================
+62 -31
View File
@@ -1,21 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
组装 DAPLink Flash 发布包: exe + 内置 OpenOCD 工具链 → 文件夹 + zip(解压即用) 组装 DAPLink Flash 发布包: 程序目录(exe + 内置 OpenOCD 工具链) → zip(解压即用)
================================================================ ================================================================
release/ 本身就是可直接使用的完整文件夹(exe + toolchain/ + 说明), release/DAPLinkFlash/ 本身就是可直接使用的完整文件夹
再原样压成 zip —— 两者内容始终一致。 (exe + _internal/ + toolchain/ + 说明), 再原样压成 zip —— 内容始终一致。
zip 解压后根目录为 DAPLinkFlash/, 与 exe 运行目录结构一致: zip 解压后根目录为 DAPLinkFlash/, 与 exe 运行目录结构一致:
DAPLinkFlash/ DAPLinkFlash/
├── DAPLinkFlash.exe 主程序(先运行 build_exe.py 打包) ├── DAPLinkFlash.exe 主程序(先运行 build_exe.py 打包)
├── _internal/ PyInstaller onedir 运行库
├── toolchain/openocd/ 内置 OpenOCD(bin + scripts, GUI 自动定位) ├── toolchain/openocd/ 内置 OpenOCD(bin + scripts, GUI 自动定位)
└── README.md 使用说明 └── README.md 使用说明
OpenOCD 来源(--openocd-src > 环境变量 OPENOCD_SRC > 自动探测): OpenOCD 来源(--openocd-src > 环境变量 OPENOCD_SRC > 自动探测):
1. FlashTool/toolchain/openocd (随仓库自带, 若已 vendor) 1. FlashTool/toolchain/openocd (随仓库自带, 若已 vendor)
2. D:/toolchain/openocd (开发机本机安装) 2. D:/toolchain/xpack-openocd-0.11.0-3
目录布局: <src>/bin/openocd.exe + <src>/openocd/scripts/ 3. D:/toolchain/openocd
整个工具链目录原样打包(不挑文件); scripts 布局自动兼容
xpack 0.11(scripts/) 与 0.12(openocd/scripts/)两种。
程序目录还会带一个空的 cfgs/ 目录(放自定义 OpenOCD cfg, 界面下拉可选)。
用法: 用法:
python package_release.py 版本号自动取最近 git tag(DAPLinkFlash-v*) python package_release.py 版本号自动取最近 git tag(DAPLinkFlash-v*)
@@ -35,6 +39,7 @@ SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC) BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release') RELEASE = os.path.join(BASE, 'release')
APP_NAME = 'DAPLinkFlash' APP_NAME = 'DAPLinkFlash'
APP_DIR = os.path.join(RELEASE, APP_NAME) # onedir 输出目录 = 解压后的程序文件夹
def detect_version(): def detect_version():
@@ -50,37 +55,52 @@ def detect_version():
def resolve_openocd_src(explicit): def resolve_openocd_src(explicit):
"""定位 OpenOCD 工具链源目录, 校验布局并返回路径""" """定位 OpenOCD 工具链源目录(bin/openocd.exe 必在, scripts 布局兼容两种)"""
candidates = [explicit, os.environ.get('OPENOCD_SRC'), candidates = [explicit, os.environ.get('OPENOCD_SRC'),
os.path.join(BASE, 'toolchain', 'openocd'), os.path.join(BASE, 'toolchain', 'openocd'),
r'D:/toolchain/xpack-openocd-0.11.0-3',
r'D:/toolchain/openocd'] r'D:/toolchain/openocd']
for cand in candidates: for cand in candidates:
if not cand: if not cand:
continue continue
exe = os.path.join(cand, 'bin', 'openocd.exe') if not os.path.isfile(os.path.join(cand, 'bin', 'openocd.exe')):
continue
scripts = os.path.join(cand, 'openocd', 'scripts') scripts = os.path.join(cand, 'openocd', 'scripts')
if os.path.isfile(exe) and os.path.isdir(scripts): if not os.path.isdir(scripts):
scripts = os.path.join(cand, 'scripts')
if os.path.isdir(scripts):
print(f'OpenOCD 工具链来源: {os.path.abspath(cand)}') print(f'OpenOCD 工具链来源: {os.path.abspath(cand)}')
return os.path.abspath(cand) return os.path.abspath(cand)
sys.exit('未找到 OpenOCD 工具链(需 bin/openocd.exe + openocd/scripts/):\n' sys.exit('未找到 OpenOCD 工具链(需 bin/openocd.exe + scripts/):\n'
' python package_release.py <版本> --openocd-src <toolchain/openocd 目录>\n' ' python package_release.py <版本> --openocd-src <openocd 根目录>\n'
' 或设置环境变量 OPENOCD_SRC') ' 或设置环境变量 OPENOCD_SRC')
def copy_toolchain(openocd_src): def copy_toolchain(openocd_src):
"""复制 bin(openocd.exe + 运行 DLL)与 openocd/scripts 到 release/toolchain/""" """整个工具链目录原样复制到程序目录 toolchain/openocd(不挑文件)"""
dst_root = os.path.join(RELEASE, 'toolchain', 'openocd') dst_root = os.path.join(APP_DIR, 'toolchain', 'openocd')
if os.path.isdir(dst_root): if os.path.isdir(dst_root):
shutil.rmtree(dst_root) shutil.rmtree(dst_root)
count = 0 shutil.copytree(openocd_src, dst_root)
for sub in ('bin', os.path.join('openocd', 'scripts')): return sum(len(fs) for _r, _d, fs in os.walk(dst_root))
src = os.path.join(openocd_src, sub)
dst = os.path.join(dst_root, sub)
os.makedirs(os.path.dirname(dst), exist_ok=True) CFGS_README = (
shutil.copytree(src, dst) "把自定义 OpenOCD cfg 文件放到本目录(任意文件名.cfg)。\n"
for _root, _dirs, files in os.walk(dst): "DAPLink Flash 的 \"Target 配置\"/\"Interface 配置\" 下拉框会自动列出\n"
count += len(files) "cfgs/<文件名>, 选中即用; 选择会被记住。\n"
return count )
def ensure_cfgs_dir():
"""程序目录建 cfgs/ + 说明文件"""
d = os.path.join(APP_DIR, 'cfgs')
os.makedirs(d, exist_ok=True)
readme = os.path.join(d, '说明-把cfg放这里.txt')
if not os.path.exists(readme):
with open(readme, 'w', encoding='utf-8') as f:
f.write(CFGS_README)
return d
def main(): def main():
@@ -92,31 +112,42 @@ def main():
args = ap.parse_args() args = ap.parse_args()
version = (args.version or detect_version()).lstrip('v') version = (args.version or detect_version()).lstrip('v')
exe = os.path.join(RELEASE, APP_NAME + '.exe') exe = os.path.join(APP_DIR, APP_NAME + '.exe')
readme = os.path.join(BASE, 'README.md') readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe): if not os.path.exists(exe):
sys.exit(f'缺少 release/{APP_NAME}.exe, 先运行: python build_exe.py') sys.exit(f'缺少 {APP_DIR}/{APP_NAME}.exe, 先运行: python build_exe.py')
if not os.path.exists(readme): if not os.path.exists(readme):
sys.exit(f'缺少 {readme}') sys.exit(f'缺少 {readme}')
openocd_src = resolve_openocd_src(args.openocd_src) openocd_src = resolve_openocd_src(args.openocd_src)
n_files = copy_toolchain(openocd_src) n_files = copy_toolchain(openocd_src)
shutil.copy2(readme, os.path.join(RELEASE, 'README.md')) ensure_cfgs_dir()
shutil.copy2(readme, os.path.join(APP_DIR, 'README.md'))
# 用户自定义 cfg 目录(存在才打包): 界面 Target/Interface 下拉可选用
src_cfgs = os.path.join(BASE, 'cfgs')
n_cfgs = 0
if os.path.isdir(src_cfgs):
dst_cfgs = os.path.join(APP_DIR, 'cfgs')
if os.path.isdir(dst_cfgs):
shutil.rmtree(dst_cfgs)
shutil.copytree(src_cfgs, dst_cfgs)
n_cfgs = sum(len(fs) for _r, _d, fs in os.walk(dst_cfgs))
print(f'自定义 cfg: 复制 {n_cfgs} 个到发布包 cfgs/')
zip_name = f'{APP_NAME}-v{version}-win64.zip' zip_name = f'{APP_NAME}-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name) zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'{APP_NAME}/{APP_NAME}.exe') for root, _dirs, files in os.walk(APP_DIR):
z.write(os.path.join(RELEASE, 'README.md'), f'{APP_NAME}/README.md')
tool_root = os.path.join(RELEASE, 'toolchain')
for root, _dirs, files in os.walk(tool_root):
for f in files: for f in files:
if f == 'flash_gui_settings.json': # 本机运行配置不随包分发
continue
full = os.path.join(root, f) full = os.path.join(root, f)
z.write(full, os.path.join(APP_NAME, os.path.relpath(full, RELEASE))) z.write(full, os.path.join(APP_NAME, os.path.relpath(full, APP_DIR)))
exe_mb = os.path.getsize(exe) / 1024 / 1024
zip_mb = os.path.getsize(zip_path) / 1024 / 1024 zip_mb = os.path.getsize(zip_path) / 1024 / 1024
print(f'发布文件夹: {RELEASE} (exe {exe_mb:.1f} MB + 内置工具链 {n_files} 个文件, 平铺即用)') print(f'发布文件夹: {APP_DIR} (exe + _internal + 内置工具链 {n_files} 个文件'
+ (f' + 自定义 cfg {n_cfgs} 个' if n_cfgs else '')
+ ', 平铺即用)')
print(f'发布包: {zip_path} ({zip_mb:.1f} MB)') print(f'发布包: {zip_path} ({zip_mb:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name) print('下一步上传: python publish_gitea.py', zip_name)
+33 -1
View File
@@ -58,11 +58,13 @@ def git_remote_info():
class Api: class Api:
def __init__(self, base, owner, repo, token, insecure): def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}' self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.token = token
self.headers = {'Authorization': f'token {token}'} self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4): def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试""" """发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试,
并改用 curl -k 兜底(urllib 的握手在该实例上可能持续失败)"""
last = None last = None
for attempt in range(retries): for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method, req = urllib.request.Request(self.api + path, data=data, method=method,
@@ -75,9 +77,39 @@ class Api:
return e.code, json.loads(e.read() or b'{}') return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e: except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e last = e
try:
status, body = self._curl(method, path, data, headers)
if status > 0:
return status, body
except Exception as ce:
last = ce
time.sleep(1.0 + attempt) time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}') raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def _curl(self, method, path, data=None, headers=None):
"""curl 兜底: 返回 (status, json/None); 解析失败返回 (0, None) 交回上层重试"""
cmd = ['curl', '-k', '-s', '-X', method, '-w', '\n%{http_code}']
if data is not None:
cmd += ['--data-binary', '@-']
for key, value in self.headers.items():
cmd += ['-H', f'{key}: {value}']
for key, value in (headers or {}).items():
cmd += ['-H', f'{key}: {value}']
cmd.append(self.api + path)
proc = subprocess.run(cmd, input=data, capture_output=True, timeout=600)
out = proc.stdout.decode('utf-8', 'replace')
body, _, code = out.rpartition('\n')
try:
status = int(code.strip())
except ValueError:
return 0, None
if not body.strip():
return status, None
try:
return status, json.loads(body)
except ValueError:
return status, {'raw': body}
def get_json(self, path): def get_json(self, path):
status, data = self.request('GET', path) status, data = self.request('GET', path)
return data if status == 200 else None return data if status == 200 else None
+3 -2
View File
@@ -31,8 +31,9 @@
日常烧录优先使用图形界面工具,不需要打开 VSCode,也不需要记忆 OpenOCD 命令。 日常烧录优先使用图形界面工具,不需要打开 VSCode,也不需要记忆 OpenOCD 命令。
- **免安装版**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases) - **免安装版**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe` 即可, 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压后进入 `DAPLinkFlash` 文件夹
**包内自带 OpenOCD 工具链**(`toolchain/openocd/`),目标机器无需任何环境。 双击 `DAPLinkFlash.exe` 即可,**包内自带 OpenOCD 工具链**
(`toolchain/openocd/`),目标机器无需任何环境。
- **源码运行**:`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat` - **源码运行**:`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat`
(需 Python 3.10+ 与 `pip install customtkinter`;OpenOCD 默认找 `D:/toolchain`)。 (需 Python 3.10+ 与 `pip install customtkinter`;OpenOCD 默认找 `D:/toolchain`)。