串口助手:UI + 配置文件 + CLI,同时服务人工调试与 AI 自动化。
当前进度:P0(core / broker / CLI)、P0.5(适配器指纹)、P1(VSCode 扩展 UI)、P2(场景脚本)均已完成,
140 个测试。设计文档见 docs/DESIGN.md(源码仓库内)。
核心特点:UI 和 CLI 是同一个 broker 的两个客户端。 你在终端跑 uart send,
面板里会实时出现这条记录,并标上 [cli] 来源;AI 跑脚本时标 [script]。
人和 AI 看到的是同一份数据,谁发的也一目了然。
npm install && npm run build
npm run package # 产出 uarttool-0.1.0.vsix
快速开始
npm install
npm run build
node dist/cli.js ports # 看有哪些串口
node dist/cli.js doctor # 环境自检
node dist/cli.js doctor --loopback COM44 # 回环往返实测
典型调试流程
# 打开会话(broker 自动拉起,持有 COM 口)
uart open --port COM44 --baud 115200 --framing line
# 发命令、等回复
uart send --text "AT+VER" --line-ending crlf
uart expect --regex "OK" --timeout 1000
# 看最近的数据(能看到每条是谁发的)
uart tail -n 20
# 抓一段存盘再离线分析
uart capture --duration 5000 --out logs/run.ndjson
uart summary logs/run.ndjson
uart decode logs/run.ndjson
# 收尾(会释放 COM 口)
uart close --all
给 AI / 脚本用
所有命令都支持 --json,退出码有语义,错误只走 stderr(不污染 stdout 的 JSON):
uart --json ports
uart --json expect --hex "01 04 02" --timeout 1000
uart summary logs/run.ndjson --json
uart schema config # 直接拿到 JSON Schema,不用读文档
退出码:0 成功 / 1 断言失败 / 2 用法错误 / 3 端口错误 / 4 broker 错误 / 5 脚本错误。
架构要点
Broker 独立进程
Windows 上 COM 口是独占的。如果扩展或某个 CLI 进程直接持有串口,AI 就没法同时操作,
扩展重载还会掉线。所以引入一个 detached 的 broker 守护进程作为唯一持有者:
扩展宿主 ─┐
├─► Broker(独立进程,不随调用方退出)──► COM7
CLI ──────┘
副产品是 CLI 每次调用都是短命进程,却能跨调用保持连接状态 —— AI 不需要自己管会话。
IPC 走命名管道(Windows)/ Unix socket(POSIX)+ 随机 token 握手。
broker 信息写在 os.tmpdir()/uarttool/broker.json,进程死了会被自动识别并重建。
会话什么时候结束
broker 活得比扩展久是有意的(扩展重载不掉线、CLI 能跨调用保持连接),
但会话不能赖着不走 —— 否则关掉窗口后 COM 口被永久占住。所以按来源区分:
| 谁开的 |
生命周期 |
扩展 UI(ownedByClient) |
跟随 UI 客户端。关窗口即释放串口,不用手动点"断开" |
| CLI / 脚本 |
独立于连接存在。CLI 每次调用都是短命进程,按连接断开关会把 uart open 之后的 uart send 全废掉 |
UI 会话在客户端断开后有 5 秒宽限期才真正关闭,这样"重载窗口"能赶上 ——
新宿主在宽限期内重新订阅就保住了会话。重载不掉线和关窗口自动释放因此可以同时成立。
扩展崩溃、被强杀也一样能回收(不依赖 deactivate() 是否跑完)。
设备被拔掉时回收是立刻的,不走宽限期 —— 传输层一断,broker 就把会话移除并通知所有订阅者,
界面会提示"串口连接已断开 —— 设备是否被拔掉?"。不会留下"死了但还在列表里"的会话。
宽限期可调:uart __broker --client-grace <ms>(设 0 表示立即释放)。
分帧与 expect 都基于字节流
实测 CH340 驱动固定以 32 字节为单位投递数据,一条 100 字节的日志会以 [32,32,32,4] 到达。
所以:
- 重组缓冲是强制项:
data 事件 ≠ 一帧
expect 匹配的是字节流,不是逐帧 —— 一个模式完全可能跨越多个驱动块
expect 的匹配起点
--since 控制从哪儿开始找:
| 值 |
含义 |
last-tx(默认) |
上次发送之后收到的数据。CLI 里 send/expect 是两个进程,回显往往在 expect 启动前就到了,所以默认回溯到上次发送点 |
now |
只看本次调用之后到达的数据 |
begin |
缓冲区里的全部数据("之前出现过 X 吗") |
配置文件
分层加载,优先级从低到高:
内置默认 < ~/.uarttool/config.json < <cwd>/.uarttool/config.json
< <cwd>/.uarttool/local.json < --config <path> < 环境变量 UARTTOOL_* < CLI 参数
local.json 是刻意留的一层:团队共享 profile 语义,个人只覆盖本机 COM 号,避免 git pull 冲突。
完整示例见 configs/uart.config.json(源码仓库内)。两个要点:
端口按 USB 标识匹配,而不是写死 COM 号
"ports": {
"dut": { "match": { "vid": "0x1A86", "pid": "0x7523" } }
}
换个 USB 插口导致 COM7 变 COM9 时,配置不用改。
编辑器补全 / AI 自描述
uart schema config > uart-config.schema.json # 然后在配置首行写 "$schema"
VSCode 扩展
装好 VSIX 后,活动栏会出现 uarttool 图标,里面两棵树:会话 与 适配器指纹。
命令面板(Ctrl+Shift+P → uarttool):
| 命令 |
说明 |
| 打开串口面板 |
主界面:连接、收发、宏 |
| 连接串口… |
选端口 → 波特率 → 分帧方式 |
| 断开当前会话 |
释放串口 |
| 运行场景脚本… |
选一个 yaml 丢进集成终端跑 |
| 打开 Trace 文件… |
把 NDJSON 渲染成带统计的可读文档 |
| 测量适配器指纹… |
生成性能指纹(会问你是否允许写入端口) |
| 显示 CLI 用法(给 AI 用) |
生成一份可直接贴给 AI 的速查文档 |
| 关闭 broker |
释放所有串口 |
面板
┌─ 工具栏 ────────────────────────────────────────────────┐
│ [profile▾] [端口▾] [波特率] [分帧▾] [连接] [⟳] │
├─ 状态行 ────────────────────────────────────────────────┤
│ COM44 │ 115200 │ 按 0x0a 切帧 │ CH340 · 固定32字节 · 往返2.2ms │ ● 录制中 │
├─ 收发列表 ──────────────────────────────────────────────┤
│ +0.000 → [cli] AT\r\n │
│ +0.002 ← OK\r\n │
│ +0.145 → [script] 01 04 02 ff │
├─ 发送区 ────────────────────────────────────────────────┤
│ [文本▾] [CRLF▾] [________________________] [发送] │
│ 宏: [Ping] [Reset] │
└─────────────────────────────────────────────────────────┘
几个实现上的要点:
- 收发列表是虚拟滚动。实测 CH340 在 115200 下约 365 事件/秒,921600 下约 2900/秒,
全量渲染会卡死。只渲染视口内的十几行。
- 扩展侧合批推送:默认每 50ms 一批、单批上限 500 帧。UI 跟不上时丢弃并如实计数
(状态行显示 ⚠),磁盘上的 trace 文件始终是全量。
- 不引前端框架。这个刷新频率下 VDOM 的协调开销纯属负担,直接操作 DOM 更快,
也让 VSIX 少一堆依赖(webview 包只有 21KB)。
- 扩展不持有串口。它和 CLI 一样只是 broker 的客户端 —— 重载窗口不掉线,
卡死的脚本也冻不住它。
快捷键:Esc 暂停自动滚动,Ctrl+L 清空,↑↓ 翻发送历史,点某一行可把它填进发送框。
适配器性能指纹
不同 USB-串口芯片(CH340 / CP2102 / FT232 / PL2303 / 原生 UART)行为差异很大 ——
分块大小、往返延迟、静默分辨率、线速利用率都不同。写死在文档里的数字换个适配器就是错的。
所以每个适配器测一份指纹存下来,用时按 VID:PID 自动加载:
# 测量(默认只被动观察,不写端口)
uart analyze --port COM44
# 完整探测(允许写入;会先跑随机 nonce 回环闸门,非回环立即中止)
uart analyze --port COM44 --active
uart devices # 列出已测指纹
uart devices show 1a86-7523 # 看某一份
uart analyze --port COM44 --check # 与基线对比漂移(不覆盖基线)
指纹存在 ~/.uarttool/devices/<vid>-<pid>.json(用户级,与项目无关 ——
硬件性能是"这台机器 + 这支适配器"的属性,换项目不用重测,换机器必须重测)。
uart open 会自动加载并显示:
已打开会话 COM44: COM44 @ 115200 8-N-1
分帧: 按 0x0a 切帧(含分隔符)
适配器: USB-SERIAL CH340 (COM44) 回环 固定 32 字节 往返 2.2ms ⚠ 1 项
CH340 实测指纹长这样:
{
"key": "1a86-7523",
"chunking": { "mode": "fixed", "sizeBytes": 32 },
"latency": { "oneByteMs": { "p50": 2.2, "p95": 2.4 } },
"throughput": { "utilizationPct": 100 },
"gapResolutionMs": 20,
"drainOverheadMs": 0.13,
"host": { "timerGranularityMs": 15.6 },
"quirks": ["驱动按固定 32 字节投递 —— 一帧可能跨多个事件,分帧必须重组"]
}
安全边界
analyze 默认不写端口。往真实设备写数据可能触发复位/OTA/擦写,必须显式加 --active。
即使加了,也会先发 16 字节随机 nonce —— 只有逐字节原样回显才认定是回环并继续;
否则立即停止写入,只保留被动观察结果。
指纹里为什么记主机信息
host.timerGranularityMs 是主机属性不是设备属性,但必须记 ——
所有超时与静默判定的实际下限都被它卡住,换台机器同一支适配器行为就变了。
--check 会把它标为"不显著"(换机器必然变),而分块方式、延迟这类设备属性的变化才标为显著。
场景脚本
给 AI 和 CI 用的声明式回归测试。YAML 或 JSON 都行。
uart run scripts/loopback-smoke.yaml # 跑
uart run scripts/loopback-smoke.yaml --json # 机器可读结果
uart run scripts/loopback-smoke.yaml --dry-run # 只校验并列出步骤
uart run s.yaml --var payload="DE AD BE EF" # 覆盖变量
name: 回环基础验证
device: 1a86-7523 # 声明期望的适配器指纹 —— 换适配器会立刻发现
onDeviceMismatch: warn
defaults:
timeout: auto # 按适配器指纹推导
onFailure:
capture: 2000 # 失败时多抓 2 秒原始数据存成 trace
vars:
payload: "01 04 00 00 00 01 31 CC"
steps:
- name: 文本收发
send: { text: "AT", lineEnding: crlf }
expect: { regex: "AT", timeout: 1000 }
- name: 取版本号并断言
send: { text: "VER=3.4.1", lineEnding: crlf }
expect: { regex: "VER=(\\d+\\.\\d+\\.\\d+)" }
save: { version: "$1" } # 从捕获组存变量
assert:
- { var: version, matches: "^3\\." }
- { var: version, notEquals: "0.0.0" }
- name: 循环压力
repeat: 5
steps:
- send: { hex: "AA 55" }
expect: { hex: "AA 55", timeout: 500 }
- name: 允许失败
optional: true
send: { text: "NOPE" }
expect: { regex: "NEVER", timeout: 200 }
步骤原语:send / expect / sleep / mark / save / assert / repeat / optional。
一个步骤里可以组合多个动作(send + expect 最常见)。
超时可以用设备指纹算
这是把指纹当判据用的地方 —— 不同适配器的合理超时不同,写死数字换台机器就不对了:
expect: { regex: "OK", timeout: "{{device.latency.p95Ms * 20}}" }
timeout: auto 则按传输层下限推导((延迟p95 + 定时器粒度×2) × 4,保底 500ms)。
auto 推导的是传输层的物理下限之上的安全值,不是"预测设备要多久回复"。
设备的处理时间跟适配器无关,推不出来。
可用的设备变量(uart schema script 会一并列出):
device.latency.p50Ms / p95Ms / maxMs、device.chunking.sizeBytes、
device.throughput.utilizationPct、device.gapResolutionMs、device.host.timerGranularityMs …
失败输出
失败时退出码 1,并且自带现场:
脚本 失败现场演示 ✗ 失败 用时 1.83s
端口 COM44 @ 115200 profile=loopback
适配器 1a86-7523 USB-SERIAL CH340 (COM44)
✓ 0 这一步会通过 3.00ms
✗ 1 这一步必然超时 303ms
300ms 内未等到 /SHOULD_NEVER_APPEAR/(脚本显式指定)
期望 {"regex":"SHOULD_NEVER_APPEAR"}
实收 AT+NOTHING\r\n
- 2 前面的失败会让这步被跳过 0.00ms
失败: 步骤 1 (这一步必然超时) 失败: expect-timeout
现场: logs/fail-demo-20261017-000218-fail.ndjson
--json 时每个步骤都带 status / expected / received(hex + text)/ reason,
外加抓包文件路径 —— AI 拿到就能定位,不用翻日志。
退出码:0 通过 / 1 断言失败 / 5 脚本错误(校验不过、变量不存在等)。
写脚本时的两个注意点
expect 默认从上次发送开始匹配(since: last-tx),这符合"发命令→等回复"的直觉。
想查"之前是否出现过 X"用 since: begin。
- 超时不要小于 50ms,会被主机定时器粒度(~15.6ms)量化。脚本校验会直接拒绝。
已知的物理限制
这些是实测出来的,不是实现缺陷,写在这里避免踩坑:
| 现象 |
原因 |
| 超时低于 ~50ms 没意义 |
主机定时器粒度约 15.6ms(Windows 默认调度粒度)。setTimeout(1) 实际等约 15ms,setTimeout(16) 实际等约 29ms。与串口硬件无关 |
gap 分帧最后一段数据晚约 15ms 才出帧 |
帧间边界靠"下一个数据到达"立即判定(准确),但尾部只能靠定时器发现静默 |
| Modbus-RTU 的 3.5 字符静默做不到 |
USB 轮询 + 32 字节分块把分辨率下限卡在约 1ms;115200 下 3.5 字符约 0.3ms |
drain() 不代表"已发出" |
它的语义是"已交给 OS 缓冲",只在缓冲写满时产生背压。正确性由 expect 保证 |
往返延迟本身约 2ms(CH340 回环实测),不是问题。
上面这些数字都是 CH340 的。换适配器请跑 uart analyze 用自己的指纹。
分帧方式怎么选(最容易踩的坑)
收文本日志一定要用 line 或 crlf,不要用 raw。
raw 是"驱动给什么就显示什么",而 CH340 是按约 32 字节切块投递的。
一条 58 字节的日志会被切成 [...slee] + [p ticks=...] 两段 ——
词被从中间切断,看起来像数据坏了。实测复现:
line 分帧:
← LED state: OFF\r\n
← pm: state=2 sleep ticks=23934000, ms=997\r\n ← 两条完整行
raw 分帧(同一份数据):
← LED state: OFF\r\npm: state=2 slee ← 在 "slee|p" 处断开
← p ticks=23934000, ms=997\r\n
分帧方式可以热切换,不用断开重连:
uart framing line # CLI
面板里直接改分帧下拉框即可。状态行始终显示会话实际在用的分帧方式。
注意:你的固件每秒打印几条,就显示几行。如果固件本身用两次 printf
输出两条 \r\n 结尾的行,那就是两条日志,不是工具把它们拆开了 ——
用 uart tail --hex 看字节就能确认。
时间戳与级别着色
每行前面是绝对时间 HH:mm:ss.SSS(相对首帧的时间在 tooltip 里)。
正文按日志级别着色,判定分三级,优先级从高到低:
| 依据 |
规则 |
| ANSI 颜色码 |
红色 → error,黄色 → warn。最可靠,SDK 通常就是按级别着色的 |
| 显式标记 |
[ERROR] / [E] / <err> / E/TAG: / ERROR:(允许时间戳前缀,如 [12:34:56] [ERROR]) |
| 关键词兜底 |
error/fail/fatal/denied… → error;warn/timeout/retry… → warn |
- error 红字 + 左侧红边,warn 黄字 + 左侧黄边,info 蓝字,debug 暗字,普通行用默认前景色
- ANSI 转义码会被剥掉再展示 —— 判级别时它是信号,展示时它是乱码
- 关键词表刻意取窄:宁可漏判(显示成普通行)也不要误判(满屏飘红)。
比如
Critical section entered 这种并发术语不会被当成错误
- 每行的 tooltip 里写着判定依据(
级别: error(依据: keyword "failed")),
误判时能一眼看出是哪条规则命中的
链路质量检测
uart summary 会统计可疑帧:文本帧里出现 UTF-8 解码失败或不该有的控制字符。
这类问题肉眼极难发现,但会静默污染日志和命令。实测遇到过一例:
一个字节的 bit 7 翻转(0x0a → 0x8a),\r\n 分隔符失效,
两条日志被粘成一帧 —— 界面上一行显示两行内容。
⚠ 可疑帧: 2 —— 文本帧里出现解码失败或异常控制字符,
多半是链路比特错误(查波特率是否精确匹配、接线是否松动、是否共地)
行尾不显示
帧末尾的换行(\r\n / \n / \r)不会渲染出来 ——
分帧已经保证了帧以换行结尾,再显示成 \r\n 就是每行都有的噪声。
只去掉结尾那一个:OK\r\n\r\n 这种带空行的会显示成 OK\r\n,
不会把空行吃掉。行内的换行和其它控制字符(\t、\0 等)照旧转义显示,
所以异常字符仍然看得见。
想看真实字节随时切 HEX 模式。
文本与二进制的判定
每一帧会算一个"可打印字符占比",低于 0.8 就打上 binary 标记,
界面和 CLI 据此决定按文本还是十六进制展示。
判定是按 UTF-8 解码后逐【字符】做的,不是逐【字节】。
中文每字占 3 字节且全部 ≥ 0x80,逐字节判断会把整条中文日志误判成二进制 ——
这是早期版本的真实 bug,现已修复并加了回归测试。
文本日志建议用 line 分帧。 分帧器会先把驱动切碎的块重组成完整行再分类,
判定才准。用 raw 分帧时,帧就是驱动的 32 字节块,中文很容易在块边界被切断,
短帧的占比会失真。
命令一览
ports 列出串口(含 VID/PID/序列号)
open 打开会话 close 关闭会话
status 查看会话 send 发送数据
expect 等待模式出现 read 读取一段时间 / 读到某模式
capture 抓一段存盘 tail 看最近数据
mark 往 trace 打锚点 trace 控制录制开关
decode 渲染 trace 文件 summary 统计 trace(含 ANSI 检测)
analyze 测量适配器指纹 devices 查看已测指纹
run 执行场景脚本 schema 打印 JSON Schema(config / script)
doctor 环境自检(含回环实测) framings 列出分帧方式
shutdown 关闭 broker
uart <命令> --help 看具体参数。
开发
npm run build # esbuild 打包 dist/cli.js
npm run typecheck # tsc --noEmit
npm test # esbuild 打包测试 + node --test
测试用 Node 内置的 node:test(不用 vitest —— 这台机器内存紧张时 vitest 的 worker 会崩),
测试文件先经 esbuild 打成 CJS 再跑。
tools/ 下是几个一次性探针脚本,记录了关键结论的实测过程:
probe-loopback.mjs — 回环验证 + 驱动分块行为
latency-probe.mjs — 往返延迟归因(推翻了早期"16ms latency timer"的误判)