Skip to content
| Marketplace
Sign in
Visual Studio Code>Debuggers>uarttool — 串口助手New to Visual Studio Code? Get it now.
uarttool — 串口助手

uarttool — 串口助手

Aaronlu2026

|
4 installs
| (0) | Free
串口助手:UI + 配置文件 + CLI,面向人工调试与 AI 自动化
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

uarttool

串口助手: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"的误判)
  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft