ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

串口模拟工具实战:虚拟串口对+Python实现协议自动化测试

串口模拟工具实战:虚拟串口对+Python实现协议自动化测试 1. 从“板子还在路上”说起串口模拟工具到底解决什么问题手上只有一份协议文档硬件板子还在物流途中上位机代码却要赶在明天上午联调完——这种场面我经历过不止一次。串口模拟工具就是给这种场景准备的救火队员它不产生真实的 TTL 电平也不占用你机箱上的物理 USB 口而是在操作系统层面虚拟出一对或多对串口让上位机软件以为自己插上了一根 USB 转 TTL 线实际上线的另一端坐着一个能按协议回数据的程序。串口测试里最耗时间的从来不是收发动作本身而是“等硬件”“复现偶发异常”“验证边界帧”这三件事而模拟工具恰好能把这三点都扳回来。先说清楚它不是什么。模拟工具替代不了电磁兼容测试、替代不了真实的 RS485 总线负载、替代不了串口烧写过程中那头具体的 MCU。但它能把 80% 的协议逻辑验证、帧格式校验、异常分支覆盖放在硬件到货之前完成剩下 20% 的硬件相关特性再上真板子收尾。这个分工一旦划清楚整个项目的节奏会顺很多也不会出现“板子一到手全都在改低级 bug”的尴尬。这篇文章写给三类人正在做上位机串口开发但缺硬件的工程师、需要用自动化测试批量回归串口协议的测试同学、以及被CH340 驱动和波特率对不上折磨过的新手。我会从底层通信机制讲到虚拟串口对的建立再给出一套可以直接抄作业的 Python 模拟工具实现最后把踩过的坑整理成速查表。文中没有模棱两可的“建议考虑”所有参数和代码都在我自己的机器上跑过。1.1 真实串口调试最让人头疼的三个卡点第一个卡点是硬件不在位。嵌入式项目里上位机和下位机经常是两个团队并行推进下位机固件还在调传感器上位机的通信模块只能干等。等板子到了两边的 bug 混在一起到底是协议解析写错了还是对方发送时序有偏差排查起来互相甩锅。第二个卡点是偶发异常难复现。比如某条命令在连续发送 10 万次后偶发回复错帧真实硬件上你要挂机跑一整天才能撞上一次。模拟工具可以直接构造“第 99999 帧故意延迟 200ms 回复”这种剧本几秒钟就能复现定位效率完全不是一个量级。第三个卡点是边界用例成本高。超长帧、校验错、帧头粘连、半包数据、粘包数据这些异常帧用真硬件注入非常别扭要么改固件专门加测试分支要么用信号发生器硬凑。虚拟串口这一侧写代码随意构造字节流想发什么发什么测完删掉即可不留任何副作用。注意模拟工具验证的是“协议逻辑与上位机鲁棒性”它无法证明你的硬件电路在强干扰下不掉线也无法验证 RS485 收发切换的时序余量。这两块必须留给真板子。1.2 模拟工具的能力边界先划清楚再动手我见过有人指望模拟工具能测出电源纹波导致的串口通信误码这是方向错了。模拟工具体验的是纯软件链路从应用程序缓冲区到虚拟驱动缓冲区中间没有任何模拟信号。它的价值集中在数据链路层以上的逻辑帧结构、命令字、长度域、校验算法、超时重传、多帧拼接、状态机跳转。反过来说凡是和电气特性、uart 串口通信电平、共模干扰、地环路相关的测试模拟工具一律不碰。把边界写进测试计划的“适用范围”一栏后面评审时不会有人拿这个来质疑测试充分性。我自己的习惯是在测试报告开头明确写一行本次验证覆盖协议层物理层特性另行安排。还有一个容易忽略的边界USB 转串口芯片带来的驱动层行为。CH340、CP2102、FT232 这些芯片在驱动层面有各自的缓冲区大小和超时特性虚拟串口对是没有这些特性的。所以在模拟环境里跑通的超时参数搬到真板子上可能要重新标定一次这一点后面第 6 节会详细讲。2. 串口通信的底层逻辑与模拟工具的介入点要明白模拟工具为什么能骗过上位机得先知道一个字节是怎么从代码走到线上的。应用程序调用 write 把一串字节交给内核串口驱动驱动把它推到 UART 控制器的发送 FIFO硬件再按波特率一位一位地把电平翻转出去。接收方向反过来起始位触发采样移位寄存器攒满 8 位就产生接收中断驱动读走数据放进缓冲区应用程序再 read 出来。波特率决定了每一位的时间宽度。115200bps 意味着每位约 8.68 微秒一个标准 10 位帧1 起始 8 数据 1 停止大约 86.8 微秒。这个数字在模拟环境里没有物理意义但它决定了上位机的超时阈值该怎么算——比如等待一个 20 字节的回复理论传输时间约 1.74 毫秒考虑到调度抖动超时设 50 到 100 毫秒比较从容。2.1 一条数据从应用程序到虚拟串口对的路径在 Linux 下虚拟串口对由伪终端pty实现。socat 创建两个配对的 pty 设备节点比如 /dev/pts/3 和 /dev/pts/4写进其中一个的数据会从另一个读出来行为上和一根交叉连接的串口线完全一致。上位机打开 /dev/pts/3模拟程序打开 /dev/pts/4双方都以为自己在和真实硬件说话。Windows 下没有 pty 这套机制靠的是虚拟串口驱动比如开源的 com0com 或者商用软件里的对应功能。它注册一对 COM 口例如 COM10 和 COM11驱动层直接把写入 COM10 的数据转发到 COM11 的接收缓冲。上位机打开 COM10模拟程序打开 COM11链路就通了。要注意这两个端口号必须是成对创建的单独一个虚拟 COM 口没有任何对端打开后会一直 read 超时。Mac 下依然走 pty 那套socat 或者自带的 screen 配合 pty 都能用路径形如 /dev/ttys00x。三套系统的实现机制不同但对上位机而言都是“一个普通串口”这就是模拟方案通用性的根源——不对上层暴露任何差异。2.2 模拟工具到底站在协议栈的哪一层模拟工具站在“字符设备之上、应用协议之下”。它不做电气层的比特翻转但要完整承担字节流的组织与解析。这意味着两件事必须由我们自己负责一是分帧二是应答时序。分帧是把连续字节流按协议切成一条条完整报文应答时序是决定收到命令后立即回复还是延迟回复、分几次回复、故意错发一次再补发正确帧。我倾向把这层逻辑写成一个独立的“协议引擎”模块和串口收发模块解耦。串口收发模块只负责 open、read、write、close协议引擎只负责字节数组进、字节数组出。好处是这套引擎既能在虚拟串口上跑也能在真串口上跑将来还能接到网络 Socket 或者 MQTT 上做虚拟串口软件式的转发复用率极高。这个分层是在被两三个项目折磨之后才定下来的一开始全塞在一个脚本里改起来非常痛苦。3. 方案选型虚拟串口对、脚本模拟、硬件仿真三种路线怎么选市面上的方案大致分三类没有绝对优劣取决于你要验证什么、团队在什么系统上开发。选错路线最典型的后果是时间花在折腾工具本身而不是验证业务协议。第一类是虚拟串口对 自研模拟程序。底层用 socat 或 com0com 建立成对端口上层自己写 Python 或 C# 程序实现协议应答。灵活度最高能精确控制每一帧的时序和内容异常注入想怎么玩怎么玩。代价是要自己写代码前期投入大概一天。第二类是现成的串口调试助手。XCOM、SSCOM、友善串口助手这类工具都支持定时发送、十六进制收发、多条指令轮发。它们适合手动验证单条命令但要做“收到 A 命令后按条件回复 B 帧”这种交互逻辑就很吃力基本只能靠人工点发送。做回归测试时不推荐效率太低。第三类是带脚本的增强型模拟软件可以理解为调试助手加了一个规则引擎收到指定帧自动触发回复。上手快不用写代码但复杂协议比如带变长负载和 CRC 校验的配置起来反而绕。适合协议简单、用例不多的场景。3.1 三种路线的横向对比对比维度虚拟串口对 自研程序串口调试助手手动带脚本的增强模拟软件协议复杂度承载能力高任意逻辑低仅手动中受规则引擎限制异常帧注入完全可控手动拼十六进制部分支持自动化回归天然支持不支持有限支持前期投入约 1 人天几乎为零约 2 小时长期维护成本低代码即文档高全靠人工中适合场景协议测试、持续集成临时抓包、单条验证快速原型、轻量验证从表里能看出如果项目周期超过两周且需要回归虚拟串口对加自研程序几乎是唯一划算的选择。前期多花的那一天会在后续每次改协议、每次回归里加倍省回来。我做过一个小统计某项目用自研模拟工具后通信模块的单轮回归时间从人工 40 分钟压到脚本 90 秒改了 11 轮协议就省出了整整一天。3.2 选型时最容易忽略的两个隐藏成本第一个隐藏成本是跨平台。如果团队里有人用 Windows、有人用 Linux虚拟串口对的建立方式完全不同模拟程序的串口路径要参数化不能写死 /dev/pts/3。我通常把端口名做成配置文件项或命令行参数甚至用环境变量注入避免换台机器就报“找不到串口”。第二个隐藏成本是端口号漂移。Linux 下 pty 的编号是动态分配的这次是 /dev/pts/3下次可能是 /dev/pts/7。解决办法是 socat 输出创建结果后程序解析或者干脆用固定的符号链接指向它。Windows 下 com0com 分配的是固定 COM 号相对省心但偶尔会被系统占用冲突。这些小事不提前想第一次跑就会卡住。提示把“建立虚拟串口对”这一步单独写成一个启动脚本成功后再拉起模拟程序中间加一个端口就绪的探测循环。否则容易出现上位机先打开、pty 还没创建完的竞态。4. 手把手搭建用 Python 写一个可配置的串口模拟工具下面这套实现是我目前项目里在用的简化版去掉业务相关代码后大约两百行跑在 Python 3.8 以上依赖只有 pyserial。它的设计目标是一套协议引擎能挂在虚拟串口上也能挂在真串口上支持变长帧、CRC16 校验、按命令字分派处理函数、可控的应答延迟和错误注入。先明确协议帧格式这个格式是我按常见工业协议抽象出来的你可以按自己的实际协议替换字段含义------------------------------------------------- | 帧头 | 长度 | 命令字 | 数据区 | CRC16 | 帧尾 | | 2 字节 | 1 字节 | 1 字节 | N 字节 | 2 字节 | 1 字节 | | AA 55 | len | cmd | ... | 低字节在前 | 0D | -------------------------------------------------长度域统计的是从命令字到数据区结束的字节数CRC 计算范围是长度域、命令字、数据区不包含帧头帧尾。这套定义在解析时要严格遵守否则半包拼接永远对不上。4.1 环境准备与依赖安装Linux 下不需要额外安装虚拟串口驱动装 socat 即可# Debian/Ubuntu 系 sudo apt-get install socat # 验证版本 socat -VWindows 下推荐用开源的 com0com安装后打开它的 Setup 图形界面添加一对端口例如 COM10 和 COM11勾选“use Ports class”可以伪装成标准串口设备。装完后在设备管理器里应该能看到这对端口。如果设备的串口烧写失败、提示找不到端口八成是驱动没签名或者被系统拦了这类CH340 串口驱动相关的问题在 Windows 上很常见后面第 6 节会专门说。Python 侧只需要一个库pip install pyserial验证 pyserial 能否枚举端口python -m serial.tools.list_ports这一步能列出你建好的虚拟端口就说明驱动 OK。4.2 建立虚拟串口对并确认链路Linux/Mac 下用一条命令建立成对 pty并把两个端口名打印出来socat -d -d pty,raw,echo0,link/tmp/ttyV0 pty,raw,echo0,link/tmp/ttyV1执行后会停在终端不返回这是正常的它作为守护进程持续转发数据。另开一个终端检查ls -l /tmp/ttyV0 /tmp/ttyV1两个符号链接会指向实际的 /dev/pts/N。这种用固定符号链接的做法解决了前面说的端口号漂移问题程序里直接填 /tmp/ttyV0 即可。参数里 raw 表示不做任何字符转换echo0 表示不回显这两个必须加否则你会看到一个端口发出去的数据从自己这里读回来误以为是协议 bug。Windows 下端口就是 COM10 和 COM11无需命令模拟程序开 COM11上位机开 COM10。4.3 核心代码帧解析与命令分派先写 CRC16-MODBUS这个算法在工业设备里出现频率极高低字节在前发送def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc再写一个带环形缓冲的解析器这是整个工具里最容易写错的部分。很多人一上来就对 read 到的整块数据切帧遇到半包立刻崩。正确做法是维护一个 bytearray每次把新数据追加进去然后循环尝试切出一条完整帧切不出来就等着class FrameParser: HEAD b\xAA\x55 TAIL b\x0D def __init__(self): self.buf bytearray() def feed(self, chunk: bytes): self.buf.extend(chunk) frames [] while True: frame self._try_extract() if frame is None: break frames.append(frame) return frames def _try_extract(self): # 找帧头 idx self.buf.find(self.HEAD) if idx 0: # 没有帧头只保留最后一个字节防跨包 if len(self.buf) 1: del self.buf[:-1] return None if idx 0: del self.buf[:idx] # 丢弃帧头前的垃圾数据 # 帧头 长度域至少 3 字节 if len(self.buf) 3: return None length self.buf[2] total 2 1 length 2 1 # 头 长 体 CRC 尾 if len(self.buf) total: return None candidate bytes(self.buf[:total]) if candidate[-1] ! self.TAIL[0]: del self.buf[:2] return None calc crc16_modbus(candidate[2:3 length]) recv candidate[3 length] | (candidate[4 length] 8) if calc ! recv: del self.buf[:2] return None del self.buf[:total] return candidate这里有个细节值得强调校验失败时我只丢弃两个字节的帧头而不是整帧丢掉。原因是数据流里可能出现“假帧头”如果按伪长度直接跳过一整帧真实的帧头可能就被误吞了。逐字节滑动重新找帧头鲁棒性最好。当然这会带来一点点重解析开销但对串口这种低速链路完全无所谓。接下来是模拟从机的收发主循环命令字分派用字典import serial import time class MockSlave: def __init__(self, port, baudrate115200, delay0.01, inject_errorFalse): self.ser serial.Serial( portport, baudratebaudrate, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.02, ) self.parser FrameParser() self.delay delay self.inject_error inject_error self.counter 0 self.handlers { 0x01: self.handle_read_status, 0x02: self.handle_write_param, 0x03: self.handle_heartbeat, } def build_frame(self, cmd: int, payload: bytes) - bytes: body bytes([len(payload) 1, cmd]) payload crc crc16_modbus(body) return b\xAA\x55 body bytes([crc 0xFF, crc 8]) b\x0D def handle_read_status(self, payload: bytes) - bytes: return bytes([0x00, 0x64, 0x01]) # 状态正常, 温度, 通道 def handle_write_param(self, payload: bytes) - bytes: return bytes([0x00]) def handle_heartbeat(self, payload: bytes) - bytes: self.counter 1 return bytes([0x00, self.counter 0xFF]) def run_forever(self): while True: chunk self.ser.read(256) if not chunk: continue for frame in self.parser.feed(chunk): cmd frame[3] length frame[2] payload frame[4:3 length] handler self.handlers.get(cmd) if handler is None: resp_payload bytes([0x01]) # 不支持的命令 else: resp_payload handler(payload) time.sleep(self.delay) if self.inject_error and self.counter % 7 0: resp self.build_frame(cmd, resp_payload)[:-4] # 故意截断 else: resp self.build_frame(cmd, resp_payload) self.ser.write(resp) if __name__ __main__: slave MockSlave(/tmp/ttyV1, baudrate115200, delay0.005) slave.run_forever()这段代码里几个参数都是可调的delay 控制应答延迟用来测上位机的超时逻辑inject_error 打开后每 7 帧故意发一条残缺帧验证上位机对错帧的处理。实测把 delay 调到 0.2 秒时我那份超时阈值设 100 毫秒的上位机立刻报超时并重发说明超时机制工作正常。4.4 配一个自动化回归脚本收尾模拟从机跑起来后上位机的验证就可以完全自动化。下面这段脚本模拟上位机行为逐条发命令、断言回复import serial import time import pytest PORT_MASTER /tmp/ttyV0 def build(cmd, payloadb): body bytes([len(payload) 1, cmd]) payload crc crc16_modbus(body) return b\xAA\x55 body bytes([crc 0xFF, crc 8]) b\x0D pytest.fixture def master(): ser serial.Serial(PORT_MASTER, 115200, timeout0.1) yield ser ser.close() def read_one(ser): parser FrameParser() deadline time.time() 1.0 while time.time() deadline: frames parser.feed(ser.read(256)) if frames: return frames[0] return None def test_read_status(master): master.write(build(0x01)) resp read_one(master) assert resp is not None assert resp[3] 0x01 assert resp[4] 0x00 def test_heartbeat_increments(master): seen set() for _ in range(5): master.write(build(0x03)) resp read_one(master) assert resp is not None seen.add(resp[4]) assert len(seen) 5 # 每个心跳计数应不同用 pytest 跑起来就是一条命令的事配合持续集成可以做到每次提交自动回归。这套东西搭好之后协议改动的验证成本几乎降到零。5. 测试结论怎么来的功能、边界、压力三类用例设计工具能跑通只是第一步真正决定测试结论可信度的是用例设计。我习惯把串口类用例拆成三层功能层保证主流程对边界层保证异常输入不崩压力层保证长时间运行不泄漏不卡死。三层都过了才敢在报告里写“通过”。5.1 功能用例把每条命令的正常路径走一遍功能层最直接针对协议文档里每一条命令字构造合法请求断言响应字段含义正确。这里有两个细节值得展开。一是响应内容要覆盖所有分支。比如读状态命令正常返回 0x00但设备可能处于告警、离线、初始化中等状态模拟工具应该能按参数切换返回不同状态码让上位机的状态机每个分支都被执行到。如果模拟工具只会返回一种状态上位机的告警处理逻辑永远是没测过的死代码。二是请求参数要有代表性组合。写参数命令往往有多个字段全用 0 和全用最大值是最没营养的测法。我的做法是选边界值加随机值混合字段 A 取最小值、字段 B 取最大值、字段 C 取随机中间值跑几十组。随机数种子固定保证失败可复现。这块我在一个项目里吃过教训写参数命令的校验逻辑只测了合法值上线后遇到设备端传回全 0xFF 的非法参数直接解析越界。补上边界用例只花了半小时省下的现场排查时间按天算。5.2 边界与异常用例丑帧才是真考验边界层是模拟工具真正的主场。下面这些帧类型建议全部覆盖异常类型构造方法期望上位机行为半包帧先发前半截延迟 50ms 再发后半截正确拼接不误判为错帧粘包帧两条完整帧一次性连续发送完整切分成两条都正确处理校验错故意把 CRC 改错丢弃该帧不回应答不崩溃长度域超限length 填一个远超实际的巨大值等待超时后丢弃缓冲区不无限增长帧头粘连帧头后紧跟另一帧头丢弃前段垃圾从第二个帧头重新解析未知命令字cmd 填协议未定义值返回不支持错误码或按约定忽略非法帧尾帧尾字节被篡改判定为无效帧重新同步注意长度域超限这条最容易埋雷。如果解析器按 length 预分配缓冲区一个伪造的巨大 length 就能让内存爆掉。我的做法是给 length 设一个上限常量超过直接丢弃当前候选帧并滑动重新同步。异常用例还有一类是时序类比如两帧间隔小于设备端处理周期、应答延迟超过上位机超时阈值、上位机连发三帧不等回复。这些用模拟工具的 delay 参数就能构造真硬件上反而难精确控制。5.3 压力与长稳跑够时长再下结论压力层关注三件事吞吐、内存、稳定性。吞吐上让模拟工具以最高频率收帧回帧持续跑一小时观察上位机的处理队列有没有积压、有没有丢帧。串口本身速度有限115200bps 下理论上限也就每秒一千多帧短帧一般跑不满瓶颈往往在上位机的解析线程。内存上重点看解析缓冲区会不会随错误帧增长。如果每收到一条校验错帧就多留一段垃圾在 buffer 里跑一晚上内存就上去了。验证方法很简单让模拟工具持续发错帧跑两小时同时监控上位机进程的常驻内存曲线应该是平的。稳定性上我一般设置一个 24 小时长稳模拟工具按脚本随机组合正常帧和异常帧上位机持续运行不重启。跑完之后检查有无异常退出、有无句柄泄漏、日志里错误率是否在预期范围。这套组合拳跑下来测试结论才站得住脚。6. 踩坑记录与常见问题速查这一节是我最想分享的部分因为下面这些问题几乎每一个都让我在现场多待了半小时以上。先说CH340 串口驱动那点事。Windows 10 之后的系统对未签名驱动卡得很严有些老版本的 CH340 驱动装上后设备管理器能认到端口但一打开就报“拒绝访问”或者干脆收不到数据。遇到这种情况先卸载旧驱动重启再装官网最新版。如果还是不行检查设备管理器里该端口是否被标记了黄色感叹号有的话右键看错误码。另一个高频坑是同一个 CH340 芯片被两套驱动抢占表现为端口能打开但读写全是 0 字节设备管理器里能看到两个条目删掉多余的那个即可。再说串口接收数据丢失。Linux 下偶尔丢数据八成是读取线程阻塞太久驱动缓冲区溢出。解决无非两条加大驱动缓冲区stty 里的设置或者加快读取频率。我习惯开一个专职读线程用最小 timeout 循环 read读到就塞进队列业务逻辑在另一个线程从队列取写日志这种耗时操作绝对不能在读线程里做。这条经验帮我解决了三次“偶发丢包”。还有串口关闭时的资源释放。很多人写程序只管 open 不管 close程序退出时端口没释放下次打开报“设备忙”。正确做法是把串口对象的关闭放进 finally 或者上下文管理器。如果还是遇到占用用 lsof 查一下哪个进程还开着它Windows 下用资源监视器搜句柄。6.1 高频问题速查表现象可能原因排查与解决端口打不开提示被占用上个进程未释放lsof 查占用进程杀进程或重启打开成功但收不到数据端口对端没打开 / 端口配错对确认虚拟串口对的另一端已连接收到自己发出的数据未关闭回显pty 建链时加 echo0、raw数据偶尔丢失读取线程阻塞缓冲溢出独立读线程最小超时循环读乱码波特率、校验位、停止位不一致逐项核对优先怀疑波特率校验频繁失败分帧逻辑有误或字节序搞反打印原始 hex核对 CRC 字节序虚拟端口下一台机器找不到pty 编号漂移用符号链接固定路径长时间运行内存上涨异常帧残留缓冲区错误帧及时清理 buffer6.2 几个别人不一定会告诉你的实操心得第一永远打印原始十六进制。不管协议多复杂先用bytes.hex()把收发都打出来肉眼核对帧头帧尾和 CRC 位置。我见过太多人对着协议文档改代码改一下午其实问题就是某一帧多了一个字节打印出来一眼就看见。第二模拟工具的日志级别做成可调。平时只记关键事件复现问题的时候打开逐帧日志。逐帧日志一开每秒几千行长时间挂机会把磁盘写满所以一定要能关。第三给每个用例编号并写进注释。回归失败时脚本输出用例编号人能直接从报告定位到具体协议条目比看堆栈有用得多。第四模拟工具的应答内容不要写死。至少留一个配置文件或者命令行参数让关键返回值可调否则每次验证新分支都要改代码重新跑。这个习惯让我的用例迭代速度提升明显。最后分享一个小经验把虚拟串口对的建立、模拟从机启动、上位机回归脚本串成一个一键脚本每次代码改动只需要跑一条命令。跑完输出通过率失败项自动附带收到的原始帧。这套流程搭好之后串口协议的验证从“一件麻烦事”变成了“顺手就跑一下”的日常操作很多潜在问题就是在这种随手回归里被提前揪出来的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进