From 4c6cdc3bf8b233aabd3937bb8981905e3d10411c Mon Sep 17 00:00:00 2001 From: hulk Date: Mon, 14 Sep 2026 18:54:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(flash-gui):=20=E5=AF=B9=E9=BD=90=20DLP?= =?UTF-8?q?ilot=20=E9=87=8D=E6=9E=84=20GUI=20=E5=B9=B6=E5=86=85=E7=BD=AE?= =?UTF-8?q?=20OpenOCD=20=E5=8F=91=E5=B8=83=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + FlashTool/README.md | 177 ++++-- FlashTool/daplink_flash_gui.py | 877 ----------------------------- FlashTool/src/app_gui.py | 664 ++++++++++++++++++++++ FlashTool/src/build_exe.py | 97 ++++ FlashTool/src/config.py | 101 ++++ FlashTool/src/flash_gui.py | 202 +++++++ FlashTool/src/fw_image.py | 98 ++++ FlashTool/src/logos/logo_flash.ico | Bin 0 -> 23340 bytes FlashTool/src/logos/logo_flash.png | Bin 0 -> 9671 bytes FlashTool/src/logos/make_ico.py | 47 ++ FlashTool/src/openocd_runner.py | 249 ++++++++ FlashTool/src/package_release.py | 125 ++++ FlashTool/src/publish_gitea.py | 156 +++++ FlashTool/start_flash_gui.bat | 6 +- README.md | 25 +- 16 files changed, 1896 insertions(+), 933 deletions(-) delete mode 100644 FlashTool/daplink_flash_gui.py create mode 100644 FlashTool/src/app_gui.py create mode 100644 FlashTool/src/build_exe.py create mode 100644 FlashTool/src/config.py create mode 100644 FlashTool/src/flash_gui.py create mode 100644 FlashTool/src/fw_image.py create mode 100644 FlashTool/src/logos/logo_flash.ico create mode 100644 FlashTool/src/logos/logo_flash.png create mode 100644 FlashTool/src/logos/make_ico.py create mode 100644 FlashTool/src/openocd_runner.py create mode 100644 FlashTool/src/package_release.py create mode 100644 FlashTool/src/publish_gitea.py diff --git a/.gitignore b/.gitignore index 6fdd6d4..012ede6 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/FlashTool/README.md b/FlashTool/README.md index f769d82..ebcfd69 100644 --- a/FlashTool/README.md +++ b/FlashTool/README.md @@ -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/ 内置 OpenOCD(GUI 自动定位) +│ ├── bin/openocd.exe + libftdi1.dll / libusb-1.0.dll +│ └── openocd/scripts/ interface / target 配置脚本 +└── README.md 本说明 ``` -完整镜像烧录基地址为 `0x08000000`;只烧 APP 时基地址为 `0x08002000`。 +## 3. GUI 使用 -## 使用 +主窗口为双栏仪表盘: -1. 连接 DAPLink 和目标板 SWD(SWDIO / SWCLK / GND)。 -2. 双击 `start_flash_gui.bat`,或执行: +``` +左列 右列 +┌ 探针与工具链 [自动定位] ┐ ┌ 补齐 (Pad) — Y-Modem 对齐 ┐ +├ 芯片与固件(型号/固件/基地址)┤ ├ 拼接 (Merge) — BL+APP+标志 ┤ +└ 操作: 检测/烧录/擦除/读取/复位/停止 ┘ └ 运行日志 — OpenOCD 输出 ┘ +``` - ```bat - python daplink_flash_gui.py - ``` +1. **选工具链**:默认自动定位(内置 → 本机 `D:/toolchain` → PATH),左卡右上 + "自动定位"可重新探测;也可手动浏览指定 `openocd.exe` 与 scripts 目录; +2. **选芯片与烧录器**:芯片型号下拉自动带出 Flash 大小和 target;烧录器下拉 + 自动带出 interface cfg;多探针时填序列号; +3. **选固件**:点"浏览"选 BIN/HEX/ELF;BIN 确认基地址(完整镜像 `0x08000000`, + 单独 APP `0x08002000`); +4. **点"检测设备"**确认链路通 → **点"烧录固件"**;输出实时显示在右下日志区; +5. **固件工具**(右列): + - 补齐:选输入 BIN 后输出名自动带出(`*_pad.bin`),对齐默认 1K,点"执行补齐"; + - 拼接:选 Bootloader 与 APP 后输出名自动带出(`*_BL.bin`),地址按典型布局 + 预填,点"执行拼接"。 -3. 选择芯片型号、烧录器型号、固件文件,点击“烧录固件”。 +典型布局:`0x08000000` Bootloader 8KB | `0x08002000` APP | `0x0800FFFC` +有效标志 `0xEEEEEEEE` | `0x08010000` Flash 结束 (64KB)。 -运行参数会自动保存到 `FlashTool/config/flash_gui_<主机名>.json`。首次打开会自动创建 -`config` 目录并生成当前电脑的配置;下次打开检测到就加载,没有就重新创建。 -每台电脑使用独立文件名,同步仓库时不会互相覆盖。 +配置(全部输入框的值)自动保存在 exe 旁的 `flash_gui_settings.json`,下次启动 +自动恢复;旧版 `config/flash_gui_<主机名>.json` 在首次启动时自动迁移。 -## 环境要求 +## 4. 命令行模式 -- Windows -- Python 3.10+(标准安装自带 tkinter,无需第三方库) -- OpenOCD(默认 `D:/toolchain/openocd/bin/openocd.exe`,可在界面中修改) +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 # 组装发布 zip(exe + 内置 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`。 diff --git a/FlashTool/daplink_flash_gui.py b/FlashTool/daplink_flash_gui.py deleted file mode 100644 index 45e121e..0000000 --- a/FlashTool/daplink_flash_gui.py +++ /dev/null @@ -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(">", 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("<>", 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() diff --git a/FlashTool/src/app_gui.py b/FlashTool/src/app_gui.py new file mode 100644 index 0000000..6c11299 --- /dev/null +++ b/FlashTool/src/app_gui.py @@ -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 ') diff --git a/FlashTool/src/build_exe.py b/FlashTool/src/build_exe.py new file mode 100644 index 0000000..fc1647b --- /dev/null +++ b/FlashTool/src/build_exe.py @@ -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() diff --git a/FlashTool/src/config.py b/FlashTool/src/config.py new file mode 100644 index 0000000..a2c960c --- /dev/null +++ b/FlashTool/src/config.py @@ -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 diff --git a/FlashTool/src/flash_gui.py b/FlashTool/src/flash_gui.py new file mode 100644 index 0000000..6ee94fa --- /dev/null +++ b/FlashTool/src/flash_gui.py @@ -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() diff --git a/FlashTool/src/fw_image.py b/FlashTool/src/fw_image.py new file mode 100644 index 0000000..c496ebb --- /dev/null +++ b/FlashTool/src/fw_image.py @@ -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("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 literal 0 HcmV?d00001 diff --git a/FlashTool/src/logos/logo_flash.png b/FlashTool/src/logos/logo_flash.png new file mode 100644 index 0000000000000000000000000000000000000000..110cff44a675744db19942b4a65e035375e179c0 GIT binary patch literal 9671 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 literal 0 HcmV?d00001 diff --git a/FlashTool/src/logos/make_ico.py b/FlashTool/src/logos/make_ico.py new file mode 100644 index 0000000..3a61317 --- /dev/null +++ b/FlashTool/src/logos/make_ico.py @@ -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}') diff --git a/FlashTool/src/openocd_runner.py b/FlashTool/src/openocd_runner.py new file mode 100644 index 0000000..d6b2bf2 --- /dev/null +++ b/FlashTool/src/openocd_runner.py @@ -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" diff --git a/FlashTool/src/package_release.py b/FlashTool/src/package_release.py new file mode 100644 index 0000000..a2d06d9 --- /dev/null +++ b/FlashTool/src/package_release.py @@ -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 (开发机本机安装) +目录布局: /bin/openocd.exe + /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 \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() diff --git a/FlashTool/src/publish_gitea.py b/FlashTool/src/publish_gitea.py new file mode 100644 index 0000000..04ada69 --- /dev/null +++ b/FlashTool/src/publish_gitea.py @@ -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 --notes-file release/notes-v0.1.0.md + python publish_gitea.py --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() diff --git a/FlashTool/start_flash_gui.bat b/FlashTool/start_flash_gui.bat index c85df00..beab37b 100644 --- a/FlashTool/start_flash_gui.bat +++ b/FlashTool/start_flash_gui.bat @@ -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 diff --git a/README.md b/README.md index 2f134f7..2774d1d 100644 --- a/README.md +++ b/README.md @@ -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` 在新版首次启动时自动迁移 ### 使用步骤