基于硬件设备的虚拟串口间通信:双CDC透传与排障实践

发布时间:2026/10/28 23:04:27
基于硬件设备的虚拟串口间通信:双CDC透传与排障实践

做过嵌入式联调的朋友大概都遇到过这种场景:手上有块板子,跑着一套串口协议,上位机软件却只有一个串口号可用,两个要通信的程序互相抢端口;又或者设备本身需要"自己和自己对话",比如一路串口收指令、另一路串口转发出去做透传,中间还要能抓包看数据。这时候"基于硬件设备进行虚拟串口间通信"就成了一条很实在的路子——用一块带 USB 外设的硬件,配合主机侧的驱动和虚拟串口对机制,凭空造出几个逻辑上互相独立的串口,让它们之间能稳定地收发数据,而不必占用物理 DB9 接口。

这套东西解决的并不是什么高深问题,本质上就是把"物理线缆连接两个串口"这件事,搬到软件和 USB 通道里完成,同时保留串口编程接口不变——你的老代码里SerialPort.Open()或者serial.Serial()一行都不用改,照样跑。适合谁看?做下位机固件的、写上位机上位软件的、做设备出厂测试的、以及需要复现客户现场串口问题的技术支持,都能从这里拿到能直接落地的东西。下面我按自己实际搭过几套环境的顺序,把思路、原理、步骤和踩过的坑一次讲透。

1. 需求拆解与技术路线选型

1.1 这个题目究竟要解决哪些实际问题

先把这个标题拆开看。"基于硬件设备"说明通信的落脚点不是纯软件模拟,得有一块真实硬件参与;"虚拟串口间通信"说明要通信的两端都是操作系统里枚举出来的虚拟串口设备节点,而不是两个物理 DB9 口对着连线。合起来,要解决的是三类很具体的麻烦。

第一类是端口数量不够。很多工控上位机软件写死了只认 COM1 到 COM4,或者要求收发两端必须是两个独立端口。笔记本早就没有物理串口了,插一个 USB 转串口模块只能得到一个端口,两个程序抢一个口必然打架——一个打开了另一个就报"拒绝访问"。

第二类是链路中间需要可观测。真实设备对真实设备,中间想插个抓包工具很难,除非专门做一块带监听口的板子。虚拟串口对的好处是数据在操作系统内部流转,随时可以在中间挂一个转发程序,把每一帧原样落盘。

第三类是测试环境要可复现。产线测试、回归测试需要反复构造异常帧、超长帧、半包数据,用真实串口和真实下位机很难控制时序和内容,虚拟串口对配上脚本就很容易做到。

还有一类是设备内部的双通道透传。某些板子上跑的是 USB 复合设备,同时向主机暴露两个 CDC 接口,主机侧看到两个虚拟串口,板子内部把 A 口收到的数据转发到 B 口。这其实也是"虚拟串口间通信",只不过通信的桥梁是 MCU 的固件而不是主机侧的虚拟串口驱动。

1.2 三条技术路线的对比与选型

真动手之前我把能想到的方案都列了一遍,最后归纳成三条,各有各的适用边界。

第一条是主机侧纯软件虚拟串口对。装一个虚拟串口驱动,它在系统里凭空创建一对(或多对)串口设备节点,写入 A 的数据会直接出现在 B 的接收缓冲区里。这条路线不需要任何硬件,成本最低,但它是"纯软"的,标题里"基于硬件设备"的部分要靠另一头接一个真实的 USB 转串口模块来补——比如 A 端是虚拟口,B 端是真实硬件口,这样既保留了硬件参与,又能让上位机软件以为自己接了两个口。

第二条是真实 USB 转串口硬件做环回。把模块的 TX 和 RX 短接,自己发自己收;或者用两个模块,TX 交叉接 RX。这是最"硬"的方案,能真实验证电平、时序、误码,缺点是只能验证一个端口,两个端口就意味着两个模块两份驱动。

第三条是设备内部双 CDC 复合设备。一块带 USB 的 MCU,固件里配置成两个 CDC-ACM 接口,主机枚举出两个虚拟串口,MCU 内部做数据搬运,可以加过滤、加时间戳、加协议转换。这条路线最贴近标题字面意思,也最有产品价值,但对固件能力要求最高。

我实际采用的是第二条与第三条混合的思路:硬件侧用一块带 USB 的板子做成双虚拟串口透传,主机侧再配合虚拟串口对软件做端口映射和抓包。理由很直接——纯软件方案验证不了 USB 枚举、缓冲区、流控这些真问题;纯硬件双模块方案又太笨重,插拔两次才能换一次配置。混合方案一次搭好,后面改协议只动固件和脚本。

路线硬件依赖能验证什么搭建成本适合场景
纯软件虚拟串口对无应用层协议、粘包处理低上位机开发、协议联调
USB 转串口环回一个模块电平、波特率、误码中硬件自检、出厂测试
设备内双 CDC一块 MCU 板枚举、端点、固件转发高产品化透传、协议网关
混合方案一块板 + 虚拟口软件以上大部分中高我推荐的通用做法

1.3 整体数据流与关键约束

把链路画清楚,后面排查才有依据。以混合方案为例,数据从"应用 A"出发,经过系统串口 API 写入虚拟串口 V1,虚拟串口驱动把这段字节交给配对的虚拟串口 V2,V2 上挂着我的转发脚本,脚本读到数据后加上时间戳和方向标记落盘,同时通过 USB 写到硬件板子的 CDC 接口,板子固件收到后从另一个 CDC 接口回吐,主机侧表现为虚拟串口 V3,最终被"应用 B"读到。

这条链路上有三个必须提前定死的约束,否则后面一定返工。

约束一是端口命名。Windows 下 COM 号会随着插拔变化,同一个模块插不同 USB 口可能变成 COM7 也可能变成 COM9。Linux 下更明显,/dev/ttyACM0和/dev/ttyACM1谁是谁取决于枚举顺序。我的做法是提前在设备管理器里查 USB 序列号,Windows 上用注册表或工具固定端口号,Linux 上写 udev 规则按序列号绑定别名。

约束二是超时策略。串口读默认是阻塞的,读超时设太长会让整个转发线程卡死,设太短又会把一帧数据切成两半。我一般设 100 毫秒读超时,配合"读到就重置计时器"的粘包处理逻辑。

约束三是缓冲区大小。驱动层、内核层、应用层各有一层缓冲。压测时如果发送速率超过消费速率,最先溢出的一定是最小的那一层。这个后面在第 4 章会专门讲怎么定位。

2. 虚拟串口在硬件上是怎么跑起来的

2.1 USB 枚举与 CDC 类描述符,决定了主机看到几个口

很多人以为"虚拟串口"是驱动凭空变出来的,其实主机侧看到的每一个虚拟串口,背后都对应 USB 设备描述符树里的一个接口。USB 设备插入后,主机先读设备描述符拿到 VID/PID 和配置数量,再读配置描述符,里面列出这个配置下有几个接口、每个接口几个端点、端点类型和最大包长。CDC-ACM 类设备典型结构是:一个通信控制接口(用来下发波特率、流控等线路编码命令),一个数据接口(挂着两个批量端点,一个 IN 一个 OUT)。

关键在于,一个 USB 设备可以声明多个 CDC 功能。固件里把配置描述符写成两个接口关联描述符(IAD),主机枚举时就会装上两次 CDC 驱动,于是"设备管理器"里出现两个虚拟串口。这就是设备内双虚拟串口的实现基础,不需要什么黑科技,只是描述符组织方式的差别。

这里有个很容易翻车的细节:全速 USB 的批量端点最大包长是 64 字节,一次传输超过 64 字节会被拆成多个包。如果固件里缓冲区按 64 字节对齐处理不当,短包和零长度包(ZLP)的处理就会出现"明明发了 100 字节,主机只收到 64 字节"的现象。我在这上面卡了整整一个下午,最后发现是固件没在整 64 字节倍数时补 ZLP,主机侧的传输层一直等后续包。

提示:改描述符之后一定要彻底卸载旧驱动再重新枚举,Windows 有缓存机制,改过的 PID 没变时可能沿用旧描述符信息,导致端口数量对不上。

2.2 波特率参数的真相,以及误差怎么算

这是虚拟串口最容易误解的地方。真实的 UART 硬件,波特率决定了每一位占用的时间宽度,收发双方必须严格一致,误差超过 2% 到 3% 就开始出错。而 USB 虚拟串口的"波特率"只是 CDC 类里的一条线路编码命令,主机把它下发给设备,设备可以当回事,也可以完全忽略——数据实际是按 USB 的批量传输速率在跑,跟 9600 还是 115200 半毛钱关系都没有。

那为什么还要设?两个原因。一是兼容老代码,很多上位机库打开串口必须给一个波特率参数;二是当设备内部真的把 CDC 数据桥接到一路物理 UART 时,这个参数会被用来配置那路真实 UART 的分频寄存器,这时候它才真正起作用。

一旦涉及真实的 UART 桥接,误差计算就必须认真对待。以 16 MHz 时钟、目标 9600 波特率、16 倍过采样为例,分频值 = 16 000 000 / (16 × 9600) − 1 ≈ 103.17,取整得 103,实际波特率 = 16 000 000 / (16 × 104) ≈ 9615.4,相对误差约 0.16%,完全在容忍范围内。但如果换成 115200,分频值 = 16 000 000 / (16 × 115200) − 1 ≈ 7.68,取整得 8,实际波特率 = 16 000 000 / (16 × 9) ≈ 111111,误差高达 3.5%,这就危险了,短帧可能侥幸通过,长帧必错。

目标波特率时钟 16 MHz 分频值实际波特率相对误差结论
960010396150.16%可用
1920051192300.16%可用
5760016588232.1%勉强
11520081111113.5%不可用

解决办法要么换时钟(比如用 8 MHz 倍频到 48 MHz 再分频),要么用带小数分频的 UART 外设,要么直接上外部晶振。这个坑我在早期项目里踩过,现象是低速指令帧一切正常,一到批量数据就成片乱码,查了两天才怀疑到时钟上。

2.3 电平标准与接线方式,别把 TTL 当 RS232

虚拟串口本身不涉及电平,因为它压根没有物理引脚。但只要你的链路末端接了一个真实的串口设备,电平就必须搞清楚。常见的三档是:TTL 电平(3.3 V 或 5 V,逻辑 1 为高)、RS232 电平(逻辑 1 为 −3 V 到 −15 V,负逻辑)、RS485(差分,半双工)。

最典型的错误是把 USB 转 TTL 模块直接怼到 RS232 设备上。TTL 的 5 V 高电平送到 RS232 接收端,会被识别成逻辑 0,数据全反;反过来 RS232 的负电压打进 TTL 引脚,轻则不通,重则烧片。我在客户现场见过一次,对方把 TX 直接接到 DB9 的 3 脚,上电冒烟,换芯片才修好。

接线规则也简单但必须记牢:TX 接 RX,RX 接 TX,GND 必须共地。两个模块之间如果只接两根信号线不接地,参考电平漂移,短距离可能凑合,稍微拉长一点就全是误码。另外要注意有些模块丝印上标的 TX 是"对外发送",有些标的是"应接对方 TX",遇到丝印含糊的一定用万用表量一下空闲电平确认。

对于设备内双 CDC 的方案,这一层基本不用操心,因为数据从 USB 进、从 USB 出,全程没有离开芯片。这也是它比物理接线方案更省心的原因之一。

2.4 缓冲区与流控,决定长时间跑会不会崩

串口数据流的典型特征是"突发短、峰值高"。一条 200 字节的指令可能在 1 毫秒内发完,然后静默几秒。如果消费端处理不及时,数据就堆在缓冲区里。整个链路至少有四层缓冲:USB 端点缓冲(设备侧,通常几百字节)、驱动环形缓冲(主机内核,通常 4 KB 可配)、串口 API 缓冲(应用层库,通常也是几 KB)、你自己的业务缓冲。

流控就是用来解决这个问题的。硬件流控用 RTS/CTS 两根线,接收方缓冲快满时拉高 RTS 告诉对方停发。虚拟串口对一般支持模拟流控,但硬件 CDC 方案里,很多固件根本没实现 RTS/CTS 端点通知,主机侧即使勾选了硬件流控也不起作用——数据照样丢。所以我的经验是:能不用流控就不用,靠应用层应答加限速更可靠。

具体做法是发送方维护一个在途帧计数,收到对端 ACK 才继续发下一帧,窗口大小设为 4。这样即便底层缓冲只有 1 KB,也不会溢出。代价是吞吐量降到理论值的六七成,但对绝大多数控制类应用完全够用。真要跑大流量(比如固件升级、图像传输),那就必须老老实实实现流控或分包重传。

3. 实操:从零搭一对能互通的双虚拟串口

3.1 硬件侧固件配置,把两个 CDC 接口立起来

我用的是一块带 USB 全速外设的通用 MCU 开发板,具体型号不重要,思路通用。固件基于一个成熟的开源 USB 协议栈改造,主要做三件事:组织描述符、注册两个 CDC 接口的回调、在回调里做数据搬运。

描述符部分按 IAD 结构组织,两个功能各自拥有一个控制接口和一个数据接口,端点分配上 IN/OUT 各一对,互不冲突。这里要注意端点地址不能重复,第一个功能用 0x81/0x01,第二个必须换成 0x82/0x02,否则枚举会失败,而且失败得很隐蔽——主机可能只认出一个口。

/* 描述符关键片段示意 */ /* 功能 1:控制接口 0,数据接口 1,端点 0x81 / 0x01 */ /* 功能 2:控制接口 2,数据接口 3,端点 0x82 / 0x02 */ static const uint8_t cfg_desc[] = { 0x09, 0x02, sizeof(cfg_desc), 0x00, 0x02, 0x01, 0x00, 0x80, 0x32, 0x08, 0x0B, 0x00, 0x02, 0x02, 0x02, 0x00, 0x00, /* IAD 1 */ /* ... 接口与端点描述符 ... */ 0x08, 0x0B, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00 /* IAD 2 */ };

数据回调里我只做最简单的透传:从端点 0x01 收到的数据放进环形队列 A,从端点 0x02 收到的放进队列 B,主循环里把 A 的内容写到 0x82 的发送缓冲,把 B 的内容写到 0x81。中间留了一个钩子函数,方便以后加协议过滤。

注意:USB 中断里绝对不能做耗时操作,也不能阻塞等待发送完成。我第一版就是在回调里直接调发送函数,结果主机侧连续发数据时固件直接卡死,改成中断里只入队、主循环出队发送才稳定。

烧录后用 USB 线把板子接到主机,正常情况下设备管理器里会多出两个"USB 串行设备",分别占用两个 COM 号。如果只出现一个,先回去查端点地址和 IAD 描述符长度。

3.2 主机侧虚拟串口对创建与端口识别

板子给的两个口是"真实"的虚拟串口,它们之间不会自动互通,需要主机侧再建一对虚拟串口把它们桥起来,或者干脆用脚本做中间人。我用的是后者,因为脚本能顺便抓包。

虚拟串口对工具的用法大同小异:安装后在管理界面里点"添加配对",给它起两个名字,确认后系统设备列表里就多出两个端口,比如 COM11 和 COM12。写入 COM11 的数据会立刻出现在 COM12 的接收缓冲里,反之亦然。这类工具底层通常是一个内核态驱动,建立两个虚拟设备节点,在写入路径上把数据直接投递到对端的读队列,本质上是一次内存拷贝,几乎没有延迟。

端口识别这块我踩过坑。Windows 上虚拟端口和硬件端口的 COM 号可能交叉分配,脚本里写死 COM11 换台机器就跑不起来。后来改成按"友好名称"匹配:遍历可用端口列表,找出名字里含特定关键字的那两个,动态取号。Linux 下就更规范一些,写一条 udev 规则按 USB 序列号建立固定软链接,脚本直接打开/dev/ttyBridge0这种别名,换机器也不用改代码。

# Linux 下按序列号固定虚拟串口名的 udev 规则示意 SUBSYSTEM=="tty", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", \ ATTRS{serial}=="BRIDGE0001", SYMLINK+="ttyBridge%n"

写规则之后要udevadm control --reload再重新插拔,不然不生效。另外当前用户要加入dialout组,否则打开端口直接报权限拒绝,这个错误信息很难让人联想到用户组。

3.3 收发验证脚本,先跑通一个来回再谈其他

链路搭好之后别急着上业务协议,先用最小脚本验证一个来回。我习惯用 Python 加 pyserial,原因很简单:跨平台、改起来快、出错信息清楚。

import serial import time # 桥接脚本:A 端收,B 端发;B 端收,A 端发 port_a = serial.Serial("COM11", 115200, timeout=0.1) port_b = serial.Serial("COM12", 115200, timeout=0.1) def loop(): while True: data = port_a.read(port_a.in_waiting or 1) if data: port_b.write(data) print("A->B", len(data), data.hex()) data = port_b.read(port_b.in_waiting or 1) if data: port_a.write(data) print("B->A", len(data), data.hex())

这段代码有两点值得说。read(n)里的 n 用in_waiting or 1,意思是缓冲区里有多少读多少,没有数据时最多阻塞 100 毫秒(靠 timeout 兜底),既能批量读又能及时返回。另一个是双向轮询写在一个循环里,单线程就够,因为读写都是非阻塞加超时的,不需要开两个线程互相加锁。

验证时先用串口助手往 COM11 发一串字符,看 COM12 那边有没有原样出现,再反过来发一次。两边都通了,说明虚拟串口对本身没问题,可以接下一步。接下来把硬件板子的两个口也加进来:脚本改成 COM11 ↔ 硬件口1,硬件口2 ↔ COM12,这样一条完整的"虚拟口—虚拟口—硬件—硬件—虚拟口—虚拟口"链路就成型了。

测试数据我一般不用纯文本,用递增的 4 字节序号加固定长度填充,方便发现丢包和乱序。比如每帧 64 字节,前 4 字节是序号,后 60 字节是按序号生成的伪随机数,接收端校验序号连续性和内容一致性。

3.4 压力测试与数据校验,把边界条件摸出来

通了不代表稳。我固定会跑三组压力测试。

第一组是持续低速:每秒 10 帧,每帧 64 字节,连跑 30 分钟,统计丢帧和误码。这一组验证的是长期运行的稳定性,重点看有没有内存泄漏和句柄耗尽。

第二组是突发高速:一次性灌 500 KB 数据,不做流控,看接收端能不能完整收下。这一组专门用来暴露缓冲区溢出,正常情况下不做流控一定会丢,丢了以后记录丢在哪一帧,就能反推缓冲区的实际容量。

第三组是边界帧:发 0 字节、1 字节、63 字节、64 字节、65 字节、1024 字节几种长度,验证短包和跨包处理。64 字节附近最容易出问题,因为它是 USB 批量端点的包长边界,固件里如果没处理零长度包,64 字节的帧可能会被主机认为"后面还有后续数据",一直等下去。

import serial, time ser = serial.Serial("COM12", 115200, timeout=0.5) sizes = [1, 63, 64, 65, 128, 1024] for size in sizes: payload = bytes([0x5A] * size) ser.write(payload) time.sleep(0.05) got = ser.read(size) status = "OK" if len(got) == size else f"FAIL got {len(got)}" print(f"len={size:5d} {status}")

实测结果里,1024 字节那一档如果固件包长设置正确应该能一次读完,读不完就说明中间有缓冲截断。这套测试跑完,基本可以判断链路的健康度。

4. 通信不稳定?排查顺序与实录

4.1 三层排查法:从设备层往下逐级排除

串口不通是最常见的投诉,我的排查顺序固定为三层,从上往下走,每层都有明确的判定依据。

第一层看设备层。打开设备管理器或ls /dev/tty*,确认端口是不是真的存在。Windows 上如果端口带黄色感叹号,是驱动问题;如果端口消失,是 USB 枚举失败或线材问题。这一层没通过,后面所有软件调试都是白费力气。

第二层看链路层。用最笨的方法验证:把两个端口短接成环回(虚拟串口对本身就自带环回),往 A 发数据看 B 能不能收到。收不到就是配对配置错了,或者端口被别的程序占用了。Windows 上"拒绝访问"一定是占用,Linux 上权限报错多半是用户组问题。

第三层看应用层。链路通了但业务数据不对,那就是参数或协议问题:波特率不一致、数据位/停止位/校验位对不上、帧边界切错、编码错。这一层最多变,也最需要日志。

我遇到过一例很典型:客户反馈"发了十条指令只有三条有响应"。三层查下来,设备层正常,链路层环回正常,最后定位在应用层——上位机软件的串口读取线程设了 500 毫秒超时,而下位机响应延迟偶尔超过 500 毫秒,超时后线程直接抛异常退出,后面的数据就没人读了。改成超时后重试而不是退出,问题消失。

4.2 乱码、丢数、粘包,三种现象对应三种病根

乱码几乎百分百是参数不匹配。先核对波特率,再核对数据位和校验位。有个隐蔽情况是虚拟串口的波特率参数只在设备侧生效,主机侧脚本设 115200、设备侧 UART 实际配成 9600,就会出乱码。这种时候用示波器或者逻辑分析仪量一下波形最直接,测一位的宽度,反算出实际波特率。

丢数的成因更多,按概率排序:缓冲区溢出、流控没生效、消费线程阻塞、USB 掉线重连。排查时先看丢的位置有没有规律——如果每次都在大量数据之后丢,那是溢出;如果随机零星丢,可能和线程调度有关;如果丢完一段之后完全没数据了,那是链路断了。

粘包其实不是故障,是串口作为字节流的固有属性。串口没有"帧"的概念,发 100 字节和发 10 次 10 字节,接收端看到的字节序列完全一样。解决办法是在应用层加帧协议,最常用的是"帧头 + 长度 + 载荷 + 校验"结构。

现象最可能原因快速验证方法处理方式
全乱码波特率不一致逻辑分析仪测位宽统一两端参数
偶发单字节错时钟误差偏大换成低波特率复测换时钟源或降速
大批量后丢数据缓冲溢出减慢发送速率复测加流控或应答
数据粘连无帧边界打印十六进制看规律加帧头长度校验
打开即报错端口被占用关掉其他串口工具独占模式排查

4.3 踩过的坑与独家避坑清单

有些坑文档里不会写,但真会让人耗掉一整天。

坑一是热插拔后的端口号漂移。虚拟串口对在主机休眠唤醒后有时候会重新枚举,COM 号往后跳。解决办法是脚本里不要硬编码端口名,用扫描匹配。

坑二是短接环回测出来正常,接上真实设备就失败。环回是软件内部拷贝,绕过了所有硬件路径,它只能证明虚拟串口对工作正常,证明不了电平、时序、USB 传输。所以环回通过之后一定要接硬件再测一次。

坑三是日志打印本身拖慢了转发。我最初在转发循环里print每一帧的十六进制,压测时吞吐量直接掉到十分之一,还以为是链路问题。后来改成写内存队列、由单独线程批量落盘,才恢复正常。串口转发这类高实时场景,任何同步 IO 都要挪到旁路。

提示:转发脚本里所有可能阻塞的操作(文件写、网络请求、数据库插入)都必须异步化,主循环只做收和发。

坑四是虚拟串口对工具的驱动和杀毒软件冲突。表现是装完端口不出现,或者出现后一访问就蓝屏。这种情况先看系统日志里的驱动加载记录,再考虑临时关闭安全软件做交叉验证。

坑五是固件里发送缓冲区太小。有些协议栈默认发送缓冲只有 128 字节,主机一次下发的数据超过这个数,固件只能收一半,剩下的直接丢,而且不报错。把缓冲调到 512 字节以上,问题消失。

5. 进阶玩法:把临时链路做成可复用的测试资产

5.1 中间人抓包与协议解析日志

链路稳定之后,最有价值的产出其实是日志。我的做法是把转发脚本升级成"中间人",每收到一帧就记录方向、时间戳、长度、原始十六进制和解析后的字段。时间戳精确到毫秒,用单调时钟避免系统时间跳变影响计算。

日志格式我坚持用两行结构:第一行是人类可读的摘要,第二行是原始字节,方便后续用脚本二次解析。落盘按小时切分,单个文件不超过 50 MB,超过就滚动。

import time, serial, threading, queue log_queue = queue.Queue(maxsize=10000) def writer(): with open("bridge.log", "a", encoding="utf-8") as f: while True: item = log_queue.get() f.write(item + "\n") f.flush() threading.Thread(target=writer, daemon=True).start() def on_frame(direction, data): ts = time.monotonic() summary = f"[{direction}] {ts:.3f} len={len(data)}" log_queue.put(summary) log_queue.put(" " + data.hex(" "))

队列满时选择丢弃并计数,而不是阻塞主循环,这个取舍很关键——日志丢几行不影响业务,主循环卡住会丢真实数据。

5.2 自动化回归:让串口通信也能进流水线

手工点串口助手验证一次两次还行,天天这么干谁也受不了。我把前面这套东西打包成了一个测试工程,跑起来自动完成:建虚拟串口对、打开端口、按用例表逐条发送、等待响应、比对结果、生成报告。

用例表用最简单的 CSV 描述,一行一个用例,字段包括发送内容、期望响应、超时时间、失败后是否继续。这样新加一个异常场景只需要加一行,不用改代码。参数化的部分还包括波特率和帧长度,方便做兼容性测试。

这套东西跑在持续集成里有个前提:虚拟串口对和硬件板子必须能稳定枚举。所以我又加了一个"环境自检"步骤,先扫描端口,数量不对就重试三次,仍然不对就判环境异常而不是用例失败。这个区分很重要,不然报告里全是红叉,没人愿意看。

最后分享一个我用了很久的小技巧:把这块硬件板子固定插在主机的一个 USB 口上,用标签纸写清楚口位,一年内不要换。USB 端口和设备的绑定关系在多数系统上是稳定的,固定下来以后,端口名、序列号、日志路径全部可预测,省掉大量"上次还好好的"的扯皮时间。

这套环境我前后迭代了四版,从最初拿两个 USB 转串口模块对插,到后来固件里做双 CDC 透传,再到现在脚本化回归,投入产出比最高的一步其实不是写代码,而是把端口命名和日志格式这两件事一次性定死。系统层面的不确定性被消除之后,后面所有调试都变成了简单的数据比对,问题定位时间从半天缩短到十几分钟。这也是我一直推荐新人先花时间把这套骨架搭起来的原因——串口通信本身不难,难的是反复确认那些看不见的中间环节。