refactor(flash-gui): 对齐 DLPilot 重构 GUI 并内置 OpenOCD 发布链

This commit is contained in:
2026-09-14 18:54:20 +08:00
parent e8eb194fb8
commit 4c6cdc3bf8
16 changed files with 1896 additions and 933 deletions
+5
View File
@@ -21,4 +21,9 @@ venv/
!gd32e230f8_bootloader_hulk.bin
# DAPLink Flash GUI local config
FlashTool/config/
FlashTool/flash_gui_settings.json
# DAPLink Flash 构建与发布产物 + 内置工具链(不入库)
FlashTool/release/
FlashTool/src/build/
FlashTool/toolchain/
+138 -39
View File
@@ -1,55 +1,154 @@
# DAPLink Flash GUI
# DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接
一个面向 GD32 日常烧录的 Windows 小工具,不依赖 VSCode,也不用记 OpenOCD 命令。
双击 `start_flash_gui.bat` 即可启动。
> GD32E230 系列的桌面烧录/固件处理工具:**OpenOCD 烧录**DAPLink / GD-Link /
> ST-Link / J-Link+ **补齐 Pad**Y-Modem 页对齐)+ **拼接 Merge**
> Bootloader + APP + 有效标志 → 整片镜像)。
>
> - **只想用工具**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
> 下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe`
> Windows 10/11 x64,免安装、无需 Python**内置 OpenOCD 工具链**)。
> - **要改代码/重新打包**:见 §5、§6。
>
> 界面与构建方式对齐 DLPilot 项目(CustomTkinter 深色双栏仪表盘 +
> PyInstaller onefile + Gitea Release 发布)。更新:2026-09-14
## 功能
## 1. 功能总览
### 烧录页
| 功能 | 说明 |
|---|---|
| OpenOCD 烧录 | 检测设备 / 烧录固件 / 整片擦除 / 读取 Flash / 复位芯片 / 停止,OpenOCD 输出实时进日志区 |
| 固件格式 | BIN / HEX / ELF——HEX/ELF 自动用文件内地址,仅 BIN 需填基地址(完整镜像 `0x08000000`,单独 APP `0x08002000` |
| 芯片型号 | GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小与 target 配置 |
| 烧录器 | DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP(TCP)、Custom;支持适配器速度与探针序列号 |
| 补齐 Pad | BIN 尾部补 `0xFF` 到 1K/2K/4K 页边界;bootloader 的 Y-Modem 只按 1K 整包写 Flash,最后一包不满必须先补齐 |
| 拼接 Merge | Bootloader + APP + 有效标志(`0xEEEEEEEE` @`0x0800FFFC`) → 整片 Flash 镜像,逻辑与 `merge_bin.py` 一致 |
| 内置工具链 | 发布包自带 OpenOCD(`toolchain/openocd/`),GUI 自动定位,免安装免配置 |
| 命令行模式 | 全部功能可脚本化调用(`--flash / --pad / --merge ...`,见 §4 |
| 外观 | 跟随系统深/浅色,顶栏可切换 Dark / Light / System |
- 芯片型号:GD32E230F4 / F6 / F8 / C8,自动带出 Flash 大小和 OpenOCD target 配置
- 烧录器型号:DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link、CMSIS-DAP (TCP)
- OpenOCD 路径和 scripts 目录可配置,默认使用 `D:/toolchain` 下同一套工具
- BIN 文件可指定烧录基地址,HEX/ELF 使用文件内地址
- 常用烧录参数:适配器速度、探针序列号、烧录前擦除、烧录后校验、复位运行
- 操作:检测设备、整片擦除、烧录固件、读取 Flash、复位芯片、停止当前操作
## 2. 下载与安装
### 固件工具页
1. 打开 [Releases 页面](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
取最新的 `DAPLinkFlash-vX.Y.Z-win64.zip`
2. 解压到**任意可写目录**(配置 `flash_gui_settings.json` 写在 exe 旁边);
3. 双击 `DAPLinkFlash.exe`。首次启动比脚本慢一两秒(onefile 解压),属正常现象。
- 补齐:把 BIN 填充到 1K(或 2K/4K)整数倍,默认填充 `0xFF`
因为 bootloader 的 Y-Modem 只按 1K 整包写 Flash,最后一包不满 1K 时
必须先在发送侧补齐。
- 拼接:把 Bootloader + APP + 有效标志合成一个完整 Flash 镜像,
逻辑与 `gd32e230f8_firmware_merge_tool/merge_bin.py` 一致。
包内包含:
## 典型布局
```text
0x08000000 Bootloader 8KB
0x08002000 Application
0x0800FFFC 有效标志 0xEEEEEEEE
0x08010000 Flash 结束 (64KB)
```
DAPLinkFlash/
├── DAPLinkFlash.exe 主程序(单文件自包含)
├── toolchain/openocd/ 内置 OpenOCDGUI 自动定位)
│ ├── bin/openocd.exe + libftdi1.dll / libusb-1.0.dll
│ └── openocd/scripts/ interface / target 配置脚本
└── README.md 本说明
```
完整镜像烧录基地址为 `0x08000000`;只烧 APP 时基地址为 `0x08002000`
## 3. GUI 使用
## 使用
主窗口为双栏仪表盘:
1. 连接 DAPLink 和目标板 SWDSWDIO / SWCLK / GND)。
2. 双击 `start_flash_gui.bat`,或执行:
```bat
python daplink_flash_gui.py
```
左列 右列
┌ 探针与工具链 [自动定位] ┐ ┌ 补齐 (Pad) — Y-Modem 对齐 ┐
├ 芯片与固件(型号/固件/基地址)┤ ├ 拼接 (Merge) — BL+APP+标志 ┤
└ 操作: 检测/烧录/擦除/读取/复位/停止 ┘ └ 运行日志 — OpenOCD 输出 ┘
```
3. 选择芯片型号、烧录器型号、固件文件,点击“烧录固件”。
1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上
"自动定位"可重新探测;也可手动浏览指定 `openocd.exe` 与 scripts 目录;
2. **选芯片与烧录器**:芯片型号下拉自动带出 Flash 大小和 target;烧录器下拉
自动带出 interface cfg;多探针时填序列号;
3. **选固件**:点"浏览"选 BIN/HEX/ELFBIN 确认基地址(完整镜像 `0x08000000`
单独 APP `0x08002000`);
4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区;
5. **固件工具**(右列):
- 补齐:选输入 BIN 后输出名自动带出(`*_pad.bin`),对齐默认 1K,点"执行补齐"
- 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`),地址按典型布局
预填,点"执行拼接"。
运行参数会自动保存到 `FlashTool/config/flash_gui_<主机名>.json`。首次打开会自动创建
`config` 目录并生成当前电脑的配置;下次打开检测到就加载,没有就重新创建
每台电脑使用独立文件名,同步仓库时不会互相覆盖。
典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC`
有效标志 `0xEEEEEEEE` | `0x08010000` Flash 结束 (64KB)
## 环境要求
配置(全部输入框的值)自动保存在 exe 旁的 `flash_gui_settings.json`,下次启动
自动恢复;旧版 `config/flash_gui_<主机名>.json` 在首次启动时自动迁移。
- Windows
- Python 3.10+(标准安装自带 tkinter,无需第三方库)
- OpenOCD(默认 `D:/toolchain/openocd/bin/openocd.exe`,可在界面中修改)
## 4. 命令行模式
exe 版无控制台,脚本化请用源码方式(或 `build_exe.py --debug` 打的带控制台变体):
```
python src/flash_gui.py 启动 GUI
python src/flash_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
python src/flash_gui.py --version 打印 OpenOCD 版本(验证工具链定位)
python src/flash_gui.py --detect 检测烧录器与目标芯片
python src/flash_gui.py --flash app.bin 烧录(BIN 默认基地址 0x08000000)
python src/flash_gui.py --flash app.bin --base 0x08002000 --no-reset
python src/flash_gui.py --erase 整片擦除
python src/flash_gui.py --read dump.bin 读取整片 Flash
python src/flash_gui.py --reset 复位运行
python src/flash_gui.py --pad app.bin 补齐到 1K → app_pad.bin
python src/flash_gui.py --merge bl.bin app.bin 拼接 → app_BL.bin
--align N / --pad-byte 0xFF / -o OUT pad 参数
--app-addr / --flag-addr / --flash-size / --flag-value merge 参数
--speed 4000 / --serial SN / --interface cfg / --target cfg
--openocd PATH / --scripts DIR 手动指定工具链(默认自动定位)
```
退出码:OpenOCD 操作非 0 即失败,可直接用于 CI/脚本串联。
## 5. 目录结构与从源码运行
```
FlashTool/
├── src/ # 源代码(人工维护区)
│ ├── flash_gui.py # 入口与命令行模式(argparse 分发)
│ ├── app_gui.py # CustomTkinter 主界面(双栏仪表盘)
│ ├── openocd_runner.py # 工具链定位 / OpenOCD 命令构建与子进程
│ ├── fw_image.py # 补齐(pad)/拼接(merge) + 芯片参数表
│ ├── config.py # 运行目录与 flash_gui_settings.json 配置
│ ├── build_exe.py # PyInstaller 打包 → release/
│ ├── package_release.py # 组装发布 zipexe + 内置 OpenOCD 工具链)
│ ├── publish_gitea.py # 上传到 Gitea Release
│ └── logos/ # 图标源(make_ico.py + png/ico
├── release/ # 构建与发布产物(机器生成区, 不手改/不入库)
│ ├── DAPLinkFlash.exe # 打包单文件版
│ ├── toolchain/openocd/ # package_release.py 复制的内置工具链
│ └── *.zip # 发布包(上传 Gitea Release
├── toolchain/openocd/ # (可选) vendor 进仓库的工具链, 不入库
├── start_flash_gui.bat # 源码模式双击启动器
└── README.md # 本说明
```
从源码运行:`python src/flash_gui.py` 或双击 `start_flash_gui.bat`
需 Python ≥3.10 + customtkinter`py -m pip install customtkinter`);
OpenOCD 用本机安装(默认找 `D:/toolchain/openocd`)或先跑一次
`package_release.py` 把工具链放进 `release/toolchain/`
## 6. 构建与发布
```powershell
cd src
python logos/make_ico.py # (可选) 改图标后重新生成 png/ico, 需 Pillow
python build_exe.py # 打包 → ../release/DAPLinkFlash.exe(约 19MB;
# 自动装 PyInstaller, CTk 主题数据自动收集)
python build_exe.py --debug # 额外出一个带控制台的调试变体(命令行模式可见输出)
python package_release.py 0.1.0 # 组装 release/DAPLinkFlash-v0.1.0-win64.zip
# 含内置 OpenOCD 工具链(来源见脚本说明)
python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip --insecure
# 上传 Gitea Release(需环境变量 GITEA_TOKEN)
```
版本号默认从最近的 `DAPLinkFlash-v*` git tag 推断,无 tag 时手动指定。
## 7. 注意事项 / FAQ
- **"找不到 OpenOCD"**:点"自动定位";发布包用户确认解压后 `toolchain/openocd/`
目录和 exe 在一起;
- **烧录失败排查**:①"检测设备"先验链路;② 探针序列号留空可排除序列号问题;
③ 速度过高可降到 1000 kHz;④ DAPLink 固件过旧换 GD-Link 固件试试;
- **exe 放只读目录**`flash_gui_settings.json` 写在 exe 旁边,只读目录存不了配置;
- **重新打包后图标没变**:Windows 图标缓存,`ie4uinit -show` 或改个名即可;
- **残留进程**:修改源码重新打包前先结束残留的 DAPLinkFlash 进程
`build_exe.py` 会自动 taskkill);
- **配置互不影响**:exe 与源码脚本各自按所在目录保存 `flash_gui_settings.json`
-877
View File
@@ -1,877 +0,0 @@
# -*- coding: utf-8 -*-
"""
DAPLink Flash GUI
A small Windows desktop tool for flashing GD32 firmware with OpenOCD over a
CMSIS-DAP / DAPLink adapter. It also contains the two firmware preparation
helpers used by the project:
* pad - pad a binary to a fixed alignment (1024 bytes for the 1K Y-Modem
packets used by the GD32E230 bootloader)
* merge - combine bootloader + application + valid-app flag into one full
flash image for initial flashing
Only the Python standard library is required (tkinter is included with the
standard Windows Python installer).
"""
import json
import os
import queue
import re
import socket
import struct
import subprocess
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
APP_TITLE = "DAPLink Flash GUI"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_DIR = os.path.join(SCRIPT_DIR, "config")
CONFIG_NAME = "flash_gui_%s.json" % re.sub(r"[^A-Za-z0-9_.-]", "_", socket.gethostname())
CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_NAME)
DEFAULT_OPENOCD = r"D:/toolchain/openocd/bin/openocd.exe"
DEFAULT_SCRIPTS = r"D:/toolchain/openocd/openocd/scripts"
FLASH_BASE_DEFAULT = 0x08000000
APP_ADDR_DEFAULT = 0x08002000
FLAG_ADDR_DEFAULT = 0x0800FFFC
FLAG_VALUE_DEFAULT = 0xEEEEEEEE
CHIP_PROFILES = {
"GD32E230F4": {"target": "target/gd32e23x.cfg", "flash": 16 * 1024, "ram": 4 * 1024},
"GD32E230F6": {"target": "target/gd32e23x.cfg", "flash": 32 * 1024, "ram": 4 * 1024},
"GD32E230F8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
"GD32E230C8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
}
PROBE_PROFILES = {
"DAPLink / CMSIS-DAP": "interface/cmsis-dap.cfg",
"GD-Link (CMSIS-DAP)": "interface/cmsis-dap.cfg",
"ST-Link": "interface/stlink.cfg",
"J-Link": "interface/jlink.cfg",
"CMSIS-DAP (TCP)": "interface/cmsis-dap-tcp.cfg",
"Custom": "",
}
def _strip_ansi(text):
return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
def _parse_int(text):
s = text.strip().lower()
if not s:
return None
mult = 1
if s.endswith("k"):
mult = 1024
s = s[:-1]
elif s.endswith("m"):
mult = 1024 * 1024
s = s[:-1]
try:
if s.startswith("0x"):
return int(s, 16) * mult
return int(s, 10) * mult
except ValueError:
return None
def _tcl_quote(path):
return '"' + path.replace("\\", "/").replace('"', '\\"') + '"'
def _ensure_cfg_path(value, folder):
value = value.strip()
if not value:
return value
if "/" in value or "\\" in value or value.lower().endswith(".cfg") is False:
return value
return folder + "/" + value
def pad_bin(src_path, dst_path, align, pad_byte=0xFF):
"""Pad a binary file to a multiple of `align` bytes with `pad_byte`."""
with open(src_path, "rb") as f:
data = f.read()
if align <= 0:
raise ValueError("align must be positive")
remainder = len(data) % align
if remainder:
data += bytes([pad_byte & 0xFF]) * (align - remainder)
with open(dst_path, "wb") as f:
f.write(data)
return len(data)
def merge_firmware(bootloader_path, app_path, output_path, app_addr, flag_addr,
flash_size, flag_value=FLAG_VALUE_DEFAULT):
"""Merge bootloader + app + valid-app flag into one flash image."""
if app_addr < FLASH_BASE_DEFAULT or app_addr >= FLASH_BASE_DEFAULT + flash_size:
raise ValueError("APP address is outside the flash range")
if flag_addr < FLASH_BASE_DEFAULT or flag_addr + 4 > FLASH_BASE_DEFAULT + flash_size:
raise ValueError("flag address is outside the flash range")
with open(bootloader_path, "rb") as f:
bootloader = f.read()
with open(app_path, "rb") as f:
app = f.read()
app_offset = app_addr - FLASH_BASE_DEFAULT
flag_offset = flag_addr - FLASH_BASE_DEFAULT
if len(bootloader) > app_offset:
raise ValueError("bootloader (%d bytes) exceeds APP offset (%d bytes)"
% (len(bootloader), app_offset))
if app_offset + len(app) > flag_offset:
raise ValueError("APP end address exceeds the flag address")
image = bytearray(b"\xFF" * flash_size)
image[0:len(bootloader)] = bootloader
image[app_offset:app_offset + len(app)] = app
struct.pack_into("<I", image, flag_offset, flag_value & 0xFFFFFFFF)
with open(output_path, "wb") as f:
f.write(image)
return {
"bootloader_size": len(bootloader),
"app_size": len(app),
"output_size": len(image),
"app_addr": app_addr,
"flag_addr": flag_addr,
}
class FlashGui:
def __init__(self):
self.root = tk.Tk()
self.root.title(APP_TITLE)
self.root.geometry("980x780")
self.root.minsize(880, 660)
self._configure_style()
self.queue = queue.Queue()
self.proc = None
self.action_buttons = []
self._create_vars()
self._build_ui()
self._ensure_config()
self._load_config()
self._on_chip_change()
self._on_probe_change()
self.root.after(100, self._poll_queue)
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _configure_style(self):
try:
self.style = ttk.Style(self.root)
if "vista" in self.style.theme_names():
self.style.theme_use("vista")
style_options = {
"Title.TLabel": {
"font": ("Microsoft YaHei UI", 13, "bold"),
},
"TLabel": {
"font": ("Microsoft YaHei UI", 9),
},
"TButton": {
"font": ("Microsoft YaHei UI", 9),
"padding": (12, 6),
},
"TLabelframe.Label": {
"font": ("Microsoft YaHei UI", 9, "bold"),
},
"TNotebook.Tab": {
"font": ("Microsoft YaHei UI", 9),
"padding": (14, 6),
},
}
for name, opts in style_options.items():
try:
self.style.configure(name, **opts)
except tk.TclError:
pass
except tk.TclError:
pass
def _create_vars(self):
self.v_openocd = tk.StringVar(value=DEFAULT_OPENOCD)
self.v_scripts = tk.StringVar(value=DEFAULT_SCRIPTS)
self.v_speed = tk.StringVar(value="4000")
self.v_serial = tk.StringVar(value="")
self.v_extra = tk.StringVar(value="")
self.v_chip = tk.StringVar(value="GD32E230F8")
self.v_probe = tk.StringVar(value="DAPLink / CMSIS-DAP")
self.v_interface = tk.StringVar(value="interface/cmsis-dap.cfg")
self.v_target = tk.StringVar(value="target/gd32e23x.cfg")
self.v_flash_size = tk.StringVar(value="0x10000")
self.v_firmware = tk.StringVar(value="")
self.v_base = tk.StringVar(value="0x08000000")
self.v_erase = tk.BooleanVar(value=True)
self.v_verify = tk.BooleanVar(value=True)
self.v_reset = tk.BooleanVar(value=True)
self.v_pad_in = tk.StringVar(value="")
self.v_pad_out = tk.StringVar(value="")
self.v_pad_align = tk.StringVar(value="1024")
self.v_pad_byte = tk.StringVar(value="0xFF")
self.v_pad_result = tk.StringVar(value="")
self.v_bl = tk.StringVar(value="")
self.v_app = tk.StringVar(value="")
self.v_merge_out = tk.StringVar(value="")
self.v_app_addr = tk.StringVar(value="0x08002000")
self.v_flag_addr = tk.StringVar(value="0x0800FFFC")
self.v_merge_flash = tk.StringVar(value="0x10000")
self.v_flag_value = tk.StringVar(value="0xEEEEEEEE")
self.v_merge_result = tk.StringVar(value="")
self.v_status = tk.StringVar(value="就绪")
def _build_ui(self):
root = self.root
root.grid_rowconfigure(2, weight=1)
root.grid_columnconfigure(0, weight=1)
top = ttk.Frame(root, padding=(14, 8, 14, 4))
top.grid(row=0, column=0, sticky="ew")
ttk.Label(top, text=APP_TITLE, style="Title.TLabel").pack(side="left")
ttk.Label(top, text="OpenOCD 烧录与固件工具", foreground="#666666").pack(
side="left", padx=(6, 0), pady=(3, 0))
ttk.Button(top, text="清空日志", command=self._clear_log).pack(
side="right", padx=(0, 2))
ttk.Separator(root, orient="horizontal").grid(
row=1, column=0, sticky="ew", padx=10, pady=(2, 0))
self.notebook = ttk.Notebook(root)
self.notebook.grid(row=2, column=0, sticky="nsew", padx=10, pady=(4, 4))
self._build_flash_tab()
self._build_tools_tab()
self.log_text = scrolledtext.ScrolledText(
root, height=7, state="disabled", font=("Consolas", 9),
relief="solid", borderwidth=1)
self.log_text.grid(row=3, column=0, sticky="sew", padx=10, pady=(2, 4))
status = ttk.Frame(root, padding=(12, 0, 12, 6))
status.grid(row=4, column=0, sticky="ew")
ttk.Label(status, textvariable=self.v_status).pack(side="left")
def _build_flash_tab(self):
tab = ttk.Frame(self.notebook, padding=8)
self.notebook.add(tab, text="烧录")
tab.grid_columnconfigure(0, weight=1)
# Tool / probe paths
path_frame = ttk.LabelFrame(tab, text="工具与探针", padding=8)
path_frame.grid(row=0, column=0, sticky="ew")
path_frame.grid_columnconfigure(0, minsize=132)
path_frame.grid_columnconfigure(1, weight=1)
path_frame.grid_columnconfigure(2, minsize=82)
self._add_row(path_frame, 0, "OpenOCD 路径", self.v_openocd, self._browse_openocd)
self._add_row(path_frame, 1, "OpenOCD 脚本目录", self.v_scripts, self._browse_scripts)
self._add_pair_row(path_frame, 2, "适配器速度 (kHz)", self.v_speed,
"探针序列号 (可选)", self.v_serial)
probe_combo, _ = self._add_pair_row(
path_frame, 3, "烧录器型号", self.v_probe, "Interface 配置", self.v_interface,
widget1_cls=ttk.Combobox,
widget1_kwargs={"state": "readonly", "values": list(PROBE_PROFILES.keys())})
probe_combo.bind("<<ComboboxSelected>>", self._on_probe_change)
self.probe_combo = probe_combo
self._add_pair_row(path_frame, 4, "Target 配置", self.v_target,
"额外初始化命令", self.v_extra)
# Target chip / firmware parameters
chip_frame = ttk.LabelFrame(tab, text="芯片与固件参数", padding=8)
chip_frame.grid(row=1, column=0, sticky="ew", pady=(6, 0))
chip_frame.grid_columnconfigure(0, minsize=132)
chip_frame.grid_columnconfigure(1, weight=1)
chip_frame.grid_columnconfigure(2, minsize=82)
chip_combo, _ = self._add_pair_row(
chip_frame, 0, "芯片型号", self.v_chip, "Flash 大小", self.v_flash_size,
widget1_cls=ttk.Combobox,
widget1_kwargs={"state": "readonly", "values": list(CHIP_PROFILES.keys())})
chip_combo.bind("<<ComboboxSelected>>", self._on_chip_change)
self.chip_combo = chip_combo
self._add_row(chip_frame, 1, "固件文件", self.v_firmware, self._browse_firmware)
self._add_row(chip_frame, 2, "烧录基地址 (BIN)", self.v_base)
ttk.Label(chip_frame, text="HEX/ELF 自动使用文件内地址,仅 BIN 需要填写基地址",
foreground="#666666").grid(row=3, column=0, columnspan=3,
sticky="w", padx=0, pady=(0, 4))
opt_frame = ttk.Frame(chip_frame)
opt_frame.grid(row=4, column=0, columnspan=3, sticky="w", pady=(2, 0))
ttk.Checkbutton(opt_frame, text="烧录前擦除", variable=self.v_erase).pack(
side="left", padx=(0, 16))
ttk.Checkbutton(opt_frame, text="烧录后校验", variable=self.v_verify).pack(
side="left", padx=(0, 16))
ttk.Checkbutton(opt_frame, text="烧录后复位运行", variable=self.v_reset).pack(
side="left")
# Actions
act_frame = ttk.LabelFrame(tab, text="操作", padding=8)
act_frame.grid(row=2, column=0, sticky="ew", pady=(6, 0))
for i in range(6):
act_frame.grid_columnconfigure(i, weight=1)
self.action_buttons = [
self._make_action_button(act_frame, 0, "检测设备", self._on_detect),
self._make_action_button(act_frame, 1, "整片擦除", self._on_erase),
self._make_action_button(act_frame, 2, "烧录固件", self._on_flash),
self._make_action_button(act_frame, 3, "读取 Flash", self._on_read),
self._make_action_button(act_frame, 4, "复位芯片", self._on_reset),
self._make_action_button(act_frame, 5, "停止", self._on_stop),
]
note = ("提示:BIN 文件需要填写基地址;完整镜像(bootloader+app)基地址为 0x08000000"
"单独烧录 APP 时基地址为 0x08002000。")
ttk.Label(tab, text=note, foreground="#666666").grid(
row=3, column=0, sticky="w", pady=(6, 0))
def _build_tools_tab(self):
tab = ttk.Frame(self.notebook, padding=12)
self.notebook.add(tab, text="固件工具")
tab.grid_columnconfigure(0, weight=1)
pad_frame = ttk.LabelFrame(tab, text="补齐 (Pad) - 对齐到 1K,适配 Y-Modem 整包", padding=10)
pad_frame.grid(row=0, column=0, sticky="ew")
pad_frame.grid_columnconfigure(0, minsize=132)
pad_frame.grid_columnconfigure(1, weight=1)
pad_frame.grid_columnconfigure(2, minsize=82)
self._add_row(pad_frame, 0, "输入 BIN", self.v_pad_in, self._browse_pad_in)
self._add_row(pad_frame, 1, "输出 BIN", self.v_pad_out, self._browse_pad_out)
pad_opt = ttk.Frame(pad_frame)
pad_opt.grid(row=2, column=0, columnspan=3, sticky="ew", pady=(6, 0))
ttk.Label(pad_opt, text="对齐字节").pack(side="left", padx=(0, 6))
ttk.Combobox(pad_opt, textvariable=self.v_pad_align, values=["1024", "2048", "4096"],
width=8, state="readonly").pack(side="left", padx=(0, 16))
ttk.Label(pad_opt, text="填充字节").pack(side="left", padx=(0, 6))
ttk.Entry(pad_opt, textvariable=self.v_pad_byte, width=8).pack(
side="left", padx=(0, 16))
btn = ttk.Button(pad_opt, text="执行补齐", command=self._on_pad)
btn.pack(side="left")
self.action_buttons.append(btn)
ttk.Label(pad_frame, textvariable=self.v_pad_result, foreground="#1a6fb5").grid(
row=3, column=0, columnspan=3, sticky="w", pady=(6, 0))
merge_frame = ttk.LabelFrame(tab, text="拼接 (Merge) - Bootloader + APP + 有效标志", padding=10)
merge_frame.grid(row=1, column=0, sticky="ew", pady=(10, 0))
merge_frame.grid_columnconfigure(0, minsize=132)
merge_frame.grid_columnconfigure(1, weight=1)
merge_frame.grid_columnconfigure(2, minsize=82)
self._add_row(merge_frame, 0, "Bootloader", self.v_bl, self._browse_bl)
self._add_row(merge_frame, 1, "Application", self.v_app, self._browse_app)
self._add_row(merge_frame, 2, "输出文件", self.v_merge_out, self._browse_merge_out)
merge_opt = ttk.Frame(merge_frame)
merge_opt.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(6, 0))
ttk.Label(merge_opt, text="APP 地址").pack(side="left", padx=(0, 4))
ttk.Entry(merge_opt, textvariable=self.v_app_addr, width=11).pack(
side="left", padx=(0, 12))
ttk.Label(merge_opt, text="标志地址").pack(side="left", padx=(0, 4))
ttk.Entry(merge_opt, textvariable=self.v_flag_addr, width=11).pack(
side="left", padx=(0, 12))
ttk.Label(merge_opt, text="Flash 大小").pack(side="left", padx=(0, 4))
ttk.Entry(merge_opt, textvariable=self.v_merge_flash, width=11).pack(
side="left", padx=(0, 12))
ttk.Label(merge_opt, text="标志值").pack(side="left", padx=(0, 4))
ttk.Entry(merge_opt, textvariable=self.v_flag_value, width=11).pack(
side="left", padx=(0, 12))
btn = ttk.Button(merge_opt, text="执行拼接", command=self._on_merge)
btn.pack(side="left")
self.action_buttons.append(btn)
ttk.Label(merge_frame, textvariable=self.v_merge_result, foreground="#1a6fb5").grid(
row=4, column=0, columnspan=3, sticky="w", pady=(6, 0))
layout = ("典型布局:0x08000000 Bootloader 8KB | 0x08002000 APP | "
"0x0800FFFC 有效标志 0xEEEEEEEE | Flash 共 64KB")
ttk.Label(tab, text=layout, foreground="#666666").grid(row=2, column=0, sticky="w", pady=8)
def _add_row(self, parent, row, label, var, browse_cmd=None, widget_cls=ttk.Entry, **kwargs):
ttk.Label(parent, text=label).grid(
row=row, column=0, sticky="w", padx=(0, 10), pady=3)
widget = widget_cls(parent, textvariable=var, **kwargs)
widget.grid(row=row, column=1, sticky="ew", pady=3)
if browse_cmd is not None:
ttk.Button(parent, text="浏览", width=6, command=browse_cmd).grid(
row=row, column=2, sticky="ew", padx=(8, 0), pady=3)
return widget
def _add_pair_row(self, parent, row, label1, var1, label2, var2,
widget1_cls=ttk.Entry, widget2_cls=ttk.Entry,
widget1_kwargs=None, widget2_kwargs=None):
pair = ttk.Frame(parent)
pair.grid(row=row, column=0, columnspan=3, sticky="ew", pady=3)
pair.grid_columnconfigure(0, minsize=132)
pair.grid_columnconfigure(1, weight=1)
pair.grid_columnconfigure(2, minsize=12)
pair.grid_columnconfigure(3, minsize=132)
pair.grid_columnconfigure(4, weight=1)
ttk.Label(pair, text=label1).grid(row=0, column=0, sticky="w", padx=(0, 10))
widget1 = widget1_cls(pair, textvariable=var1, **(widget1_kwargs or {}))
widget1.grid(row=0, column=1, sticky="ew")
ttk.Label(pair, text=label2).grid(row=0, column=3, sticky="w", padx=(12, 10))
widget2 = widget2_cls(pair, textvariable=var2, **(widget2_kwargs or {}))
widget2.grid(row=0, column=4, sticky="ew")
return widget1, widget2
def _make_action_button(self, parent, col, text, command):
btn = ttk.Button(parent, text=text, command=command)
btn.grid(row=0, column=col, sticky="ew", padx=3, pady=3)
return btn
def _browse_openocd(self):
path = filedialog.askopenfilename(
title="选择 OpenOCD", filetypes=[("OpenOCD", "openocd.exe"), ("所有文件", "*.*")])
if path:
self.v_openocd.set(path)
def _browse_scripts(self):
path = filedialog.askdirectory(title="选择 OpenOCD scripts 目录")
if path:
self.v_scripts.set(path)
def _browse_firmware(self):
path = filedialog.askopenfilename(
title="选择固件",
filetypes=[("固件文件", "*.bin *.hex *.elf"), ("所有文件", "*.*")])
if path:
self.v_firmware.set(path)
if path.lower().endswith(".bin"):
name, ext = os.path.splitext(os.path.basename(path))
self.v_merge_out.set(os.path.join(os.path.dirname(path), name + "_BL" + ext))
def _browse_pad_in(self):
path = filedialog.askopenfilename(
title="选择需要补齐的 BIN", filetypes=[("BIN 文件", "*.bin"), ("所有文件", "*.*")])
if path:
self.v_pad_in.set(path)
name, ext = os.path.splitext(path)
self.v_pad_out.set(name + "_pad" + ext)
def _browse_pad_out(self):
path = filedialog.asksaveasfilename(
title="保存补齐后的 BIN", defaultextension=".bin",
filetypes=[("BIN 文件", "*.bin")])
if path:
self.v_pad_out.set(path)
def _browse_bl(self):
path = filedialog.askopenfilename(
title="选择 Bootloader BIN", filetypes=[("BIN 文件", "*.bin"), ("所有文件", "*.*")])
if path:
self.v_bl.set(path)
self._suggest_merge_output()
def _browse_app(self):
path = filedialog.askopenfilename(
title="选择 APP BIN", filetypes=[("BIN 文件", "*.bin"), ("所有文件", "*.*")])
if path:
self.v_app.set(path)
self._suggest_merge_output()
def _suggest_merge_output(self):
if self.v_app.get():
name, ext = os.path.splitext(os.path.basename(self.v_app.get()))
self.v_merge_out.set(os.path.join(os.path.dirname(self.v_app.get()), name + "_BL" + ext))
def _browse_merge_out(self):
path = filedialog.asksaveasfilename(
title="保存拼接后的镜像", defaultextension=".bin",
filetypes=[("BIN 文件", "*.bin")])
if path:
self.v_merge_out.set(path)
def _on_chip_change(self, event=None):
profile = CHIP_PROFILES.get(self.v_chip.get())
if profile:
self.v_flash_size.set("0x%X" % profile["flash"])
self.v_target.set(profile["target"])
def _on_probe_change(self, event=None):
cfg = PROBE_PROFILES.get(self.v_probe.get(), "")
if cfg:
self.v_interface.set(cfg)
def _check_tools(self):
if not os.path.isfile(self.v_openocd.get()):
messagebox.showerror("缺少工具", "找不到 OpenOCD%s" % self.v_openocd.get())
return False
if not os.path.isdir(self.v_scripts.get()):
messagebox.showerror("缺少工具", "找不到 OpenOCD scripts 目录:%s" % self.v_scripts.get())
return False
return True
def _build_openocd_cmd(self, script):
cmd = [self.v_openocd.get().strip(), "-s", self.v_scripts.get().strip()]
interface = _ensure_cfg_path(self.v_interface.get(), "interface")
target = _ensure_cfg_path(self.v_target.get(), "target")
cmd += ["-f", interface, "-f", target]
speed = self.v_speed.get().strip()
if speed:
cmd += ["-c", "adapter speed " + speed]
serial = self.v_serial.get().strip()
if serial and "cmsis-dap" in interface.lower():
cmd += ["-c", "cmsis_dap_serial " + serial]
extra = self.v_extra.get().strip()
if extra:
cmd += ["-c", extra]
cmd += ["-c", script]
return cmd
def _start_operation(self, title, script):
if self.proc is not None:
self._append_log("已有操作正在执行,请先停止。")
return
if not self._check_tools():
return
cmd = self._build_openocd_cmd(script)
self._set_busy(True)
self._append_log("== %s ==" % title)
self._append_log("命令: " + " ".join(cmd))
thread = threading.Thread(target=self._worker, args=(title, cmd), daemon=True)
thread.start()
def _worker(self, title, cmd):
creation = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
creationflags=creation,
)
except Exception as exc:
self.queue.put(("log", "启动 OpenOCD 失败: %s" % exc))
self.queue.put(("done", (title, -1)))
return
self.proc = proc
try:
for line in proc.stdout:
line = _strip_ansi(line.rstrip("\r\n"))
if line.strip():
self.queue.put(("log", line))
except Exception:
pass
code = proc.wait()
self.proc = None
self.queue.put(("done", (title, code)))
def _poll_queue(self):
try:
while True:
kind, payload = self.queue.get_nowait()
if kind == "log":
self._append_log(payload)
elif kind == "done":
title, code = payload
if code == 0:
self._append_log("[完成] %s" % title)
self._set_status("%s 完成" % title)
else:
self._append_log("[失败] %s (退出码 %d)" % (title, code))
self._set_status("%s 失败" % title)
self._set_busy(False)
except queue.Empty:
pass
self.root.after(100, self._poll_queue)
def _on_detect(self):
self._start_operation("检测设备", "init; reset halt; flash list; exit")
def _on_erase(self):
size = _parse_int(self.v_flash_size.get())
if size is None or size <= 0:
messagebox.showerror("参数错误", "Flash 大小无效:%s" % self.v_flash_size.get())
return
script = "init; reset halt; flash erase_address 0x%08X 0x%X; reset halt; exit" % (
FLASH_BASE_DEFAULT, size)
self._start_operation("整片擦除", script)
def _on_flash(self):
filepath = self.v_firmware.get().strip()
if not filepath or not os.path.isfile(filepath):
messagebox.showerror("缺少固件", "请先选择固件文件。")
return
ext = os.path.splitext(filepath)[1].lower()
script = "init; reset halt; "
if ext == ".bin":
base = _parse_int(self.v_base.get())
if base is None:
messagebox.showerror("参数错误", "BIN 基地址无效:%s" % self.v_base.get())
return
addr = " 0x%08X" % base
else:
addr = ""
quoted = _tcl_quote(filepath)
if self.v_erase.get():
script += "flash write_image erase %s%s; " % (quoted, addr)
else:
script += "flash write_image %s%s; " % (quoted, addr)
if self.v_verify.get():
script += "verify_image %s%s; " % (quoted, addr)
script += ("reset run" if self.v_reset.get() else "reset halt") + "; exit"
self._start_operation("烧录固件", script)
def _on_read(self):
out_path = filedialog.asksaveasfilename(
title="保存 Flash 内容", defaultextension=".bin",
initialfile="flash_dump.bin", filetypes=[("BIN 文件", "*.bin")])
if not out_path:
return
size = _parse_int(self.v_flash_size.get())
if size is None or size <= 0:
messagebox.showerror("参数错误", "Flash 大小无效:%s" % self.v_flash_size.get())
return
script = "init; reset halt; dump_image %s 0x%08X 0x%X; exit" % (
_tcl_quote(out_path), FLASH_BASE_DEFAULT, size)
self._start_operation("读取 Flash", script)
def _on_reset(self):
self._start_operation("复位芯片", "init; reset run; exit")
def _on_stop(self):
if self.proc is not None:
try:
self.proc.terminate()
self._append_log("已发送停止请求。")
except Exception as exc:
self._append_log("停止失败: %s" % exc)
else:
self._append_log("当前没有正在执行的操作。")
def _on_pad(self):
src = self.v_pad_in.get().strip()
dst = self.v_pad_out.get().strip()
align = _parse_int(self.v_pad_align.get())
pad_byte = _parse_int(self.v_pad_byte.get())
if not src or not os.path.isfile(src):
messagebox.showerror("缺少文件", "请选择需要补齐的 BIN。")
return
if not dst:
messagebox.showerror("缺少文件", "请设置输出文件。")
return
if align is None or align <= 0:
messagebox.showerror("参数错误", "对齐字节无效。")
return
if pad_byte is None:
pad_byte = 0xFF
try:
original = os.path.getsize(src)
padded = pad_bin(src, dst, align, pad_byte)
self.v_pad_result.set("原始 %d 字节 -> 补齐后 %d 字节 (+%d),对齐 %d 字节"
% (original, padded, padded - original, align))
self._append_log("补齐完成: %s -> %s" % (src, dst))
except Exception as exc:
messagebox.showerror("补齐失败", str(exc))
self._append_log("补齐失败: %s" % exc)
def _on_merge(self):
bl = self.v_bl.get().strip()
app = self.v_app.get().strip()
out = self.v_merge_out.get().strip()
app_addr = _parse_int(self.v_app_addr.get())
flag_addr = _parse_int(self.v_flag_addr.get())
flash_size = _parse_int(self.v_merge_flash.get())
flag_value = _parse_int(self.v_flag_value.get())
if not bl or not os.path.isfile(bl):
messagebox.showerror("缺少文件", "请选择 Bootloader BIN。")
return
if not app or not os.path.isfile(app):
messagebox.showerror("缺少文件", "请选择 APP BIN。")
return
if not out:
messagebox.showerror("缺少文件", "请设置输出文件。")
return
if app_addr is None or flag_addr is None or flash_size is None:
messagebox.showerror("参数错误", "地址或 Flash 大小无效。")
return
if flag_value is None:
flag_value = FLAG_VALUE_DEFAULT
try:
info = merge_firmware(bl, app, out, app_addr, flag_addr, flash_size, flag_value)
self.v_merge_result.set(
"Bootloader %d 字节 @0x%08X | APP %d 字节 @0x%08X | 镜像 %d 字节 | 标志 @0x%08X"
% (info["bootloader_size"], FLASH_BASE_DEFAULT, info["app_size"],
info["app_addr"], info["output_size"], info["flag_addr"]))
self._append_log("拼接完成: %s" % out)
except Exception as exc:
messagebox.showerror("拼接失败", str(exc))
self._append_log("拼接失败: %s" % exc)
def _set_busy(self, busy):
for btn in self.action_buttons:
btn.config(state=("disabled" if busy else "normal"))
if busy:
self._set_status("正在执行...")
else:
self._set_status("就绪")
def _set_status(self, text):
self.v_status.set("状态: " + text)
def _append_log(self, text):
stamp = time.strftime("%H:%M:%S")
self.log_text.config(state="normal")
self.log_text.insert("end", "[%s] %s\n" % (stamp, text))
self.log_text.see("end")
self.log_text.config(state="disabled")
def _clear_log(self):
self.log_text.config(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.config(state="disabled")
def _load_config(self):
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
except (OSError, ValueError):
return
mapping = {
"openocd": self.v_openocd,
"scripts": self.v_scripts,
"speed": self.v_speed,
"serial": self.v_serial,
"extra": self.v_extra,
"chip": self.v_chip,
"probe": self.v_probe,
"interface": self.v_interface,
"target": self.v_target,
"flash_size": self.v_flash_size,
"firmware": self.v_firmware,
"base": self.v_base,
"erase": self.v_erase,
"verify": self.v_verify,
"reset": self.v_reset,
"pad_in": self.v_pad_in,
"pad_out": self.v_pad_out,
"pad_align": self.v_pad_align,
"pad_byte": self.v_pad_byte,
"bootloader": self.v_bl,
"app": self.v_app,
"merge_out": self.v_merge_out,
"app_addr": self.v_app_addr,
"flag_addr": self.v_flag_addr,
"merge_flash": self.v_merge_flash,
"flag_value": self.v_flag_value,
}
for key, var in mapping.items():
if key in cfg:
try:
var.set(cfg[key])
except tk.TclError:
pass
def _ensure_config(self):
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
except OSError as exc:
self._append_log("创建配置目录失败: %s" % exc)
return
if not os.path.isfile(CONFIG_PATH):
self._save_config()
def _save_config(self):
try:
os.makedirs(CONFIG_DIR, exist_ok=True)
except OSError as exc:
self._append_log("创建配置目录失败: %s" % exc)
return
data = {
"openocd": self.v_openocd.get(),
"scripts": self.v_scripts.get(),
"speed": self.v_speed.get(),
"serial": self.v_serial.get(),
"extra": self.v_extra.get(),
"chip": self.v_chip.get(),
"probe": self.v_probe.get(),
"interface": self.v_interface.get(),
"target": self.v_target.get(),
"flash_size": self.v_flash_size.get(),
"firmware": self.v_firmware.get(),
"base": self.v_base.get(),
"erase": bool(self.v_erase.get()),
"verify": bool(self.v_verify.get()),
"reset": bool(self.v_reset.get()),
"pad_in": self.v_pad_in.get(),
"pad_out": self.v_pad_out.get(),
"pad_align": self.v_pad_align.get(),
"pad_byte": self.v_pad_byte.get(),
"bootloader": self.v_bl.get(),
"app": self.v_app.get(),
"merge_out": self.v_merge_out.get(),
"app_addr": self.v_app_addr.get(),
"flag_addr": self.v_flag_addr.get(),
"merge_flash": self.v_merge_flash.get(),
"flag_value": self.v_flag_value.get(),
}
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError as exc:
self._append_log("保存配置失败: %s" % exc)
def _on_close(self):
if self.proc is not None:
if not messagebox.askyesno("确认退出", "烧录操作仍在进行,是否停止并退出?"):
return
try:
self.proc.terminate()
except Exception:
pass
self._save_config()
self.root.destroy()
def main():
app = FlashGui()
app.root.mainloop()
if __name__ == "__main__":
main()
+664
View File
@@ -0,0 +1,664 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DAPLink Flash 主界面 — CustomTkinter 双栏仪表盘
================================================================
布局:
左列 探针与工具链 | 芯片与固件 | 操作(检测/烧录/擦除/读取/复位/停止)
右列 补齐 Pad(Y-Modem 对齐) | 拼接 Merge(BL+APP+标志) | 运行日志
默认跟随系统深/浅色外观, 顶栏可切换 Dark/Light/System。
本模块只管界面: OpenOCD 子进程与命令构建在 openocd_runner,
固件补齐/拼接在 fw_image, 运行配置在 config。
"""
import ctypes
import os
import queue
import subprocess
import sys
import time
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
import config
import openocd_runner
from fw_image import (CHIP_PROFILES, FLAG_VALUE_DEFAULT, FLASH_BASE_DEFAULT,
merge_firmware, pad_bin, parse_int)
MONO = ('Consolas', 12)
BOLD = ('Microsoft YaHei UI', 13, 'bold')
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
APP_NAME = 'DAPLink Flash'
APP_VERSION = '2.0.0'
REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool'
OK_BLUE = '#3B8ED0'
def get_icon_path():
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)"""
if getattr(sys, 'frozen', False):
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_flash.ico')
else:
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'logos', 'logo_flash.ico')
return p if os.path.exists(p) else None
def _apply_dropdown_theme():
"""CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应。
在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。"""
ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B')
ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C')
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
def run_gui(auto_close=0):
"""入口: 配置外观并启动主窗口"""
ctk.set_appearance_mode('System')
_apply_dropdown_theme()
App(auto_close).mainloop()
class App(ctk.CTk):
def __init__(self, auto_close=0):
super().__init__()
self.title(f'{APP_NAME} v{APP_VERSION} — GD32 固件烧录 · 补齐 · 镜像拼接')
try: # 任务栏独立分组
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
'hulk.DAPLinkFlash')
except Exception:
pass
self.geometry('1360x880')
self.minsize(1200, 780)
ic = get_icon_path()
if ic:
try:
self.iconbitmap(ic) # 窗口标题栏图标
except Exception:
pass
self.cfg = config.load_settings()
self._toolchain_source = '已配置'
if not os.path.isfile(self.cfg['openocd']):
# 未配置或配置指向的路径不存在 → 自动定位(内置/本机/PATH)
exe, scripts, self._toolchain_source = openocd_runner.locate_toolchain()
self.cfg['openocd'], self.cfg['scripts'] = exe, scripts
self.v = {}
for key, val in self.cfg.items():
self.v[key] = tk.BooleanVar(value=val) if isinstance(val, bool) \
else tk.StringVar(value=val)
for key in ('openocd', 'scripts'):
self.v[key].trace_add('write', lambda *_a: self._mark_custom_toolchain())
self._log_q = queue.Queue()
self.proc = None
self._poll_job = None
self._op_buttons = []
self._build()
self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接')
ver = openocd_runner.version(self.cfg['openocd'])
self._log('工具链: %s (%s)%s' % (self.cfg['openocd'], self._toolchain_source,
' ' + ver if ver else ''))
self._poll()
self.protocol('WM_DELETE_WINDOW', self._on_close)
if auto_close > 0:
self.after(auto_close * 1000, self._on_close)
# ============================================================
# 布局
# ============================================================
def _set_appearance(self, mode):
ctk.set_appearance_mode(mode)
def _card(self, parent, title, button=None, command=None, right_label=None):
"""卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签)。
卡片自身用 grid 参与列布局; 卡片内部一律用 pack。"""
card = ctk.CTkFrame(parent, corner_radius=10, border_width=1,
border_color=('gray70', 'gray30'))
head = ctk.CTkFrame(card, fg_color='transparent')
head.pack(fill='x', padx=12, pady=(8, 0))
ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left', fill='y')
if right_label is not None:
ctk.CTkLabel(head, textvariable=right_label,
text_color='gray60').pack(side='right', fill='y')
if button:
ctk.CTkButton(head, text=button, width=72,
command=command).pack(side='right', fill='y')
content = ctk.CTkFrame(card, fg_color='transparent')
content.pack(fill='both', expand=True, padx=12, pady=(2, 10))
return card, content
def _build(self):
top = ctk.CTkFrame(self, fg_color='transparent')
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='GD32 固件烧录 · 补齐 · 镜像拼接',
text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'],
command=self._set_appearance)
seg.set('System')
seg.pack(side='right')
ctk.CTkButton(top, text='关于', width=48, height=24,
fg_color='transparent', border_width=1,
border_color=('gray55', 'gray35'),
text_color='gray60', hover_color=('gray82', 'gray22'),
command=self._show_about).pack(side='right', padx=(0, 12))
body = ctk.CTkFrame(self, fg_color='transparent')
body.pack(fill='both', expand=True, padx=12, pady=(4, 12))
body.grid_columnconfigure(0, weight=1, uniform='col')
body.grid_columnconfigure(1, weight=1, uniform='col')
body.grid_rowconfigure(0, weight=1)
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.pack(fill='x', padx=14, pady=(0, 8))
self.v_status = tk.StringVar(value='状态: 就绪')
ctk.CTkLabel(status, textvariable=self.v_status, anchor='w').pack(side='left')
ctk.CTkLabel(status, text=f'v{APP_VERSION}',
text_color='gray60').pack(side='right')
# -- 左列: 探针与工具链 / 芯片与固件 / 操作 --
def _build_left(self, col):
col.grid_rowconfigure(2, weight=1) # 操作卡片吸底
col.grid_columnconfigure(0, weight=1)
self.v_toolchain = tk.StringVar(
value='工具链: ' + self._toolchain_source)
card, c = self._card(col, '探针与工具链', button='自动定位',
command=self._relocate_toolchain,
right_label=self.v_toolchain)
card.grid(row=0, column=0, sticky='ew')
self._field(c, 'OpenOCD 路径', self.v['openocd'], self._browse_openocd)
self._field(c, '脚本目录', self.v['scripts'], self._browse_scripts)
probe, _w = self._field_pair(
c, '烧录器', self.v['probe'], 'Interface', self.v['interface'],
cls1=(ctk.CTkComboBox, {'state': 'readonly',
'values': list(openocd_runner.PROBE_PROFILES)}),
cls2=(ctk.CTkComboBox,
{'values': sorted(set(openocd_runner.PROBE_PROFILES.values()) - {''})}))
probe.configure(command=lambda _=None: self._on_probe_change())
self._field_pair(c, '速度 (kHz)', self.v['speed'], '探针序列号',
self.v['serial'], width2=150)
self._field_pair(
c, 'Target', self.v['target'], '额外命令', self.v['extra'],
cls1=(ctk.CTkComboBox, {'values': ['target/gd32e23x.cfg']}))
card, c = self._card(col, '芯片与固件')
card.grid(row=1, column=0, sticky='ew', pady=(10, 0))
chip, _flash = self._field_pair(
c, '芯片型号', self.v['chip'], 'Flash 大小', self.v['flash_size'],
cls1=(ctk.CTkComboBox, {'state': 'readonly',
'values': list(CHIP_PROFILES)}),
width2=100)
chip.configure(command=lambda _=None: self._on_chip_change())
_flash.configure(state='disabled')
self._field(c, '固件文件', self.v['firmware'], self._browse_firmware)
self._field(c, '基地址 (仅 BIN)', self.v['base'])
ctk.CTkLabel(c, text='HEX/ELF 自动使用文件内地址; 完整镜像 0x08000000, '
'单独烧 APP 0x08002000',
text_color='gray50', justify='left', anchor='w',
).pack(fill='x', pady=(2, 0))
opts = ctk.CTkFrame(c, fg_color='transparent')
opts.pack(fill='x', pady=(6, 0))
for key, name in (('erase', '烧录前擦除'), ('verify', '烧录后校验'),
('reset', '烧录后复位运行')):
ctk.CTkCheckBox(opts, text=name, variable=self.v[key],
checkbox_width=18, checkbox_height=18).pack(
side='left', padx=(0, 14))
card, c = self._card(col, '操作')
card.grid(row=2, column=0, sticky='sew', pady=(10, 0))
grid = ctk.CTkFrame(c, fg_color='transparent')
grid.pack(fill='x')
grid.grid_columnconfigure((0, 1, 2), weight=1, uniform='op')
for i, (name, cmd) in enumerate((
('检测设备', self._on_detect), ('烧录固件', self._on_flash),
('整片擦除', self._on_erase), ('读取 Flash', self._on_read),
('复位芯片', self._on_reset))):
btn = ctk.CTkButton(grid, text=name, height=34, command=cmd)
btn.grid(row=i // 3, column=i % 3, sticky='ew', padx=3, pady=3)
self._op_buttons.append(btn)
self.btn_stop = ctk.CTkButton(grid, text='停止', height=34,
fg_color='gray40', hover_color='gray25',
command=self._on_stop, state='disabled')
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)
card, c = self._card(col, '补齐 (Pad) — 尾部补 0xFF 到页边界, '
'适配 Bootloader Y-Modem 整包接收')
card.grid(row=0, column=0, sticky='ew')
self._field(c, '输入 BIN', self.v['pad_in'], self._browse_pad_in)
self._field(c, '输出 BIN', self.v['pad_out'], self._browse_pad_out)
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x', pady=(6, 0))
ctk.CTkLabel(row, text='对齐:', width=40, anchor='e').pack(side='left')
ctk.CTkComboBox(row, width=86, state='readonly',
values=['1024', '2048', '4096'],
variable=self.v['pad_align']).pack(side='left', padx=(4, 12))
ctk.CTkLabel(row, text='填充字节:', width=64, anchor='e').pack(side='left')
ctk.CTkEntry(row, width=76, textvariable=self.v['pad_byte']
).pack(side='left', padx=(4, 12))
ctk.CTkButton(row, text='执行补齐', width=96,
command=self._on_pad).pack(side='left')
self.v_pad_result = tk.StringVar(value='')
ctk.CTkLabel(c, textvariable=self.v_pad_result, text_color=OK_BLUE,
justify='left', anchor='w').pack(fill='x', pady=(4, 0))
card, c = self._card(col, '拼接 (Merge) — Bootloader + APP + 有效标志 → 整片镜像')
card.grid(row=1, column=0, sticky='ew', pady=(10, 0))
self._field(c, 'Bootloader', self.v['bootloader'], self._browse_bl)
self._field(c, 'Application', self.v['app'], self._browse_app)
self._field(c, '输出文件', self.v['merge_out'], self._browse_merge_out)
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x', pady=(6, 0))
for label, key, w in (('APP 地址', 'app_addr', 104),
('标志地址', 'flag_addr', 104),
('Flash', 'merge_flash', 84),
('标志值', 'flag_value', 104)):
ctk.CTkLabel(row, text=label + ':', anchor='e').pack(side='left')
ctk.CTkEntry(row, width=w, textvariable=self.v[key]
).pack(side='left', padx=(4, 10))
ctk.CTkButton(row, text='执行拼接', width=96,
command=self._on_merge).pack(side='left')
self.v_merge_result = tk.StringVar(value='')
ctk.CTkLabel(c, textvariable=self.v_merge_result, text_color=OK_BLUE,
justify='left', anchor='w').pack(fill='x', pady=(4, 0))
ctk.CTkLabel(c, text='典型布局: 0x08000000 Bootloader 8KB | 0x08002000 APP | '
'0x0800FFFC 有效标志 0xEEEEEEEE | Flash 64KB',
text_color='gray50', justify='left', anchor='w',
).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')
# ============================================================
# 输入行辅助
# ============================================================
@staticmethod
def _mk_widget(parent, cls_info, var):
"""按控件类绑定变量: CTkComboBox 用 variable, 其余用 textvariable"""
cls, kw = cls_info
if issubclass(cls, ctk.CTkComboBox):
return cls(parent, variable=var, **kw)
return cls(parent, textvariable=var, **kw)
def _field(self, parent, label, var, browse=None):
"""一行: 标签 + 输入框(+ 浏览按钮), pack 布局"""
row = ctk.CTkFrame(parent, fg_color='transparent')
row.pack(fill='x', pady=(4, 0))
ctk.CTkLabel(row, text=label, width=150, anchor='w').pack(side='left')
ctk.CTkEntry(row, textvariable=var).pack(side='left', fill='x', expand=True)
if browse:
ctk.CTkButton(row, text='浏览', width=56,
command=browse).pack(side='left', padx=(6, 0))
return row
def _field_pair(self, parent, label1, var1, label2, var2,
cls1=(ctk.CTkEntry, {}), cls2=(ctk.CTkEntry, {}),
width2=None):
"""一行放两个字段: label1[控件1] label2[控件2(可定宽)]"""
row = ctk.CTkFrame(parent, fg_color='transparent')
row.pack(fill='x', pady=(4, 0))
ctk.CTkLabel(row, text=label1, width=150, anchor='w').pack(side='left')
w1 = self._mk_widget(row, cls1, var1)
w1.pack(side='left', fill='x', expand=True)
ctk.CTkLabel(row, text=label2, width=90, anchor='e').pack(side='left')
if width2:
cls2 = (cls2[0], {**cls2[1], 'width': width2})
w2 = self._mk_widget(row, cls2, var2)
w2.pack(side='left', padx=(4, 0))
return w1, w2
# ============================================================
# 路径选择与联动
# ============================================================
def _browse_openocd(self):
path = filedialog.askopenfilename(
title='选择 OpenOCD', filetypes=[('OpenOCD', 'openocd.exe'), ('所有文件', '*.*')])
if path:
self.v['openocd'].set(path)
def _browse_scripts(self):
path = filedialog.askdirectory(title='选择 OpenOCD scripts 目录')
if path:
self.v['scripts'].set(path)
def _relocate_toolchain(self):
exe, scripts, source = openocd_runner.locate_toolchain()
self.v['openocd'].set(exe)
self.v['scripts'].set(scripts)
self._toolchain_source = source
self.v_toolchain.set('工具链: ' + source)
ver = openocd_runner.version(exe)
self._log('工具链定位: %s (%s)%s' % (exe, source, ' ' + ver if ver else ''))
def _mark_custom_toolchain(self, *_a):
if self._toolchain_source != '自定义':
self._toolchain_source = '自定义'
self.v_toolchain.set('工具链: 自定义')
def _browse_firmware(self):
path = filedialog.askopenfilename(
title='选择固件',
filetypes=[('固件文件', '*.bin *.hex *.elf'), ('所有文件', '*.*')])
if path:
self.v['firmware'].set(path)
def _browse_pad_in(self):
path = filedialog.askopenfilename(
title='选择需要补齐的 BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['pad_in'].set(path)
name, ext = os.path.splitext(path)
self.v['pad_out'].set(name + '_pad' + ext)
def _browse_pad_out(self):
path = filedialog.asksaveasfilename(
title='保存补齐后的 BIN', defaultextension='.bin',
filetypes=[('BIN 文件', '*.bin')])
if path:
self.v['pad_out'].set(path)
def _browse_bl(self):
path = filedialog.askopenfilename(
title='选择 Bootloader BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['bootloader'].set(path)
self._suggest_merge_output()
def _browse_app(self):
path = filedialog.askopenfilename(
title='选择 APP BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
if path:
self.v['app'].set(path)
self._suggest_merge_output()
def _suggest_merge_output(self):
if self.v['app'].get():
name, ext = os.path.splitext(os.path.basename(self.v['app'].get()))
self.v['merge_out'].set(
os.path.join(os.path.dirname(self.v['app'].get()), name + '_BL' + ext))
def _browse_merge_out(self):
path = filedialog.asksaveasfilename(
title='保存拼接后的镜像', defaultextension='.bin',
filetypes=[('BIN 文件', '*.bin')])
if path:
self.v['merge_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()))
def _on_probe_change(self, *_a):
cfg = openocd_runner.PROBE_PROFILES.get(self.v['probe'].get(), '')
if cfg:
self.v['interface'].set(cfg)
# ============================================================
# OpenOCD 烧录操作
# ============================================================
def _check_tools(self):
if not os.path.isfile(self.v['openocd'].get()):
messagebox.showerror('缺少工具',
'找不到 OpenOCD:\n%s\n\n'
'可点"自动定位"使用内置/本机工具链。'
% self.v['openocd'].get())
return False
if not os.path.isdir(self.v['scripts'].get()):
messagebox.showerror('缺少工具',
'找不到 OpenOCD scripts 目录:\n%s' % self.v['scripts'].get())
return False
return True
def _build_cmd(self, script):
return openocd_runner.build_cmd(
self.v['openocd'].get(), self.v['scripts'].get(),
self.v['interface'].get(), self.v['target'].get(),
self.v['speed'].get(), self.v['serial'].get(),
self.v['extra'].get(), script)
def _start(self, title, script):
if self.proc is not None:
self._log('已有操作正在执行, 请先停止。')
return
if not self._check_tools():
return
cmd = self._build_cmd(script)
self._busy(True)
self._log('== %s ==' % title)
self._log('命令: ' + subprocess.list2cmdline(cmd))
self.proc = openocd_runner.OcdProcess(
cmd,
on_line=lambda line: self._log_q.put(('log', line)),
on_done=lambda code: self._log_q.put(('done', (title, code))))
self.proc.start()
def _on_detect(self):
self._start('检测设备', openocd_runner.script_detect())
def _on_erase(self):
try:
self._start('整片擦除', openocd_runner.script_erase(self.v['flash_size'].get()))
except ValueError as exc:
messagebox.showerror('参数错误', str(exc))
def _on_flash(self):
filepath = self.v['firmware'].get().strip()
if not filepath or not os.path.isfile(filepath):
messagebox.showerror('缺少固件', '请先选择固件文件。')
return
try:
script = openocd_runner.script_flash(
filepath, self.v['base'].get(),
erase=self.v['erase'].get(), verify=self.v['verify'].get(),
reset=self.v['reset'].get())
except ValueError as exc:
messagebox.showerror('参数错误', str(exc))
return
self._start('烧录固件', script)
def _on_read(self):
out_path = filedialog.asksaveasfilename(
title='保存 Flash 内容', defaultextension='.bin',
initialfile='flash_dump.bin', filetypes=[('BIN 文件', '*.bin')])
if not out_path:
return
try:
self._start('读取 Flash',
openocd_runner.script_read(out_path, self.v['flash_size'].get()))
except ValueError as exc:
messagebox.showerror('参数错误', str(exc))
def _on_reset(self):
self._start('复位芯片', openocd_runner.script_reset())
def _on_stop(self):
if self.proc is not None:
self.proc.terminate()
self._log('已发送停止请求。')
else:
self._log('当前没有正在执行的操作。')
def _poll(self):
"""工作线程 → GUI 主线程: 80ms 轮询日志队列并同步忙碌状态"""
try:
while True:
kind, payload = self._log_q.get_nowait()
if kind == 'log':
self._log(payload)
elif kind == 'done':
title, code = payload
if code == 0:
self._log('[完成] %s' % title)
self._set_status('%s 完成' % title)
else:
self._log('[失败] %s (退出码 %d)' % (title, code))
self._set_status('%s 失败' % title)
self._busy(False)
self._save()
except queue.Empty:
pass
self._poll_job = self.after(80, self._poll)
def _busy(self, busy):
state = 'disabled' if busy else 'normal'
for btn in self._op_buttons:
btn.configure(state=state)
self.btn_stop.configure(state='normal' if busy else 'disabled')
if busy:
self._set_status('正在执行...')
else:
self._set_status('就绪')
# ============================================================
# 固件工具: 补齐 / 拼接
# ============================================================
def _on_pad(self):
src = self.v['pad_in'].get().strip()
dst = self.v['pad_out'].get().strip()
align = parse_int(self.v['pad_align'].get())
pad_byte = parse_int(self.v['pad_byte'].get())
if not src or not os.path.isfile(src):
messagebox.showerror('缺少文件', '请选择需要补齐的 BIN。')
return
if not dst:
messagebox.showerror('缺少文件', '请设置输出文件。')
return
if align is None or align <= 0:
messagebox.showerror('参数错误', '对齐字节无效。')
return
if pad_byte is None:
pad_byte = 0xFF
try:
original = os.path.getsize(src)
padded = pad_bin(src, dst, align, pad_byte)
self.v_pad_result.set(
'原始 %d B → 补齐后 %d B (+%d), 对齐 %d B'
% (original, padded, padded - original, align))
self._log('补齐完成: %s%s' % (src, dst))
self._set_status('补齐完成')
self._save()
except Exception as exc:
messagebox.showerror('补齐失败', str(exc))
self._log('补齐失败: %s' % exc)
def _on_merge(self):
bl = self.v['bootloader'].get().strip()
app = self.v['app'].get().strip()
out = self.v['merge_out'].get().strip()
app_addr = parse_int(self.v['app_addr'].get())
flag_addr = parse_int(self.v['flag_addr'].get())
flash_size = parse_int(self.v['merge_flash'].get())
flag_value = parse_int(self.v['flag_value'].get())
if not bl or not os.path.isfile(bl):
messagebox.showerror('缺少文件', '请选择 Bootloader BIN。')
return
if not app or not os.path.isfile(app):
messagebox.showerror('缺少文件', '请选择 APP BIN。')
return
if not out:
messagebox.showerror('缺少文件', '请设置输出文件。')
return
if app_addr is None or flag_addr is None or flash_size is None:
messagebox.showerror('参数错误', '地址或 Flash 大小无效。')
return
if flag_value is None:
flag_value = FLAG_VALUE_DEFAULT
try:
info = merge_firmware(bl, app, out, app_addr, flag_addr,
flash_size, flag_value)
self.v_merge_result.set(
'BL %d B @0x%08X | APP %d B @0x%08X | 镜像 %d B | 标志 @0x%08X'
% (info['bootloader_size'], FLASH_BASE_DEFAULT, info['app_size'],
info['app_addr'], info['output_size'], info['flag_addr']))
self._log('拼接完成: %s' % out)
self._set_status('拼接完成')
self._save()
except Exception as exc:
messagebox.showerror('拼接失败', str(exc))
self._log('拼接失败: %s' % exc)
# ============================================================
# 日志 / 状态 / 配置 / 退出
# ============================================================
def _log(self, line):
self.txt_log.configure(state='normal')
self.txt_log.insert('end', time_stamp() + line + '\n')
if int(self.txt_log.index('end-1c').split('.')[0]) > 800:
self.txt_log.delete('1.0', '2.0') # 只保留最近约 800 行
self.txt_log.see('end')
self.txt_log.configure(state='disabled')
def _clear_log(self):
self.txt_log.configure(state='normal')
self.txt_log.delete('1.0', 'end')
self.txt_log.configure(state='disabled')
def _set_status(self, text):
self.v_status.set('状态: ' + text)
def _save(self):
for key, var in self.v.items():
self.cfg[key] = var.get()
self.cfg['openocd'] = self.v['openocd'].get()
config.save_settings(self.cfg)
def _show_about(self):
dlg = ctk.CTkToplevel(self)
dlg.title(f'关于 {APP_NAME}')
dlg.resizable(False, False)
dlg.after(150, dlg.grab_set())
box = ctk.CTkFrame(dlg, fg_color='transparent')
box.pack(padx=26, pady=20)
ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w')
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)',
'补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem',
'拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像',
'发布包内置 OpenOCD 工具链, 免安装解压即用',
'作者: Hulk'):
ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1)
ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w',
text_color=OK_BLUE).pack(anchor='w', pady=(12, 0))
ctk.CTkButton(box, text='关闭', width=84,
command=dlg.destroy).pack(anchor='e', pady=(14, 0))
def _on_close(self):
if self.proc is not None:
if not messagebox.askyesno('确认退出', '烧录操作仍在进行, 是否停止并退出?'):
return
self.proc.terminate()
if self._poll_job:
try:
self.after_cancel(self._poll_job)
except Exception:
pass
self._poll_job = None
self._save()
self.destroy()
def time_stamp():
return time.strftime('%H:%M:%S ')
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 flash_gui.py 打包成单文件 exe(工具名: DAPLinkFlash)
================================================================
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter +
customtkinter 全部打进一个 exe, 目标机器无需安装任何依赖。
OpenOCD 工具链不进 exe, 由 package_release.py 以 toolchain/ 目录随包分发
(scripts 目录含 1000+ 文件, 进 onefile 会拖慢每次启动的解压)。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体(命令行模式可见输出)
产物: release/DAPLinkFlash.exe(及 --debug 时的 DAPLinkFlash-console.exe)
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
"""
import os
import subprocess
import sys
import time
# 本脚本位于 src/, 打包产物输出到 FlashTool/release/
SRC = os.path.dirname(os.path.abspath(__file__))
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')
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def extra_collect_args():
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
try:
import customtkinter # noqa: F401
return ['--collect-all', 'customtkinter']
except ImportError:
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
return []
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, extra_args):
remove_stale(os.path.join(DIST, name + '.exe'))
cmd = [sys.executable, '-m', 'PyInstaller',
'--onefile', '--clean', '--name', name,
'--distpath', DIST, '--workpath', WORK, '--specpath', WORK,
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'flash_gui.py')]
if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
else:
print('警告: 未找到 logos/logo_flash.ico(先运行 logos/make_ico.py), '
'本次打包不带图标')
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(DIST, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框(GUI 与烧录操作全部可用, 命令行模式输出不可见)
build('DAPLinkFlash', ['--noconsole'])
if build_console:
# 调试版: 带控制台, 命令行模式/异常栈可见
build('DAPLinkFlash-console', [])
if __name__ == '__main__':
main()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
运行配置: flash_gui_settings.json(工具链/芯片/固件路径记忆)
================================================================
配置落在程序目录: frozen 取 exe 旁, 源码模式取 FlashTool/ 根。
(参考 DLPilot/config.py: PyInstaller onefile 打包后 __file__ 指向临时解压
目录, 配置必须用 sys.executable 定位到 exe 旁边)
兼容迁移: 旧版按主机名分文件的 config/flash_gui_<主机名>.json 在首次
启动且新配置不存在时自动导入, 之后不再读取旧文件。
"""
import glob
import json
import os
import re
import socket
import sys
if getattr(sys, "frozen", False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SETTINGS_NAME = "flash_gui_settings.json"
SETTINGS_PATH = os.path.join(BASE_DIR, SETTINGS_NAME)
# 键与旧版 config/flash_gui_<主机名>.json 完全一致, 旧配置值可原样搬过来
DEFAULTS = {
"openocd": "", # 空 = 启动时按 locate_toolchain() 自动定位
"scripts": "",
"speed": "4000",
"serial": "",
"extra": "",
"chip": "GD32E230F8",
"probe": "DAPLink / CMSIS-DAP",
"interface": "interface/cmsis-dap.cfg",
"target": "target/gd32e23x.cfg",
"flash_size": "0x10000",
"firmware": "",
"base": "0x08000000",
"erase": True,
"verify": True,
"reset": True,
"pad_in": "",
"pad_out": "",
"pad_align": "1024",
"pad_byte": "0xFF",
"bootloader": "",
"app": "",
"merge_out": "",
"app_addr": "0x08002000",
"flag_addr": "0x0800FFFC",
"merge_flash": "0x10000",
"flag_value": "0xEEEEEEEE",
}
def _legacy_settings():
"""旧版 config/flash_gui_<主机名>.json 的路径, 优先本机名, 找不到取目录里
任意一份; 没有旧配置返回 None"""
cfg_dir = os.path.join(BASE_DIR, "config")
if not os.path.isdir(cfg_dir):
return None
host = "flash_gui_%s.json" % re.sub(r"[^A-Za-z0-9_.-]", "_", socket.gethostname())
path = os.path.join(cfg_dir, host)
if not os.path.isfile(path):
found = sorted(glob.glob(os.path.join(cfg_dir, "flash_gui_*.json")))
path = found[0] if found else None
return path
def load_settings():
"""读配置, 带默认值兜底; 新配置不存在时尝试从旧版 per-host 配置迁移"""
data = dict(DEFAULTS)
for src in (_legacy_settings() if not os.path.isfile(SETTINGS_PATH) else None,
SETTINGS_PATH):
if not src or not os.path.isfile(src):
continue
try:
with open(src, "r", encoding="utf-8") as f:
stored = json.load(f)
except (OSError, ValueError):
continue
if not isinstance(stored, dict):
continue
for key in DEFAULTS:
if key in stored:
data[key] = stored[key]
return data
def save_settings(data):
"""只保存 DEFAULTS 里有的键, 防止把界面临时状态写进去"""
out = {key: data.get(key, DEFAULTS[key]) for key in DEFAULTS}
try:
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
except OSError:
pass
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 — 入口与命令行模式
================================================================
配合 GD32E230 + DAPLink/CMSIS-DAP(或 ST-Link/J-Link) 使用, OpenOCD 烧录;
内含固件补齐(pad)与镜像拼接(merge)工具。发布包内置 OpenOCD 工具链。
模块划分(重构自原单文件 daplink_flash_gui.py, 界面/打包方式对齐 DLPilot):
app_gui.py CustomTkinter 主界面(双栏仪表盘)
openocd_runner.py 工具链定位 / OpenOCD 命令构建与子进程
fw_image.py 补齐(pad)与拼接(merge) + 芯片参数表
config.py 运行目录与 flash_gui_settings.json 配置
依赖: 全部纯标准库 + customtkinter(py -m pip install customtkinter);
内置工具链不存在时需本机已装 OpenOCD 或在界面/参数里指定路径。
命令行模式(自动化脚本用):
python flash_gui.py 启动 GUI
python flash_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
python flash_gui.py --detect 检测烧录器与目标芯片
python flash_gui.py --flash app.bin 烧录(BIN 默认按基地址 0x08000000)
python flash_gui.py --flash app.bin --base 0x08002000 --no-reset
python flash_gui.py --erase 整片擦除(按配置的 Flash 大小)
python flash_gui.py --read dump.bin 读取整片 Flash
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 --version 打印 OpenOCD 版本
"""
import argparse
import os
import subprocess
import sys
import config
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)
def _toolchain(args):
"""命令行工具链解析: 参数 > 配置文件 > 自动定位(内置/本机/PATH)"""
saved = config.load_settings()
exe = args.openocd or (saved['openocd'] if os.path.isfile(saved['openocd']) else '')
scripts = args.scripts or (saved['scripts'] if exe == saved['openocd'] else '')
if not exe:
exe, scripts2, source = openocd_runner.locate_toolchain()
scripts = scripts or scripts2
print(f'工具链: {exe} ({source})')
if not os.path.isfile(exe):
sys.exit(f'找不到 OpenOCD: {exe}\n'
f'可用 --openocd 指定, 或把内置工具链放在 exe 旁 toolchain/openocd/')
if not scripts or not os.path.isdir(scripts):
sys.exit(f'找不到 OpenOCD scripts 目录: {scripts or "(空)"}\n可用 --scripts 指定')
return exe, scripts
def _ocd_cmd(args, script):
saved = config.load_settings()
exe, scripts = _toolchain(args)
return openocd_runner.build_cmd(
exe, scripts, args.interface or saved['interface'],
args.target or saved['target'],
str(args.speed) if args.speed else saved['speed'],
args.serial or saved['serial'], saved['extra'], script)
def run_openocd(args, title, script):
"""跑一条烧录命令, 实时回显输出, 返回退出码"""
cmd = _ocd_cmd(args, script)
print('== %s ==' % title)
print('命令: ' + subprocess.list2cmdline(cmd))
code, _lines = openocd_runner.run_sync(cmd)
return code
def run_pad(args):
if not os.path.isfile(args.pad):
sys.exit(f'找不到输入文件: {args.pad}')
align = parse_int(str(args.align))
if align is None or align <= 0:
sys.exit(f'对齐字节无效: {args.align}')
pad_byte = parse_int(args.pad_byte)
if pad_byte is None:
pad_byte = 0xFF
stem, ext = os.path.splitext(args.pad)
out = args.output or stem + '_pad' + ext
_ensure_parent(out)
original = os.path.getsize(args.pad)
padded = pad_bin(args.pad, out, align, pad_byte)
print(f'补齐完成: {out}')
print(f' 原始 {original} B → 补齐后 {padded} B (+{padded - original}), 对齐 {align} B')
return 0
def run_merge(args):
bootloader, app = args.merge
for p in (bootloader, app):
if not os.path.isfile(p):
sys.exit(f'找不到文件: {p}')
flash_size = parse_int(str(args.flash_size))
app_addr = parse_int(args.app_addr)
flag_addr = parse_int(args.flag_addr)
flag_value = parse_int(args.flag_value)
if flag_value is None:
flag_value = FLAG_VALUE_DEFAULT
out = args.output or os.path.splitext(app)[0] + '_BL.bin'
_ensure_parent(out)
try:
info = merge_firmware(bootloader, app, out,
app_addr, flag_addr, flash_size, flag_value)
except ValueError as exc:
sys.exit(f'拼接失败: {exc}')
print(f'拼接完成: {out}')
print(f' Bootloader {info["bootloader_size"]} B @0x{FLASH_BASE_DEFAULT:08X}')
print(f' Application {info["app_size"]} B @0x{info["app_addr"]:08X}')
print(f' Flag @0x{info["flag_addr"]:08X} 镜像 {info["output_size"]} B')
return 0
def _ensure_parent(path):
parent = os.path.dirname(os.path.abspath(path))
if parent:
os.makedirs(parent, exist_ok=True)
def main():
ap = argparse.ArgumentParser(
description='DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 '
'(无参数则启动 GUI)')
ap.add_argument('--seconds', type=int, default=0,
help='GUI 自动退出秒数(冒烟测试)')
# -- 烧录操作(互斥, 按下述顺序判优) --
op = ap.add_mutually_exclusive_group()
op.add_argument('--detect', action='store_true', help='检测烧录器与目标芯片')
op.add_argument('--flash', metavar='FILE', help='烧录固件 (BIN/HEX/ELF)')
op.add_argument('--erase', action='store_true', help='整片擦除')
op.add_argument('--read', metavar='OUT', help='读取整片 Flash 到 OUT')
op.add_argument('--reset', action='store_true', help='复位芯片并运行')
op.add_argument('--version', action='store_true', help='打印 OpenOCD 版本')
# -- 固件工具 --
op.add_argument('--pad', metavar='FILE', dest='pad', help='补齐 BIN 到页边界')
op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'),
help='拼接 Bootloader + APP → 整片镜像')
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('--app-addr', default='0x%08X' % APP_ADDR_DEFAULT,
help='--merge APP 地址(默认 0x08002000)')
ap.add_argument('--flag-addr', default='0x%08X' % FLAG_ADDR_DEFAULT,
help='--merge 标志地址(默认 0x0800FFFC)')
ap.add_argument('--flag-value', default='0x%08X' % FLAG_VALUE_DEFAULT,
help='--merge 标志值(默认 0xEEEEEEEE)')
ap.add_argument('--flash-size', default='0x10000',
help='Flash 大小(默认 0x10000, --erase/--read/--merge 用)')
# -- 烧录参数 --
ap.add_argument('--base', default='0x%08X' % FLASH_BASE_DEFAULT,
help='BIN 烧录基地址(默认 0x08000000)')
ap.add_argument('--no-erase', 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('--speed', type=int, help='适配器速度 kHz(默认取配置 4000)')
ap.add_argument('--serial', help='探针序列号(多探针时用)')
ap.add_argument('--interface', help='interface cfg(默认 interface/cmsis-dap.cfg)')
ap.add_argument('--target', help='target cfg(默认 target/gd32e23x.cfg)')
ap.add_argument('--openocd', help='OpenOCD 可执行文件路径(默认自动定位)')
ap.add_argument('--scripts', help='OpenOCD scripts 目录(默认自动定位)')
args = ap.parse_args()
if args.pad:
sys.exit(run_pad(args))
if args.merge:
sys.exit(run_merge(args))
if args.version:
exe, _scripts = _toolchain(args)
print(openocd_runner.version(exe) or '无法获取版本')
sys.exit(0)
if args.detect:
sys.exit(run_openocd(args, '检测设备', openocd_runner.script_detect()))
if args.erase:
sys.exit(run_openocd(args, '整片擦除',
openocd_runner.script_erase(args.flash_size)))
if args.flash:
sys.exit(run_openocd(args, '烧录固件', openocd_runner.script_flash(
args.flash, args.base,
erase=not args.no_erase, verify=not args.no_verify,
reset=not args.no_reset)))
if args.read:
sys.exit(run_openocd(args, '读取 Flash',
openocd_runner.script_read(args.read, args.flash_size)))
if args.reset:
sys.exit(run_openocd(args, '复位芯片', openocd_runner.script_reset()))
# 无操作参数 → 启动 GUI(实现在 app_gui.py)
import app_gui
app_gui.run_gui(args.seconds)
if __name__ == '__main__':
main()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
固件镜像处理: 补齐(pad)与拼接(merge) — 纯标准库, GUI 与命令行共用
================================================================
pad - BIN 尾部补 pad_byte 到指定对齐(bootloader 的 Y-Modem 按 1K 整包接收)
merge - Bootloader + APP + 有效标志 拼成一整片 Flash 镜像(初次烧录用)
芯片参数表(CHIP_PROFILES)同时供烧录页(target cfg/Flash 大小)与补齐/拼接
的容量校验使用。
"""
import struct
FLASH_BASE_DEFAULT = 0x08000000
APP_ADDR_DEFAULT = 0x08002000
FLAG_ADDR_DEFAULT = 0x0800FFFC
FLAG_VALUE_DEFAULT = 0xEEEEEEEE
CHIP_PROFILES = {
"GD32E230F4": {"target": "target/gd32e23x.cfg", "flash": 16 * 1024, "ram": 4 * 1024},
"GD32E230F6": {"target": "target/gd32e23x.cfg", "flash": 32 * 1024, "ram": 4 * 1024},
"GD32E230F8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
"GD32E230C8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
}
def parse_int(text):
"""解析 '0x1C' / '4096' / '1k' / '2m' 形式的数值, 非法返回 None"""
s = str(text).strip().lower()
if not s:
return None
mult = 1
if s.endswith("k"):
mult = 1024
s = s[:-1]
elif s.endswith("m"):
mult = 1024 * 1024
s = s[:-1]
try:
if s.startswith("0x"):
return int(s, 16) * mult
return int(s, 10) * mult
except ValueError:
return None
def pad_bin(src_path, dst_path, align, pad_byte=0xFF):
"""把 BIN 补齐到 align 的整数倍, 返回补齐后大小"""
with open(src_path, "rb") as f:
data = f.read()
if align <= 0:
raise ValueError("align must be positive")
remainder = len(data) % align
if remainder:
data += bytes([pad_byte & 0xFF]) * (align - remainder)
with open(dst_path, "wb") as f:
f.write(data)
return len(data)
def merge_firmware(bootloader_path, app_path, output_path, app_addr, flag_addr,
flash_size, flag_value=FLAG_VALUE_DEFAULT):
"""Bootloader + APP + 有效标志 → 整片 Flash 镜像, 返回尺寸信息 dict"""
if app_addr < FLASH_BASE_DEFAULT or app_addr >= FLASH_BASE_DEFAULT + flash_size:
raise ValueError("APP address is outside the flash range")
if flag_addr < FLASH_BASE_DEFAULT or flag_addr + 4 > FLASH_BASE_DEFAULT + flash_size:
raise ValueError("flag address is outside the flash range")
with open(bootloader_path, "rb") as f:
bootloader = f.read()
with open(app_path, "rb") as f:
app = f.read()
app_offset = app_addr - FLASH_BASE_DEFAULT
flag_offset = flag_addr - FLASH_BASE_DEFAULT
if len(bootloader) > app_offset:
raise ValueError("bootloader (%d bytes) exceeds APP offset (%d bytes)"
% (len(bootloader), app_offset))
if app_offset + len(app) > flag_offset:
raise ValueError("APP end address exceeds the flag address")
image = bytearray(b"\xFF" * flash_size)
image[0:len(bootloader)] = bootloader
image[app_offset:app_offset + len(app)] = app
struct.pack_into("<I", image, flag_offset, flag_value & 0xFFFFFFFF)
with open(output_path, "wb") as f:
f.write(image)
return {
"bootloader_size": len(bootloader),
"app_size": len(app),
"output_size": len(image),
"app_addr": app_addr,
"flag_addr": flag_addr,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DAPLink Flash 图标栅格化: 黑底 512, 绿色圆角矩形内 FLASH 黑字, 下方 Link 白字
================================================================
风格对齐 DLPilot(黑底 + 圆角色块 + 两行字), 绿色区分烧录工具定位。
输出: logos/logo_flash.png(512, 预览/复用)
logos/logo_flash.ico(16~256 七档, exe 图标用)
用法: python make_ico.py (需 Pillow)
"""
import os
from PIL import Image, ImageDraw, ImageFont
BASE = os.path.dirname(os.path.abspath(__file__))
FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
def render(size=512):
s = size / 512.0
img = Image.new('RGB', (size, size), (0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
radius=34 * s, fill=(0, 200, 120))
if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
f = ImageFont.truetype(FONT, max(8, round(146 * s)))
cx = size / 2
# FLASH: 居中于绿色框
d.text((cx, 64 * s + 186 * s / 2), 'FLASH', font=f, fill=(0, 0, 0), anchor='mm')
# Link: 与 DLPilot 同款基线(中心 ~368)
d.text((cx, 368 * s), 'Link', font=f, fill=(255, 255, 255), anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'logo_flash.png')
ico = os.path.join(BASE, 'logo_flash.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenOCD 子进程封装: 工具链定位 / 烧录脚本构建 / 后台运行
================================================================
工具链定位顺序(locate_toolchain):
1. 内置 <程序目录>/toolchain/openocd/bin/openocd.exe
(发布包自带; PyInstaller frozen 取 exe 旁, 源码模式取 FlashTool/ 下)
2. 默认 D:/toolchain/openocd(开发机本机安装)
3. PATH 里的 openocd
返回值带来源标记, GUI 在界面上显示"工具链: 内置/本机/PATH"
GUI 与命令行共用 build_cmd/script_* 这组函数, 保证两边生成的命令一致。
"""
import os
import re
import shutil
import subprocess
import sys
import threading
from fw_image import CHIP_PROFILES, FLASH_BASE_DEFAULT, parse_int
DEFAULT_OPENOCD = r"D:/toolchain/openocd/bin/openocd.exe"
DEFAULT_SCRIPTS = r"D:/toolchain/openocd/openocd/scripts"
# 探针型号 → OpenOCD interface cfg(空白 = 自定义, 不改手填值)
PROBE_PROFILES = {
"DAPLink / CMSIS-DAP": "interface/cmsis-dap.cfg",
"GD-Link (CMSIS-DAP)": "interface/cmsis-dap.cfg",
"ST-Link": "interface/stlink.cfg",
"J-Link": "interface/jlink.cfg",
"CMSIS-DAP (TCP)": "interface/cmsis-dap-tcp.cfg",
"Custom": "",
}
def _app_base():
"""程序所在目录: frozen 取 exe 旁, 源码模式取 FlashTool/"""
if getattr(sys, "frozen", False):
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def bundled_toolchain():
"""内置工具链路径, 不存在返回 None"""
exe = os.path.join(_app_base(), "toolchain", "openocd", "bin", "openocd.exe")
if os.path.isfile(exe):
scripts = os.path.join(_app_base(), "toolchain", "openocd", "openocd", "scripts")
return exe, scripts if os.path.isdir(scripts) else DEFAULT_SCRIPTS
return None
def locate_toolchain():
"""返回 (openocd_exe, scripts_dir, source)source ∈ 内置/本机/PATH"""
bundled = bundled_toolchain()
if bundled:
return bundled[0], bundled[1], "内置"
if os.path.isfile(DEFAULT_OPENOCD):
return DEFAULT_OPENOCD, DEFAULT_SCRIPTS, "本机"
which = shutil.which("openocd")
if which:
return which, "", "PATH"
return DEFAULT_OPENOCD, DEFAULT_SCRIPTS, "本机"
def strip_ansi(text):
return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
def tcl_quote(path):
return '"' + path.replace("\\", "/").replace('"', '\\"') + '"'
def ensure_cfg_path(value, folder):
"""裸 cfg 文件名(如 gd32e23x.cfg)补上所属目录前缀"""
value = value.strip()
if not value:
return value
if "/" in value or "\\" in value or not value.lower().endswith(".cfg"):
return value
return folder + "/" + value
def version(exe):
"""openocd --version 首行(如 Open On-Chip Debugger 0.12.0), 失败返回 None"""
try:
out = subprocess.run([exe, "--version"], capture_output=True, text=True,
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW
if os.name == "nt" else 0)
line = (out.stdout or out.stderr or "").strip().splitlines()
return line[0].strip() if line else None
except Exception:
return None
# ============================================================
# 烧录脚本构建(GUI 与 CLI 共用)
# ============================================================
def build_cmd(openocd, scripts, interface, target, speed="", serial="", extra="", script=""):
"""拼 OpenOCD 命令行: -s 搜索目录 + interface/target cfg + -c 选项"""
cmd = [openocd.strip()]
if scripts and scripts.strip():
cmd += ["-s", scripts.strip()]
cmd += ["-f", ensure_cfg_path(interface, "interface"),
"-f", ensure_cfg_path(target, "target")]
if speed and speed.strip():
cmd += ["-c", "adapter speed " + speed.strip()]
if serial and serial.strip() and "cmsis-dap" in interface.lower():
cmd += ["-c", "cmsis_dap_serial " + serial.strip()]
if extra and extra.strip():
cmd += ["-c", extra.strip()]
if script:
cmd += ["-c", script]
return cmd
def script_detect():
return "init; reset halt; flash list; exit"
def script_erase(flash_size_text):
size = parse_int(flash_size_text)
if size is None or size <= 0:
raise ValueError("Flash 大小无效: %s" % flash_size_text)
return ("init; reset halt; flash erase_address 0x%08X 0x%X; reset halt; exit"
% (FLASH_BASE_DEFAULT, size))
def script_flash(firmware, base_text="", erase=True, verify=True, reset=True):
"""HEX/ELF 用文件内地址(base_text 留空), BIN 必须给基地址"""
ext = os.path.splitext(firmware)[1].lower()
script = "init; reset halt; "
addr = ""
if ext == ".bin":
base = parse_int(base_text)
if base is None:
raise ValueError("BIN 基地址无效: %s" % base_text)
addr = " 0x%08X" % base
quoted = tcl_quote(firmware)
script += ("flash write_image erase %s%s; " if erase
else "flash write_image %s%s; ") % (quoted, addr)
if verify:
script += "verify_image %s%s; " % (quoted, addr)
script += ("reset run" if reset else "reset halt") + "; exit"
return script
def script_read(out_path, flash_size_text):
size = parse_int(flash_size_text)
if size is None or size <= 0:
raise ValueError("Flash 大小无效: %s" % flash_size_text)
return "init; reset halt; dump_image %s 0x%08X 0x%X; exit" % (
tcl_quote(out_path), FLASH_BASE_DEFAULT, size)
def script_reset():
return "init; reset run; exit"
# ============================================================
# 进程运行
# ============================================================
class OcdProcess:
"""后台跑一条 openocd 命令。
on_line(line)/on_done(code) 在工作线程里回调, GUI 侧自行经队列
投递回主线程; CLI 可直接在回调里 print。
"""
def __init__(self, cmd, on_line, on_done):
self.cmd = cmd
self._on_line = on_line
self._on_done = on_done
self.proc = None
self._thread = None
@property
def running(self):
return self.proc is not None
def start(self):
self._thread = threading.Thread(target=self._worker, daemon=True)
self._thread.start()
def terminate(self):
if self.proc is not None:
try:
self.proc.terminate()
except Exception:
pass
def _worker(self):
creation = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
try:
proc = subprocess.Popen(
self.cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
creationflags=creation,
)
except Exception as exc:
self._on_line("启动 OpenOCD 失败: %s" % exc)
self._on_done(-1)
return
self.proc = proc
try:
for line in proc.stdout:
line = strip_ansi(line.rstrip("\r\n"))
if line.strip():
self._on_line(line)
except Exception:
pass
code = proc.wait()
self.proc = None
self._on_done(code)
def run_sync(cmd):
"""阻塞运行一条 openocd 命令, 返回 (exit_code, 输出行)"""
lines = []
done = []
def on_line(line):
lines.append(line)
def on_done(code):
done.append(code)
job = OcdProcess(cmd, on_line, on_done)
job.start()
job._thread.join()
return (done[0] if done else -1), lines
def chip_target(chip_name):
return CHIP_PROFILES.get(chip_name, {}).get("target", "target/gd32e23x.cfg")
def chip_flash_size(chip_name):
profile = CHIP_PROFILES.get(chip_name)
return "0x%X" % profile["flash"] if profile else "0x10000"
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 DAPLink Flash 发布包: exe + 内置 OpenOCD 工具链 → 文件夹 + zip(解压即用)
================================================================
release/ 本身就是可直接使用的完整文件夹(exe + toolchain/ + 说明),
再原样压成 zip —— 两者内容始终一致。
zip 解压后根目录为 DAPLinkFlash/, 与 exe 运行目录结构一致:
DAPLinkFlash/
├── DAPLinkFlash.exe 主程序(先运行 build_exe.py 打包)
├── toolchain/openocd/ 内置 OpenOCD(bin + scripts, GUI 自动定位)
└── README.md 使用说明
OpenOCD 来源(--openocd-src > 环境变量 OPENOCD_SRC > 自动探测):
1. FlashTool/toolchain/openocd (随仓库自带, 若已 vendor)
2. D:/toolchain/openocd (开发机本机安装)
目录布局: <src>/bin/openocd.exe + <src>/openocd/scripts/
用法:
python package_release.py 版本号自动取最近 git tag(DAPLinkFlash-v*)
python package_release.py 0.1.0 手动指定版本号
python package_release.py 0.1.0 --openocd-src D:/toolchain/openocd
产物: release/DAPLinkFlash-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import argparse
import os
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
APP_NAME = 'DAPLinkFlash'
def detect_version():
"""从最近的 DAPLinkFlash-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'DAPLinkFlash-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix(APP_NAME + '-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: '
'python package_release.py <版本号>')
def resolve_openocd_src(explicit):
"""定位 OpenOCD 工具链源目录, 校验布局并返回路径"""
candidates = [explicit, os.environ.get('OPENOCD_SRC'),
os.path.join(BASE, 'toolchain', 'openocd'),
r'D:/toolchain/openocd']
for cand in candidates:
if not cand:
continue
exe = os.path.join(cand, 'bin', 'openocd.exe')
scripts = os.path.join(cand, 'openocd', 'scripts')
if os.path.isfile(exe) and os.path.isdir(scripts):
print(f'OpenOCD 工具链来源: {os.path.abspath(cand)}')
return os.path.abspath(cand)
sys.exit('未找到 OpenOCD 工具链(需 bin/openocd.exe + openocd/scripts/):\n'
' python package_release.py <版本> --openocd-src <toolchain/openocd 目录>\n'
' 或设置环境变量 OPENOCD_SRC')
def copy_toolchain(openocd_src):
"""复制 bin(openocd.exe + 运行 DLL)与 openocd/scripts 到 release/toolchain/"""
dst_root = os.path.join(RELEASE, 'toolchain', 'openocd')
if os.path.isdir(dst_root):
shutil.rmtree(dst_root)
count = 0
for sub in ('bin', os.path.join('openocd', 'scripts')):
src = os.path.join(openocd_src, sub)
dst = os.path.join(dst_root, sub)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copytree(src, dst)
for _root, _dirs, files in os.walk(dst):
count += len(files)
return count
def main():
ap = argparse.ArgumentParser()
ap.add_argument('version', nargs='?', default=None,
help='版本号, 默认取最近 DAPLinkFlash-v* git tag')
ap.add_argument('--openocd-src', default=None,
help='OpenOCD 工具链目录(含 bin/ 与 openocd/scripts/)')
args = ap.parse_args()
version = (args.version or detect_version()).lstrip('v')
exe = os.path.join(RELEASE, APP_NAME + '.exe')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit(f'缺少 release/{APP_NAME}.exe, 先运行: python build_exe.py')
if not os.path.exists(readme):
sys.exit(f'缺少 {readme}')
openocd_src = resolve_openocd_src(args.openocd_src)
n_files = copy_toolchain(openocd_src)
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'{APP_NAME}-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
z.write(exe, f'{APP_NAME}/{APP_NAME}.exe')
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:
full = os.path.join(root, f)
z.write(full, os.path.join(APP_NAME, os.path.relpath(full, RELEASE)))
exe_mb = os.path.getsize(exe) / 1024 / 1024
zip_mb = os.path.getsize(zip_path) / 1024 / 1024
print(f'发布文件夹: {RELEASE} (exe {exe_mb:.1f} MB + 内置工具链 {n_files} 个文件, 平铺即用)')
print(f'发布包: {zip_path} ({zip_mb:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag DAPLinkFlash-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(DAPLinkFlash-vX.Y.Z-win64.zip → DAPLinkFlash-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
APP_NAME = 'DAPLinkFlash'
DEFAULT_NOTES = """## DAPLink Flash {version}
GD32E230 固件烧录与打包工具: OpenOCD 烧录(检测/烧录/擦除/读取/复位)、
固件补齐(Pad, Y-Modem 对齐)、镜像拼接(Merge, Bootloader+APP+有效标志)。
### 安装
下载 `{app}-v{version}-win64.zip`, 解压后双击 `{app}.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `{app}.exe` — 主程序(单文件自包含)
- `toolchain/openocd/` — 内置 OpenOCD 工具链(openocd.exe + scripts, GUI 自动定位)
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help=f'release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix(APP_NAME + '-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version, app=APP_NAME)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'{APP_NAME} v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+3 -3
View File
@@ -3,15 +3,15 @@ cd /d "%~dp0"
where pyw >nul 2>nul
if %errorlevel%==0 (
start "" pyw -3 "daplink_flash_gui.py"
start "" pyw -3 "src\flash_gui.py"
exit /b
)
where pythonw >nul 2>nul
if %errorlevel%==0 (
start "" pythonw "daplink_flash_gui.py"
start "" pythonw "src\flash_gui.py"
exit /b
)
echo Python GUI launcher not found. Run manually: python daplink_flash_gui.py
echo Python GUI launcher not found. Run manually: python src\flash_gui.py
pause
+11 -14
View File
@@ -30,17 +30,14 @@
日常烧录优先使用图形界面工具,不需要打开 VSCode,也不需要记忆 OpenOCD 命令。
### 启动
- **免安装版**:到 [Gitea Releases](https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool/releases)
下载 `DAPLinkFlash-vX.Y.Z-win64.zip`,解压双击 `DAPLinkFlash.exe` 即可,
**包内自带 OpenOCD 工具链**`toolchain/openocd/`),目标机器无需任何环境。
- **源码运行**`python FlashTool/src/flash_gui.py` 或双击 `FlashTool\start_flash_gui.bat`
(需 Python 3.10+ 与 `pip install customtkinter`OpenOCD 默认找 `D:/toolchain`)。
```bat
双击 FlashTool\start_flash_gui.bat
```
或手动运行:
```bat
python FlashTool\daplink_flash_gui.py
```
详细说明见 [FlashTool/README.md](FlashTool/README.md)GUI 截图说明、命令行模式、
构建与发布流程)。下面是旧单文件版说明保留的要点:
### 功能
@@ -52,13 +49,13 @@ python FlashTool\daplink_flash_gui.py
- 烧录器:DAPLink / CMSIS-DAP、GD-Link、ST-Link、J-Link
- 烧录参数:适配器速度、探针序列号、烧录前擦除、烧录后校验、烧录后复位运行
- 固件处理:补齐(1K / 2K / 4K)、拼接 Bootloader + APP + 有效标志
- 工具链自动定位:内置(exe 旁 `toolchain/openocd`)→ 本机 `D:/toolchain` → PATH
### 配置
- 配置文件按主机名分开:`FlashTool/config/flash_gui_<主机名>.json`
- 首次打开自动创建 `config` 目录和当前电脑的配置
- 之后打开自动加载;每台电脑互不影响
- `FlashTool/config/` 已加入 `.gitignore`,不会提交
- 配置文件`flash_gui_settings.json`,保存在程序目录(exe 旁 / FlashTool 根),
自动保存与恢复
- 旧版 `config/flash_gui_<主机名>.json` 在新版首次启动时自动迁移
### 使用步骤