Python 串口助手(serial_tool)设计说明
版本:v1.1(需求 + 设计 + 实施记录)
日期:2026-09-15
状态:已实施(步骤 1–6 完成;本机无硬件自检项全部通过;CH340 已接入 = COM3,「拔线容错」与「烧录+抓开机日志全流程」两条正路径的硬件前提已具备,后者已由 /serial-boot-test 台架持续跑通)
关联文档:串口助手使用指南.md(命令用法/工作流/排障) · 自动化测试流程.md · serial-assistant skill(命令速查/陷阱) · .serialtest/config.json(端口单一配置源,2026-10-10 由 .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 工具不可用、/serial-boot-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 |
/serial-boot-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 |
/serial-boot-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 + "日志文件即接口"
- 单文件实现:
.dsh/skills/serial-assistant/scripts/serial_tool.py(stdlib + pyserial,约 1000 行);解释器统一 .venv\Scripts\python.exe
- Agent 调用式:
& '.venv\Scripts\python.exe' '.dsh/skills/serial-assistant/scripts/serial_tool.py' <命令> [参数](任意 cwd;state 目录按脚本位置解析,与 cwd 无关)
- 历史 = 日志文件:Agent 用 Read/Grep 直接看历史,无需专门 API(
log 命令只是便捷封装)
- 配置沿用
.serialtest/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 |
读日志(先过滤后截尾,2026-10-10) |
--tail N(默认 50;0 = 全部)--grep P -i --regex |
{"ok":true,"gen":8,"lines":["…"],"scanned":N,"matched":M} |
wait-idle |
等 RX 静默(/serial-boot-test 收尾判据) |
--settle-ms(默认 8000)--max-ms(默认 30000)--first-timeout-ms(默认同 max) |
{"ok":true,"rx_bytes":本次增量,"idle":bool,"waited_ms":N,"reason":"idle|no_data|timeout"} |
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)
以下均为运行时产物,未创建于仓库(首次运行工具后自动生成):
| 文件 |
用途 |
.dsh/skills/serial-assistant/scripts/state/serial.log |
RX/TX/SYS 全量日志(Agent 直接读) |
.dsh/skills/serial-assistant/scripts/state/meta.json(运行时未创建) |
daemon 元数据(pid / 版本 / 当前 gen) |
.dsh/skills/serial-assistant/scripts/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 读文件永不见半行。两条补充(2026-10-10 第三批):① 无换行残行 64KB 封顶(PARTIAL_MAX_BYTES)——板子跑飞/烧录期吐二进制垃圾时旧实现会无限攒在内存里(长会话可到几十 MB),且后续每条正常行都被拼在这坨垃圾尾巴上变成不可读长行;超限即当一行落地(解码回退成 HEX:)并自动重新对齐换行。② open / close / clear 是会话边界,残行一并丢弃(splitter_gen 计数,串口线程下一轮循环生效)——旧实现残行跨会话留存,上一段末尾被复位截断的半行会粘进新会话第一行,开机日志首行因此不可读 |
| 分行 |
按 \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 只能截尾;单写者线程内执行,无并发风险)。按行边界对齐(2026-10-10):取到的尾部会丢掉首个换行之前的残片——旧实现按字节偏移切,截出来的第一行是半行,直接破坏"文件只含完整行"这条不变量(Grep / verify_log 读到的首行是垃圾)
- 禁止 rename 轮转(Windows 下写句柄跟随被 rename 的文件)与重开文件/端口(CH340 释放后立即重开 → 读 0 字节 EOF 陷阱)
- 截头后写
[SYS] … log truncated to 10MB (cap=20MB);文件头内容可能随时消失 → Agent 读日志应看尾部(log --tail 或直接读文件末段)
6. daemon 行为规范
| 项 |
规定 |
| 单实例 |
HTTP 端口(默认 18765,.serialtest/config.json 的 httpPort 可改)绑定即锁;端口被占 = 已有实例。Windows 上真独占靠 SO_EXCLUSIVEADDRUSE(真名,值 -5;2026-10-10 修:旧代码写成 SO_EXCLUSIVEADDRPLACE + 数值 0x0040,setsockopt 抛 WinError 10042 被静默吞掉,"独占绑定"从未生效),失败现打 WARN,status.daemon.exclusive_bind 可验。⚠️ 进程列表里出现两个 --serve 是正常的:.venv\Scripts\python.exe 是 venv stub,会派生基解释器(本机 anaconda)子进程跑真 daemon(status.daemon.pid = 子进程),不是多实例 |
| 拉起方式 |
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 幂等路径) |
wait-idle 判据(2026-10-10 修) |
no_data = 本次等待期间零字节(旧实现比的是 daemon 累计 rx_bytes 是否为 0 → 常驻 daemon 下永不触发,板子全程静默也误报 idle:true,/serial-boot-test 的 no_data 拦截形同虚设);首字节到达前不启动 settle 计时(first_timeout_ms 兜底,默认 = max_ms);返回 rx_bytes = 本次增量 |
| CLI 超时与断连噪声(2026-10-10) |
wait-idle 未显式带 --timeout 时按 max_ms/1000 + 5 自动推导(旧版固定 5s 会在 daemon 还在等时掐断连接);handler 侧 ConnectionError(客户端已断开)不再往 daemon.log 打 traceback(本机曾累积 16 次);daemon.log 超 1MB 在拉起新实例前原地保尾 256KB |
| 配置读取(2026-10-10) |
按 utf-8-sig 读(容忍 BOM,与 run_boot_test.py 同口径);文件缺失 / JSON 语法错 / baudRate·httpPort 非整数不再静默回退成"没配端口",错误文本进 configError,由 status / ports / open 显式回显 |
7. 集成与迁移(/serial-boot-test 切换)
7.1 新 /serial-boot-test 流程(run_boot_test.py 采集步改写,退出码约定不变:0/1/2/3)
- 对比旧流程:6 个采集窗口参数 + JSON 解析 + EOF 重试 全部删除;采集 = 端口常开 + 读文件
verify_log.py 与纯文本日志契约一致(2026-09-20 随 /serial-boot-test 迁移 Python,判定语义不变)
- 参数保留:
-Port -BaudRate -FlashCmd -Required -Forbidden -OutDir;删除 -DurationMs 等窗口参数,新增 -SettleMs(静默判据,默认 8000——boot 任务间有 3~4s 自然静默间隙,2000 会截断)-MaxWaitMs(默认 30000)
7.2 配套变更清单
| 对象 |
变更 |
requirements.txt |
增 pyserial(保持纯 ASCII 注释) |
.gitignore |
增 .dsh/skills/serial-assistant/scripts/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 端到端验收(逐项过)
| 用例名 |
用例 |
判据 |
| 端口枚举 |
ports |
列表含 CH340 端口,configured 标注正确 |
| 端口被占用快速失败 |
端口被占用(另一进程先开 COM6)→ open |
≤1s 返回 ok:false(占用),无挂起 |
| 全流程走查 |
open→status→send→log→clear→close 全走一遍 |
全 ok:true;日志含 TX 行与 cleared 标记;gen 递增 |
| 强杀 daemon 自恢复 |
stop 后强杀 daemon(taskkill)→ 再发任意命令 |
自动拉起成功,open 幂等路径无 CH340 EOF 错误 |
| 日志截头 |
日志灌至 >20MB |
自动截头至 ~10MB,写入连续、无重开端口,[SYS] truncated 行存在 |
| 拔线容错 |
运行中拔 CH340 → status |
daemon 存活,state=error;重插 + open 恢复 |
| 烧录+抓开机日志全流程 |
/serial-boot-test E2E ×3(含一轮"上轮端口未关"场景) |
三轮全 PASS(Required 标记齐、无 Forbidden),boot 日志完整 |
| 无头自检 |
selftest |
0=PASS |
10. 实施步骤(按序执行)
| # |
步骤 |
产物 |
验证 |
| 步骤 1 |
requirements.txt 增 pyserial + .venv 安装 |
requirements.txt |
pip show pyserial 有版本 ✅ |
| 步骤 2 |
daemon 核心:HTTP 服务 + 串口读线程 + 行日志写(含 clear/截头/DTR 策略) |
.dsh/skills/serial-assistant/scripts/serial_tool.py(daemon 部分) |
start→status→send→stop 手动走通 ✅ |
| 步骤 3 |
CLI 全子命令 + 自动拉起 + 超时契约 + selftest |
serial_tool.py(CLI 部分) |
selftest 0=PASS + 命令走查 ✅ |
| 步骤 4 |
验收各用例 |
测试通过记录 |
本机可验项全部 PASS ✅;「拔线容错」项硬件前提已由 CH340(COM3)具备 |
| 步骤 5 |
/serial-boot-test 集成:run_boot_test.py 采集步改写 + 参数收敛 |
run_boot_test.py |
负路径(端口缺失→exit 2)+ 假烧录全链路 + verify 正/负判定 ✅;「烧录+抓开机日志全流程」正路径硬件前提已由 CH340(COM3)具备 |
| 步骤 6 |
文档/配置迁移(§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 与其他服务冲突 |
.serialtest/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 仓库内) |
当前方案 |