凌晨两点被电话叫起来,说产线测试工装挂了,上位机一个字节都收不到,怀疑是板子的锅。折腾了四十分钟,最后发现是虚拟串口对没起来,另一端被一个没关干净的串口调试助手占着。这种事情遇到过太多次,所以我一直觉得,串口模拟工具这东西看着不起眼,但它是嵌入式、工控、车载、自动化测试这些领域里最基础也最容易被低估的一块基础设施。
这篇内容我想聊的是:怎么把串口模拟工具真正做出来、并且用测试把它验证到能上产线的程度。不是教你点两下串口调试助手的发送按钮,而是从底层机制、工具选型、代码实现、异常注入一直到自动化测试接入,完整走一遍。如果你正在做上位机开发、协议栈联调、产线工装、或者被“没有硬件怎么测”这个问题卡住,这篇应该能直接用。
先把概念掰开:串口模拟工具,就是在没有真实下位机(或者只有一台)的情况下,用软件扮演一个串口设备,能收发数据、能按协议应答、能制造异常。串口调试助手解决的是“我手点一下看回显”,模拟工具解决的是“让它自己跑起来、自己应答、自己出错、自己进CI”。
1. 串口模拟工具到底在解决什么问题
1.1 三个真实到肉疼的场景
第一个场景是硬件没到位。上位机软件、协议解析层、UI 展示全都得先写,但板子还在打样。这时候用模拟工具造一个假的设备端,把协议跑通,等真板子回来只需要换一个串口号。我见过团队因为等硬件白白耗掉两周,其实协议部分早就可以跑完。
第二个场景是硬件只有一块。三个人抢一块开发板,谁插上谁用。如果有一个模拟器,协议层联调、UI 调试、异常分支覆盖都可以在本地完成,真板子只留给必须验证时序和电气特性的环节。
第三个场景是自动化回归。产线工装上每天跑几百次功能检测,如果每次都要插一个真实设备,成本高、故障率高、还不好并行。用模拟器替代被测设备,测试可以并行开十几个,跑在容器里,出问题定位到具体用例。像自助借还这类设备,服务端和读卡器、扫码器之间常走串口,做服务端逻辑的时候完全可以先拿模拟工具顶上,不用真机。
1.2 模拟工具和串口调试助手不是一回事
新手最容易踩的认知坑,就是把串口调试助手当成模拟工具。xcom、友善串口助手、commix 这类工具本质是“手动挡”:你敲一串十六进制,点发送,看对面回什么。它能帮你确认链路通不通,但它不具备三个关键能力。
第一,它不会主动应答。真设备收到轮询帧会立刻回一帧,助手不会。
第二,它不能注入异常。研发阶段最需要验证的恰恰是丢包、错包、超时、粘包这些情况下上位机是不是会崩,助手做不了这个。
第三,它进不了 CI。它是 GUI 程序,没法在流水线里无人值守跑一晚上。
所以我的判断标准很直接:如果一个工具需要人手点按钮,它就是调试助手;如果它能无人值守扮演设备,它才是模拟工具。两者都要有,但角色完全不同。
1.3 三条技术路线的取舍对比
实现串口模拟,绕不开三条路线,各有各的适用面。
| 路线 | 实现方式 | 优点 | 局限 | 典型场景 |
|---|---|---|---|---|
| 物理回环 | TX 短接 RX,或用两块 USB 转 TTL 对接 | 最真实,包含电气特性 | 需要硬件,距离受限 | 验证驱动、验证电气层 |
| 虚拟串口对 | com0com、socat pty 造一对互联的虚拟口 | 零硬件成本,可并行,易进 CI | 不验证电气和时序抖动 | 协议联调、自动化回归 |
| 网络透传 | TCP/MQTT 桥接,远端映射成串口 | 跨地域调试,多客户端共享 | 引入网络延迟和不确定性 | 远程工装、多机协同 |
我的一般做法是:日常开发和回归用虚拟串口对,覆盖率占八成以上;电气层和极端时序问题必须回到物理回环或者真机。网络透传只在设备在异地、人过不去的时候才用,因为它会让“延迟”这个变量变得不可控,调时序相关的 bug 会很痛苦。
注意:不要用虚拟串口对去验证波特率误差、EMC 干扰、线缆容性负载这类问题,它根本不经过物理层,得出的结论一定是假的。
2. 串口通信的底层机制与关键参数
2.1 一帧数据在线路上长什么样
UART 是异步通信,没有时钟线,靠双方约定的波特率对齐。一帧的构成是:1 个起始位(拉低)、5 到 8 个数据位、可选的 1 个校验位、1 到 2 个停止位(拉高)。最常见的组合是 8N1,也就是 8 数据位、无校验、1 停止位,一帧总共 10 位。
接收端怎么知道哪一位是第一位?靠起始位的下降沿触发采样,然后在每一位的中间点取样。这个“中间点采样”是关键——它给了时钟误差一定的容错空间。粗略估算,如果一帧 10 位,最后一 bit 的累积偏差不能超过半个位宽,那么理论容错大约是 5%。但工程上没人敢贴着 5% 用,一般控制在 2% 以内,超过 3% 就得查时钟配置了。
这也是为什么 115200 这种常用波特率在低主频 MCU 上特别容易翻车:主频越低,分频系数越小,量化误差占比越大。
2.2 波特率误差怎么算,为什么它会让你丢数据
很多人只知道“波特率要对上”,但不知道对不上的程度是有量化指标的。以常见的 MCU 为例,波特率分频公式是USARTDIV = fCK / (16 × 波特率),整数部分和小数部分分别写进寄存器。小数部分只有 4 位,也就是最小步进 1/16,这就产生了量化误差。
我整理了一张实际算过的表,你可以对照自己的时钟配置核一下:
| 总线时钟 | 目标波特率 | 理论分频值 | 量化后分频值 | 实际波特率 | 误差 |
|---|---|---|---|---|---|
| 72 MHz | 115200 | 39.0625 | 39.0625 | 115200 | 0% |
| 36 MHz | 115200 | 19.53125 | 19.5 | 115384.6 | +0.16% |
| 8 MHz | 9600 | 52.0833 | 52.0625 | 9603.8 | +0.04% |
| 8 MHz | 115200 | 4.3403 | 4.3125 | 115942 | +0.64% |
看最后一行,8 MHz 主频跑 115200,误差 0.64%,单看还行。但如果发送端和接收端各自往相反方向偏,累计就接近 1.3%,再加上线缆容性和温漂,异常就开始冒头了。所以低主频 + 高波特率这个组合,我一般会主动降速到 57600 或者干脆换个时钟源。
提示:算误差的时候,发送端和接收端的误差要相加再取绝对值,这才是真实的相对偏差。只算一端是自欺欺人。
2.3 虚拟串口对和伪终端在内核里到底是什么
这块搞清楚了,后面排错会快很多。
Windows 下最常用的是 com0com。它本质是一个内核态驱动,创建一个虚拟的设备对,比如 CNCA0 和 CNCB0。你往 CNCA0 写,数据直接进 CNCB0 的接收缓冲,反之亦然。整个过程不经过 USB 栈,不经过任何硬件,纯粹是内存搬运。所以在 Windows 上串口模拟的延迟极低,稳定性也很好。
Linux 下没有 com0com,但有更好的东西:pty(伪终端)。用socat可以把两个 pty 桥起来,形成一个软链接对,比如/tmp/ttyV0和/tmp/ttyV1。你 open 其中一个,另一个就变成可读的。这东西天生适合容器和 CI,因为不依赖任何内核模块。
还有一个容易被忽略的点:pty 默认带终端行规程。也就是说,内核可能会帮你处理换行、回显、甚至把 0x0D 转成 0x0A。做二进制协议的时候这是灾难。所以创建 pty 时必须加raw和echo=0,否则你发的十六进制帧会被内核偷偷改掉,然后你怀疑人生。
2.4 流控和缓冲:两个被无视的隐形参数
大部分人配置串口只设波特率和 8N1,剩下全默认。但流控和缓冲在高吞吐场景下是决定性的。
硬件流控是 RTS/CTS,接收方缓冲区快满了就拉高 RTS 告诉对方别发了。软件流控是 XON/XOFF,用 0x11 和 0x13 两个控制字符做开关。两者都有副作用:硬件流控需要额外的两根线,很多 USB 转 TTL 只引出 TX/RX/GND,压根没有 RTS/CTS;软件流控则会污染数据流,二进制协议里如果出现 0x11 就会被误判。
所以在只有三根线的场景下,正确的做法不是开流控,而是加大接收缓冲 + 应用层做应答窗口。比如协议层规定发一帧必须等应答,或者最多连发 N 帧,从协议上避免上游把下游冲爆。
缓冲这块,Linux 可以用stty查看和调整,代码里也可以给 pyserial 设置read的 timeout 和批量读长度。经验值是:115200 波特率下,单次读 256 字节比单次读 1 字节的 CPU 占用低一个数量级。
3. 环境搭建:三条路线的落地操作
3.1 Windows 下的虚拟串口对
最省事的还是 com0com,它是开源的、长期维护的虚拟串口驱动。装完之后用它的图形界面或者命令行setupc.exe建一对口,比如 COM10 和 COM11。建完在设备管理器里能看到两个新的串口。
有个坑我必须提前说:Windows 上虚拟串口的编号会变。如果你之前装过蓝牙、调试器、某些手机助手,系统里可能已经占了一堆 COM 号,重启之后编号漂移是家常便饭。所以上位机代码里千万别把 COM 号写死,要做成配置文件,甚至在启动时扫描并匹配设备描述。
另一个坑是权限和占用。串口在 Windows 上是独占的,一个进程打开了,另一个进程 open 会直接报错。开发时最典型的翻车就是:串口调试助手忘了关,模拟器起不来,报“拒绝访问”,然后你以为是驱动问题重装了三遍。
3.2 Linux 下的 socat 与 Python pty
Linux 上我基本不装额外软件,有 socat 就够了。一行命令建一对:
socat -d -d \ pty,raw,echo=0,link=/tmp/ttyV0,mode=666 \ pty,raw,echo=0,link=/tmp/ttyV1,mode=666-d -d是把日志级别提高,方便看它到底建没建成。raw关掉行规程,echo=0关掉回显,mode=666是为了让容器里的非 root 用户也能读写。这条命令跑起来之后,/tmp/ttyV0和/tmp/ttyV1就是一条对穿的虚拟线。
如果你不想依赖外部命令,Python 标准库自带pty模块,可以在进程内建一对:
import os, pty, tty, termios master, slave = pty.openpty() tty.setraw(master) tty.setraw(slave) slave_name = os.ttyname(slave) print("从设备路径:", slave_name) # slave 给"被测程序"用(它认为自己打开了串口) # master 给"模拟器"用(它扮演另一端设备)这种方式的好处是整个模拟逻辑可以是一个进程,不需要额外管理 socat 的生命周期。坏处是它在 Windows 上不可用,跨平台项目还是老实按平台分支。
提示:在容器里跑的时候记得把
/dev/pts挂进去,否则 pty 建不出来。另外 selinux 或 apparmor 严格的机器上,mode=666可能被覆盖,得单独配策略。
3.3 真实硬件链路:USB 转 TTL 与 CH340 那些事
当你要验证的东西必须过物理层,就得用 USB 转串口芯片。常见的有 CH340、CH341、CP2102、FT232、PL2303。经验排序是:FT232 最稳,CP2102 次之,CH340 便宜但兼容性依赖驱动版本,PL2303 老芯片假货多,慎用。
CH340 驱动的问题几乎是每个新手必经的一课。Windows 10 以后系统会自动装一个版本,但这个自动装的版本有时候在高波特率下会丢数据;Ubuntu 上用ch340芯片如果识别不出来,一般是内核模块没加载或者被 brltty 抢占了。是的,brltty(盲文终端服务)会抢先抓走 CH340 并把它当成盲文显示器,这个坑非常经典:
# 查看是不是被 brltty 占了 dmesg | grep -i brltty # 临时解决 sudo systemctl stop brltty-udev.service sudo systemctl disable brltty-udev.service如果ls /dev/ttyUSB*死活出不来,先dmesg | tail -30看内核有没有认出设备,再看是不是被上面的服务抢了。权限问题也好解决,把用户加进 dialout 组:
sudo usermod -aG dialout $USER # 需要重新登录生效3.4 把串口桥到网络:TCP 与 MQTT 的取舍
设备在异地、人过不去的时候,网络透传是唯一解。最朴素的方案是 TCP 桥接:本机起一个服务,把串口收到的字节原样发到 TCP,TCP 收到的字节原样写进串口。上位机连本地虚拟串口,虚拟串口后面挂着 TCP 客户端。
MQTT 方案则更适合多端订阅的场景,比如你想让调试工具、日志服务、监控面板同时看串口数据。但 MQTT 是消息模型,天然会把数据流切成一个个消息,做二进制协议的时候必须保证“一个消息对应一个完整帧”,否则接收端还是得自己拼包。
选择逻辑很简单:点对点调试用 TCP,多端观察用 MQTT,两者都不要指望它来验证时序。网络抖动引入的几十毫秒延迟,会让任何基于超时的协议逻辑变得不可信。
4. 从零实现一个串口模拟器
4.1 先定架构,再动手写代码
我写模拟器从来不从“打开串口读数据”开始,而是先分三层,因为这三层决定了后面能不能复用到不同项目。
第一层是链路层,只负责打开端口、收发字节、处理超时和异常。它不知道协议是什么。
第二层是协议层,负责拆帧、校验、组帧。它不知道业务是什么。
第三层是设备行为层,也就是状态机,负责“收到什么回什么、什么时候主动上报、什么时候故意不回”。
这么分的好处是,链路层换个串口号就复用,协议层换个帧头就复用,只有设备行为层需要针对具体产品重写。我手上一个模拟器框架已经横跨过三个不同产品线,改动量最大的永远是行为层。
4.2 最小可用版本:能收发、能应答
先用 Python 的 pyserial 写一个能跑起来的最小版本:
import serial import time PORT = "/tmp/ttyV1" # Linux 下 socat 的一端;Windows 换成 COM11 BAUD = 115200 def open_port(port, baud): return serial.Serial( port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.02, # 读超时,别设太大,否则退出不灵敏 write_timeout=0.5, ) def main(): ser = open_port(PORT, BAUD) print("模拟器已启动:", PORT) try: while True: data = ser.read(256) if not data: continue print("收到:", data.hex(" ")) if data.startswith(b"PING"): ser.write(b"PONG\n") except KeyboardInterrupt: pass finally: ser.close() if __name__ == "__main__": main()这段代码里有三个细节值得说。timeout设成 0.02 秒而不是默认的 None,是因为read需要能被周期性打断,否则 Ctrl+C 半天退不出来。write_timeout一定要设,USB 转串口芯片在某些状态下写会阻塞,不设超时程序直接卡死。try/finally里的ser.close()必须写,否则端口不释放,下次启动就是“拒绝访问”。
4.3 协议帧的拆解与重组
真实项目里不可能用PING这种文本协议,基本都是二进制帧。假设我们的帧格式是:帧头 0x7E、长度、载荷、异或校验、帧尾 0x7F。发送和解析分别是:
HEAD, TAIL = 0x7E, 0x7F def pack(payload: bytes) -> bytes: if len(payload) > 255: raise ValueError("载荷过长") crc = len(payload) for b in payload: crc ^= b return bytes([HEAD, len(payload)]) + payload + bytes([crc & 0xFF, TAIL]) class FrameParser: """流式拆帧:处理粘包和半包""" def __init__(self, max_len=260): self.buf = bytearray() self.max_len = max_len def feed(self, chunk: bytes): self.buf.extend(chunk) frames = [] while True: # 丢掉帧头之前的噪声 try: start = self.buf.index(HEAD) except ValueError: self.buf.clear() break if start > 0: del self.buf[:start] if len(self.buf) < 3: break length = self.buf[1] total = length + 4 # 头 + 长度 + 载荷 + 校验 + 尾 if total > self.max_len: del self.buf[0] # 长度非法,丢一个字节重新同步 continue if len(self.buf) < total: break # 半包,等下一次数据 frame = bytes(self.buf[:total]) del self.buf[:total] if frame[-1] != TAIL: continue # 帧尾不对,丢弃 crc = length for b in frame[2:2 + length]: crc ^= b if (crc & 0xFF) != frame[2 + length]: continue # 校验失败,丢弃 frames.append(frame[2:2 + length]) return frames这个FrameParser是整套模拟器里最值钱的三十行代码。它同时解决了三个经典问题:粘包(一次收到两帧)、半包(一帧被拆成两次到)、噪声同步(帧头前面有脏数据)。自己写上位机的时候,这段可以直接抄。
提示:
max_len一定要设。我见过因为没设长度上限,遇到一段全是 0x7E 的噪声,接收缓冲一路涨到几百兆,最后 OOM 的案例。
4.4 状态机:让模拟器像真设备
设备行为层用一个简单的状态机描述就够了。比如一个采集设备:上电后处于空闲,收到轮询命令返回当前数据,收到配置命令返回 ACK 并切换采样率,超过 5 秒没收到任何命令就主动上报一次心跳。
import random class DeviceSim: def __init__(self): self.sample_rate = 10 self.last_poll = time.monotonic() self.seq = 0 def handle(self, payload: bytes): cmd = payload[0] if cmd == 0x01: # 轮询数据 self.last_poll = time.monotonic() self.seq = (self.seq + 1) & 0xFF value = random.randint(2300, 2700) return pack(bytes([0x81, self.seq]) + value.to_bytes(2, "big")) if cmd == 0x02: # 配置采样率 self.sample_rate = payload[1] self.last_poll = time.monotonic() return pack(bytes([0x82, 0x00])) # ACK return pack(bytes([0xFF, 0x01])) # 未知命令 def tick(self): # 主循环里周期性调用,处理心跳上报 if time.monotonic() - self.last_poll > 5: self.last_poll = time.monotonic() return pack(bytes([0x90, 0x00])) return None这种写法有个额外好处:它能同时用来调 PID。以前调控制参数要反复烧写 MCU、接传感器、跑一遍看曲线,现在让模拟器按预设的阶跃响应吐数据,上位机的 PID 逻辑和可视化都能在几分钟内迭代十几轮。这比烧写快太多了。
4.5 异常注入:把模拟器变成测谎仪
模拟器最有价值的部分不是正常应答,而是能稳定复现异常。我的做法是在链路层和协议层之间插一个故障注入器,用配置控制概率和模式:
import random class FaultInjector: def __init__(self, cfg): self.cfg = cfg # {"drop": 0.02, "corrupt": 0.01, "delay_ms": (0, 30), # "duplicate": 0.005, "truncate": 0.005} def process(self, frame: bytes): if random.random() < self.cfg.get("drop", 0): return None # 丢帧 if random.random() < self.cfg.get("duplicate", 0): return frame + frame # 重复帧 if random.random() < self.cfg.get("truncate", 0): return frame[: max(1, len(frame) // 2)] # 截断帧 if random.random() < self.cfg.get("corrupt", 0): i = random.randrange(len(frame)) b = bytearray(frame) b[i] ^= 0xFF # 位翻转 return bytes(b) lo, hi = self.cfg.get("delay_ms", (0, 0)) if hi: time.sleep(random.uniform(lo, hi) / 1000) return frame这套东西配合随机种子使用,效果最好。把种子固定下来,就得到了一组可复现的异常序列,同一个 bug 每次都能重现;把种子关掉,就变成了长时间随机压测,跑一晚上经常能翻出上位机里藏了很久的边界问题。这就是协议层的模糊测试思路,目的不是攻击谁,而是把解析器的健壮性测到极限。
5. 测试用例设计与结果判定
5.1 测试矩阵怎么排才不漏
串口测试最容易犯的错是只测“正常收发”,然后上线翻车。我习惯按四个维度排矩阵:
| 维度 | 取值 | 目的 |
|---|---|---|
| 参数组合 | 9600/19200/57600/115200 × 8N1/8E1/8O1/8N2 | 验证配置解析和重连 |
| 帧类型 | 最短帧、最长帧、空载荷、超长载荷 | 验证边界处理 |
| 异常序列 | 丢帧、重复、乱序、截断、位翻转 | 验证容错和恢复 |
| 时间行为 | 立即回、延迟回、不回、超时后回 | 验证超时和重试逻辑 |
每个维度交叉一下,用例数量涨得很快,但真正要手写的其实不多——因为前三个维度都可以用参数化自动生成。我实际项目里大概 200 多条用例,其中手写的只有 30 条左右,剩下全是参数化出来的。
优先级上,我永远先测异常序列。因为正常路径在开发阶段天天跑,出问题的概率低;异常路径没人主动测,一上现场就爆。
5.2 边界与异常用例清单
这份清单是我从几个项目里攒下来的,可以直接当检查表用:
- 载荷长度为 0 的帧,上位机是不是会死循环或者卡住
- 长度字段与实际字节数不符,上位机是否会用越界长度去读内存
- 连续两帧之间没有任何间隔(粘包),能不能正确切分
- 一帧被拆成三次到达,间隔分别是 1 ms、50 ms、500 ms
- 校验和全为 0、全为 0xFF、单个字节错,各测一遍
- 帧尾丢失、帧尾后直接接下一帧头
- 收到未知命令码,是否返回错误而不是静默
- 收到超长载荷(长度字段 255 但实际只有 10 字节)
- 长时间空闲后突然来一帧,是否还有响应
- 写入过程中拔掉设备(模拟端口消失),程序是否崩溃
最后一条特别重要。USB 转串口在运行中被拔掉,Linux 下对应的/dev/ttyUSB0会消失,read会抛异常。如果代码里没捕获,整个程序直接退出。我的做法是捕获serial.SerialException,标记端口的“健康状态”,然后用指数退避重连。
5.3 性能与压力测试怎么做才有意义
性能测试不是为了刷一个漂亮的数字,而是为了找到瓶颈在哪一层。我关注三个指标。
吞吐:在 115200、8N1 下,理论极限是 115200 / 10 = 11520 字节/秒。实测能到 11000 以上算合格,低于 9000 说明某处有阻塞,通常是读取批量太小或者线程里做了耗时操作。
往返延迟:发一帧到收到应答的时间。虚拟串口对上应该在 1 到 3 毫秒;超过 20 毫秒就要查是不是有time.sleep或者写操作没设超时。
突发承载:一次灌入 4 KB 数据不分批,看接收方会不会丢。这个指标对下有 FIFO 的 USB 转串口芯片很关键。CH340 的内部缓冲较小,突发超过一定量就容易丢,这时候要么降速,要么加流控,要么在协议层做分片。
压测脚本的关键是记录而不是断言。先把每帧的发送时间、接收时间、序号都写进 CSV,跑完再分析。只看一个“通过/失败”的结果,出了问题根本没法定位。
5.4 测试结论怎么写才有人看
我见过太多测试报告写“测试通过,功能正常”,这种结论等于没写。有用的结论应该包含四件事:在什么条件下、做了什么、观察到了什么、边界在哪里。
比如不要写“高波特率下通信稳定”,而要写“在 115200、8N1、单次连续发送 200 帧、帧间隔 5 ms 的条件下,丢帧率为 0;当单次连续发送超过 500 帧时,出现约 0.3% 的丢帧,原因是接收方未做流控且读取周期为 50 ms”。
后一种写法,别人才知道这个模块的能力边界在哪,也才知道该不该在生产环境的协议里加应答窗口。测试的价值不是证明“它能用”,而是清楚地标出“它到哪儿会不能用”。
6. 常见问题与排查速查表
6.1 故障速查表
| 现象 | 常见原因 | 快速验证方法 | 处理 |
|---|---|---|---|
| 打开端口报权限错误 | 被占用或权限不足 | lsof /dev/ttyUSB0、查进程 | 关掉调试助手,用户加 dialout 组 |
| 一个字节都收不到 | TX/RX 接反、没共地 | 短接 TX/RX 自发自收 | 交换线序,接好 GND |
| 收到的全是乱码 | 波特率或校验位不匹配 | 逐一切换常见波特率 | 两端统一参数,核对时钟误差 |
| 偶发丢字节 | 无流控、缓冲小、中断被屏蔽 | 加大读取批量,开流控 | 协议层加应答窗口 |
| 数据粘连成一大坨 | 读取端没按帧切分 | 打印原始 hex 看帧头位置 | 引入流式拆帧器 |
| 高波特率下错误率飙升 | 转串口芯片 FIFO 小、延迟大 | 降到 57600 对比 | 换 FT232/CP2102,调低延迟定时器 |
| 端口用一段时间后消失 | USB 供电不足或驱动异常 | `dmesg | tail` |
| 收到自己刚发出去的东西 | 单线半双工模式收发回环 | 关闭接收或过滤 | 单线模式下主动忽略自身回环帧 |
| Ctrl+C 退出不响应 | read超时设成了 None | 加打印看主循环 | 超时设为 20 到 50 ms |
| 虚拟串口对建了但连不通 | 行规程未关、链接名写错 | ls -l看软链接指向 | 加 raw 和 echo=0 |
6.2 几个容易被忽略的工程细节
第一,单线半双工模式下的回环。有些 MCU 在单线模式下,自己发出去的字节会同时进入接收 FIFO。如果你的模拟器或上位机没做过滤,就会出现“自己跟自己对话”的诡异现象,表现为应答帧数量翻倍。定位方法是打印帧的时间戳,如果收发时间差在微秒级,那基本就是回环。
第二,中断优先级和接收丢失。Linux 上从串口接收大量数据时丢包,很多时候不是应用层的问题,而是内核驱动缓冲区溢出。/proc/tty/driver/serial里的overrun计数会告诉你真相。如果 overrun 一直在涨,要么降速,要么改用 DMA 方式,要么用更大的硬件 FIFO 芯片。
第三,EMC 测试时的串口误码。做电磁兼容摸底的时候,串口线就是一根很好的天线。我遇到过在特定频率下误码率飙升,换了带屏蔽层并且屏蔽层单端接地的线缆之后就好了。所以如果有人报告“只有在某个设备启动时串口才出错”,先别怀疑软件,先怀疑线缆和接地。
6.3 我个人踩过的三个坑
第一个坑是把 COM 号写死在代码里。当时项目在四台机器上跑,每台机器的虚拟串口编号都不一样,结果每换一台机器就要重新编译一次。后来改成配置文件加自动扫描,靠设备描述字符串匹配,才算彻底解决。
第二个坑是以为虚拟串口对能测出真实问题。有一次协议偶发丢帧,我在虚拟串口对上怎么跑都复现不了,白白耗了两天。后来换成两块 USB 转 TTL 对接,一跑就复现了,原因是发送方在帧的最后两个字节前有极短的间隔,真实芯片的 FIFO 在那一刻正好触发了一次分包。虚拟串口对是内存搬运,根本不会产生这种时序碎片。
第三个坑是没给模拟器加超时保护。模拟器跑在 CI 里,某次因为协议解析里一个死循环卡住了,整个流水线挂了两小时才被发现。从那以后我所有模拟器都加了两条保险:主循环必须有超时,关键解析循环必须有限次迭代上限。
如果你现在正准备做这件事,我的建议是先花半小时把 socat 或者 com0com 跑起来,写一个只有三十行的回环模拟器,把链路打通;然后再花一天时间把拆帧器和故障注入器写出来。真正花时间的从来不是“怎么把串口打开”,而是“怎么让它一直开着并且出错的时候你知道”。