Python 串口助手(serial_tool)设计说明
版本:v1.1(需求 + 设计 + 实施记录)
日期:2026-09-15
状态:已实施(S1–S6 完成;本机验收 A1–A5/A8 通过,A6/A7 正路径待 CH340 硬件接入后补验)
关联文档:串口助手使用指南.md(命令用法/工作流/排障) · 自动化测试流程.md · AGENTS.md(串口调试/自动化测试节) · .opencode/serial_config.json(端口单一配置源,沿用)
取代:serial-mcp-server_串口调试.md(实施时删除,工具历史并入本文 §12)
1. 背景与目标
1.1 为什么重写
现串口调试 = Adancurusul/serial-mcp-server(Rust 二进制,手编译置于用户档案)+ MCP/CLI 双形态。两个致命缺陷(2026-09-15 本机实测):
| # |
缺陷 |
影响 |
| F1 |
二进制绑定用户档案:opencode.json 的 mcp.serial 指向 C:\Users\16241\AppData\Local\serial-mcp-server\*.exe,本机实际用户为 Administrator(该档案不存在,二进制缺失) |
serial MCP 工具不可用、@test 采集步骤断裂;换机器/换用户无法复现 |
| F2 |
无历史缓冲:read 只能读调用之后到达的字节 |
抓开机日志被迫走"采集窗口 + first-byte 触发 + EOF 重试"仪式(6 个参数 + JSON 解析 + CH340 释放后重开陷阱特判) |
Python 重写一并解决:依赖收敛到仓库 .venv + pyserial(任意机器/用户可复现);常驻 daemon 把全量 RX/TX 持续写入日志文件,历史 = 日志文件本身,采集窗口仪式整体消失。
1.2 目标
| # |
目标 |
| G1 |
Agent 可枚举 COM 口、连接;失败 ≤1s 快速返回,绝不阻塞 |
| G2 |
RX/TX 全量写入纯文本日志文件,Agent 直接读文件即得完整历史 |
| G3 |
Agent 一切操作 = 带硬超时(5s)的短命令;串口所有权归常驻 daemon |
| G4 |
一条 status 命令看全状态(daemon/端口/计数/最后 RX/日志文件/错误) |
| G5 |
随时清空日志(原子 + 代际号,新旧内容可区分) |
| G6 |
@test 一键流程切换新工具:open → clear → 烧录 → 读文件 → verify,无采集窗口 |
| G7 |
全流程不关端口(ST-Link 烧录不占 COM),从根上规避 CH340 释放/重开陷阱 |
2. 需求清单
| # |
需求 |
优先级 |
来源 |
| R1 |
ports:枚举 COM 口(名称/友好名/厂商,标注配置目标口) |
P0 |
用户 #1 |
| R2 |
open:连接端口;不存在/被占用 ≤1s 返回明确错误;参数 port/baud/DTR/RTS |
P0 |
用户 #1 |
| R3 |
RX/TX 全量时间戳日志(单文件,纯文本,行格式见 §5) |
P0 |
用户 #2 |
| R4 |
全部命令硬超时 5s + 失败返回 JSON(exit code 区分),CLI 永不挂起 |
P0 |
用户 #3 |
| R5 |
status:单条 JSON 全状态 |
P0 |
用户 #4 |
| R6 |
clear:原子清空 + 代际递增 + 文件内标记行 |
P0 |
用户 #5 |
| R7 |
常驻 daemon + 单实例 + CLI 自动拉起(Agent 无需记生命周期) |
P0 |
补充 |
| R8 |
日志 20MB 上限,超限原地截头保留尾部 10MB(不重开端口/不 rename) |
P0 |
补充(用户确认) |
| R9 |
开端口不翻转 DTR/RTS(防 STM32 自动复位电路误复位),可配置 |
P0 |
补充 |
| R10 |
串口读错/拔线 → daemon 存活,state=error,可重开;daemon 被杀可自恢复 |
P0 |
补充 |
| R11 |
@test 集成:run_boot_test.py 采集步改写 + verify_log.py 兼容 + 旧文档/配置迁移 |
P0 |
用户场景 |
| R12 |
send(TX 进日志)、log --tail/--grep、start/stop/restart、selftest 无头自检 |
P1 |
补充 |
3. 总体方案
3.1 形态:常驻 daemon + 薄 CLI + "日志文件即接口"
- 单文件实现:
tools/serial_tool/serial_tool.py(stdlib + pyserial,约 1000 行);解释器统一 .venv\Scripts\python.exe
- Agent 调用式:
& '.venv\Scripts\python.exe' 'tools/serial_tool/serial_tool.py' <命令> [参数](任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
- 历史 = 日志文件:Agent 用 Read/Grep 直接看历史,无需专门 API(
log 命令只是便捷封装)
- 配置沿用
.opencode/serial_config.json(portName/baudRate 为默认值;新增可选字段 httpPort,默认 18765)
3.2 "不卡死"的归属(对用户 #3 的重新表述)
串口读是长时操作,归 daemon 后台线程;Agent 侧只存在短命令。"不卡死" = 命令层契约:每条 CLI 命令有 5s 硬截止(--timeout 可覆盖),到期即返回超时 JSON 与 exit 2,不做任何无限等待。
4. 命令契约
4.1 子命令一览
| 命令 |
用途 |
关键参数 |
返回(stdout,恒 JSON) |
ports |
枚举 COM 口 |
— |
{"ok":true,"ports":[{"name":"COM6","description":"USB 串行设备 (CH340)"},…],"configured":"COM6"} |
open |
开端口(幂等:同端口已开 = 直接成功,不重开) |
--port --baud --dtr 0/1 --rts 0/1(默认取配置文件;DTR/RTS 默认 0) |
{"ok":true,"port":"COM6","baud":115200,"gen":7,"since":"…ISO…"} |
close |
关端口(flush 后释放) |
— |
{"ok":true} |
send |
发 TX 字节(进日志);默认精确字节不加换行 |
位置参数文本(UTF-8)或 --hex 0D0A;--nl 追加 CRLF |
{"ok":true,"bytes":12} |
status |
全状态(见 §4.2) |
— |
见 §4.2 |
log |
读日志(文件尾) |
--tail N(默认 50;0 = 全部)--grep P |
{"ok":true,"gen":8,"lines":["…"]} |
clear |
清空日志:原地截断 + 写 [SYS] log cleared (gen=N+1) |
— |
{"ok":true,"gen":8} |
start / stop / restart |
daemon 生命周期 |
— |
{"ok":true,"pid":…} / stop 后 {"ok":true,"stopped":true} |
selftest |
无头自检(行拆分器/解码回退/截头算法 单测回环,无需硬件) |
— |
0=PASS |
4.2 status 返回示例
state ∈ {stopped, active, error};port.ready = 已开 && state≠error。Agent 轮询等待只用本命令。
4.3 CLI 退出码契约
| exit |
含义 |
| 0 |
命令成功(ok:true) |
| 1 |
领域错误(ok:false + error:端口不存在/被占用/参数错…)——快速失败,≤1s |
| 2 |
daemon 不可达或命令超时(5s 硬截止) |
| 3 |
参数/用法错误 |
4.4 自动拉起
任何命令(除 start 本身)发现 daemon 未运行 → CLI 以 sys.executable(同一 .venv 解释器)detached 拉起 daemon(CREATE_NO_WINDOW,stderr → state/daemon.log),轮询 HTTP 就绪(≤5s)后重试原命令一次;仍失败按 exit 2 返回。Agent 无需任何显式 start 步骤。
5. 日志文件规范
5.1 位置与配套文件(目录整体 gitignore)
| 文件 |
用途 |
tools/serial_tool/state/serial.log |
RX/TX/SYS 全量日志(Agent 直接读) |
tools/serial_tool/state/meta.json |
daemon 元数据(pid / 版本 / 当前 gen) |
tools/serial_tool/state/daemon.log |
daemon 自身 stderr(排障用) |
5.2 行格式
| 规则 |
规定 |
| 方向标记 |
[RX] 串口入 / [TX] Agent 发出 / [SYS] 工具事件(open/close/error/cleared/truncated) |
| 时间戳 |
本地 ISO8601 含毫秒与偏移(2026-09-15T10:30:03.445+08:00);行时间戳 = 该行首字节到达时刻 |
| 完整行不变量 |
文件只含完整行:残行留 daemon 内存,200ms 无后续数据即冲刷成行(或 clear/close 时强制冲刷)→ Agent 读文件永不见半行 |
| 分行 |
按 \n 分(\r 剥除);纯二进制流(无换行)按 200ms 窗口成行 |
| 解码回退 |
UTF-8 → GBK → 仍失败则该行文本域写 HEX:<原始字节 hex>(MCU 流 = ASCII 为主 + UTF-8 中文,前两级足够) |
| flush |
每行即 flush(串口速率低,无缓冲压力)→ 文件随时可读、内容即时可见 |
5.3 清空与代际
clear = 写句柄上原地截断(seek(0)+truncate)+ 写 [SYS] log cleared (gen=N+1) 标记行;对写线程原子(单写者,截断与写入同一线程串行)
- 代际
gen 持久化于 meta.json(daemon 重启不丢);Agent 以"最后一个 cleared/opened 标记行"或 status.log.gen 区分新旧内容
5.4 体积上限(20MB,用户确认)
- 每次写后检查:
size > 20MB → 原地截头:在同一写句柄上读尾部 10MB → 截空 → 写回(Windows 无"丢弃头部"单系统调用,ftruncate 只能截尾;单写者线程内执行,无并发风险)
- 禁止 rename 轮转(Windows 下写句柄跟随被 rename 的文件)与重开文件/端口(CH340 释放后立即重开 → 读 0 字节 EOF 陷阱)
- 截头后写
[SYS] … log truncated to 10MB (cap=20MB);文件头内容可能随时消失 → Agent 读日志应看尾部(log --tail 或直接读文件末段)
6. daemon 行为规范
| 项 |
规定 |
| 单实例 |
HTTP 端口(默认 18765,serial_config.json 的 httpPort 可改)绑定即锁;端口被占 = 已有实例 |
| 拉起方式 |
CLI detached 子进程(CREATE_NO_WINDOW),解释器 = sys.executable(保证同一 .venv) |
open 语义 |
幂等(同端口已开 = 成功返回,不重开);端口不存在 → ≤1s 报错并附可用端口列表;被占用 → ≤1s 报错(Windows 共享冲突即时返回)并提示持有者 |
| DTR/RTS |
默认不翻转(dtr=False, rts=False,防 STM32 自动复位电路误复位目标板);--dtr/--rts 可显式置位 |
| 读线程容错 |
串口读异常/设备拔出 → 记 [SYS] … serial error: …,state=error(status.error 非空),daemon 不退出;重插后 open 重开恢复 |
| 重开退避 |
错误恢复路径重开端口前 ≥1.5s 退避(CH340 特性);正常流程不重开 |
stop |
关端口(flush 尾行)→ 写 [SYS] port closed → 退出 |
| 进程被强杀 |
Windows 进程退出自动释放 COM 端口;下次命令自动拉起新实例(open 幂等路径) |
7. 集成与迁移(@test 切换)
7.1 新 @test 流程(run_boot_test.py 采集步改写,退出码约定不变:0/1/2/3)
- 对比旧流程:6 个采集窗口参数 + JSON 解析 + EOF 重试 全部删除;采集 = 端口常开 + 读文件
verify_log.py 与纯文本日志契约一致(2026-09-20 随 @test 迁移 Python,判定语义不变)
- 参数保留:
-Port -BaudRate -FlashCmd -Required -Forbidden -OutDir;删除 -DurationMs 等窗口参数,新增 -SettleMs(静默判据,默认 8000——boot 任务间有 3~4s 自然静默间隙,2000 会截断)-MaxWaitMs(默认 30000)
7.2 配套变更清单
| 对象 |
变更 |
requirements.txt |
增 pyserial(保持纯 ASCII 注释) |
.gitignore |
增 tools/serial_tool/state/ |
opencode.json(仓库根) |
删除 mcp.serial 段(指向缺失二进制,已失效) |
测试说明/serial-mcp-server_串口调试.md |
已删除(内容精华并入本文;工具历史见 §12) |
AGENTS.md |
"串口调试"节改写为 serial_tool(配置源仍 serial_config.json);"自动化测试"节同步新流程 |
.opencode/command/test.md |
Step 0/2/Notes 改写(前置检查 = serial_tool 可用性 + ports;流程 = §7.1) |
docs/测试说明/自动化测试流程.md |
采集机制描述同步(去 serial-mcp-server 表述) |
.opencode/serial_config.json |
结构不变(可选增 httpPort);note 更新 |
8. 非目标与 v2 候选
v1 明确不做:多端口并发、MCP 封装、协议级 HEX 分析、硬件流控(RTS/CTS)、波特率自适应、GUI。
v2 候选:多端口(每口一 daemon 实例或子会话)、MCP wrapper(如需工具面板呈现)、按 tag 过滤的日志订阅(WebSocket/SSE)。
9. 测试与验收
9.1 无头自检(selftest,0=PASS)
行拆分器(CRLF/残行冲刷/纯二进制窗口)、解码回退链(UTF-8/GBK/HEX)、截头算法(合成大文件 → ftruncate 语义)——纯进程内,无需硬件。
9.2 端到端验收(逐项过)
| # |
用例 |
判据 |
| A1 |
ports |
列表含 CH340 端口,configured 标注正确 |
| A2 |
端口被占用(另一进程先开 COM6)→ open |
≤1s 返回 ok:false(占用),无挂起 |
| A3 |
open→status→send→log→clear→close 全走一遍 |
全 ok:true;日志含 TX 行与 cleared 标记;gen 递增 |
| A4 |
stop 后强杀 daemon(taskkill)→ 再发任意命令 |
自动拉起成功,open 幂等路径无 CH340 EOF 错误 |
| A5 |
日志灌至 >20MB |
自动截头至 ~10MB,写入连续、无重开端口,[SYS] truncated 行存在 |
| A6 |
运行中拔 CH340 → status |
daemon 存活,state=error;重插 + open 恢复 |
| A7 |
@test E2E ×3(含一轮"上轮端口未关"场景) |
三轮全 PASS(Required 标记齐、无 Forbidden),boot 日志完整 |
| A8 |
selftest |
0=PASS |
10. 实施步骤(按序执行)
| # |
步骤 |
产物 |
验证 |
| S1 |
requirements.txt 增 pyserial + .venv 安装 |
requirements.txt |
pip show pyserial 有版本 ✅ |
| S2 |
daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) |
tools/serial_tool/serial_tool.py(daemon 部分) |
start→status→send→stop 手动走通 ✅ |
| S3 |
CLI 全子命令 + 自动拉起 + 超时契约 + selftest |
serial_tool.py(CLI 部分) |
selftest 0=PASS + 命令走查 ✅ |
| S4 |
验收 A1–A6、A8 |
测试通过记录 |
A1–A5/A8 PASS ✅(本机无 CH340,A6 待硬件) |
| S5 |
@test 集成:run_boot_test.py 采集步改写 + 参数收敛 |
run_boot_test.py |
负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;A7 正路径待 CH340 接入 |
| S6 |
文档/配置迁移(§7.2 全清单) |
docs + 配置 |
全库 grep 无 serial-mcp-server 残留引用(本文 §12 工具历史除外) ✅ |
提交:全部完成后单提交(pre-commit 门禁;纯宿主侧 Python,不涉及 Keil 构建)。
11. 风险与开放问题
| # |
项 |
策略 |
| O1 |
换机器缺 CH340 驱动 → ports 无目标口 |
ports 返回带提示("未找到 CH340,请装驱动");文档列驱动要求 |
| O2 |
截头瞬间 Agent 恰在读文件 → 视图错位 |
已知可接受:Agent 读尾部;log 命令经 daemon 读更稳 |
| O3 |
daemon 强杀瞬间丢 ≤1 行(内存残行) |
可接受(debug 日志场景);close 路径保证冲刷 |
| O4 |
HTTP 端口 18765 与其他服务冲突 |
serial_config.json httpPort 可改 |
| O5 |
TX 用途(本工程 USART1 单向输出) |
保留能力,面向未来交互/RS485 调试;v1 仅保证进日志 |
| O6 |
daemon 常驻内存(Python 进程 ~20MB) |
可接受;stop 即释放 |
12. 工具历史
| 代 |
方案 |
结局 |
| 1 |
Serial Studio MCP(GUI 依赖、导出文件定位问题) |
已清理(2026-09 前) |
| 2 |
Adancurusul/serial-mcp-server(Rust,MCP+CLI) |
被本工具取代:二进制绑定用户档案(本机缺失)+ 无历史缓冲(采集窗口仪式) |
| 3 |
serial_tool(本文,Python 仓库内) |
当前方案 |
13. 变更记录
| 日期 |
变更 |
| 2026-09-15 |
v1.1 实施完成(S1–S6)。实施中实测发现并修复 4 个设计文档未覆盖的 Windows 实现级缺陷:① SO_REUSEADDR 在 Windows 允许多个活跃 socket 绑同端口 → HTTP 服务改独占绑定(SO_EXCLUSIVEADDRPLACE,发行版未导出时用数值 0x40 兜底)+ restart 强制确认旧实例死亡,防"already_running"假成功留下旧代码;② 写句柄 "ab" 追加模式在 Windows 禁读 → 溢出截头的"读尾部"必崩,改 "a+b"(自测用独立句柄故未暴露,真实 daemon 溢出路径已 E2E 验证);③ Windows 对不存在的 COM 口常报 Access Denied → 误报"port busy",改以 list_ports 枚举结果为"不存在/占用"判定准绳;④ 静默判据默认 2s 会截断 boot(任务间 3~4s 自然静默间隙),-SettleMs 默认改 8000ms。验收状态:A1–A5/A8 PASS(本机无 CH340,用 COM1 替代口验证;A5 溢出用进程内驱动真实 writer_loop 验证);A6/A7 正路径待硬件接入补验 |