简介:CommTest是一款面向嵌入式开发、工业自动化与物联网设备调试人员的多串口通信测试工具,基于VC6工程构建,支持多串口并行测试与多线程独立运行,可有效解决复杂系统中串口吞吐量验证与稳定性评估问题。资源包共41个文件,约7.21MB,包含11个exe可执行程序、6个h头文件、4个cpp源文件及obj、pdb、res、ico等工程与资源文件,另附dsp、dsw工程配置与ReadMe说明,便于直接运行或二次开发。已有557人学习下载。读者可获取完整可编译的串口测试工程源码,通过修改代码定制波特率、停止位、校验方式等参数,模拟实际通信条件,并结合误码率、丢包检测等思路评估串口性能。目录结构清晰,适合需要快速搭建多串口并发测试环境、排查通信故障或学习多线程串口编程的开发者参考。
1. 多串口多线程串口测试工具:为什么单线程轮询一到 8 路就崩
如果你手上只有一两个串口,用单线程read轮询完全够用;但只要通道数上到 4 路以上、波特率拉到 115200 甚至更高,单线程轮询就会开始丢包、粘包、时间戳错乱。CommTest 这类多串口多线程串口测试工具要解决的核心问题,就是把「N 个串口同时收发、互不阻塞、每条数据可追溯」这件事做扎实。它适合做嵌入式产测、工控设备联调、多路传感器数据采集的工程师,也适合需要长时间跑稳定性验证的测试岗。我见过太多人一开始用while(true)挨个ReadFile,跑十分钟没事,跑两小时就发现某一路数据整段消失——不是串口坏了,是轮询把缓冲区读溢出了。这篇就按「怎么搭、参数怎么设、坑在哪」把方案讲透。
2. 多串口多线程的架构选型:每口一线程还是线程池
2.1 三种常见架构的取舍
做多串口并发,绕不开三种结构:每串口独立线程、线程池 + 事件驱动、单线程异步 IO(如 IOCP/epoll)。我一般会先看通道数和数据频率再定。
每串口一线程是最直观的:开 8 个串口就起 8 个线程,每个线程内部阻塞读自己那一路。优点是逻辑隔离干净,某一路出问题不影响其他路,调试时挂个断点就能定位。缺点是线程数随通道线性增长,32 路以上时上下文切换开销开始明显,而且线程间共享状态(比如统一日志、统一发送队列)要加锁。
线程池 + 事件驱动是把串口句柄注册到统一的事件循环里,用少量线程处理多路 IO。它省线程,但串口在多数平台上并不是天然的事件源,需要靠定时轮询或平台特定的异步接口,实现复杂度高,跨平台一致性差。
单线程异步 IO 性能最好,但代码可读性最差,一旦某路回调里做了耗时操作,整个循环都被拖住。
对绝大多数串口测试场景(4 到 16 路,波特率不超过 921600),我推荐每串口一线程。它的收益/复杂度比最高,出问题也好排查。下面按这个结构展开。
2.2 线程模型与数据流设计
每个串口线程内部做三件事:阻塞读、解析、投递。读到的原始字节先进入本线程的接收缓冲,按协议做帧解析,解析出完整帧后投递到一个线程安全的队列,由独立的消费线程统一落盘或上报。发送方向则相反:业务线程把待发数据放进每口的发送队列,串口线程在两次读之间检查队列并写出。
这里有个关键点:读和写不要放在同一个阻塞点上。如果串口线程阻塞在read上,发送队列里的数据就得等下一次读返回才能发出去,实时性差。常见做法是给读设置超时(比如 50ms),超时返回后立刻处理发送队列,再回去读。这样读写都能兼顾。
import serial import threading import queue import time class SerialWorker(threading.Thread): def __init__(self, port, baudrate, rx_queue): super().__init__(daemon=True) self.port = port self.baudrate = baudrate self.rx_queue = rx_queue # 解析后的帧投递到这里 self.tx_queue = queue.Queue() # 待发送数据 self._stop = threading.Event() self.ser = None def run(self): # timeout=0.05 让 read 定期返回,腾出机会处理发送队列 self.ser = serial.Serial(self.port, self.baudrate, timeout=0.05) buf = bytearray() while not self._stop.is_set(): # 1. 优先发送 while not self.tx_queue.empty(): data = self.tx_queue.get() self.ser.write(data) # 2. 读取,超时返回空 chunk = self.ser.read(256) if chunk: buf.extend(chunk) buf = self._parse(buf) self.ser.close() def _parse(self, buf): # 按帧头帧尾切分,示例:0xAA ... 0x55 while True: start = buf.find(b'\xAA') if start < 0: return bytearray() end = buf.find(b'\x55', start) if end < 0: return buf[start:] # 半帧,留到下次 frame = buf[start:end+1] self.rx_queue.put((self.port, frame)) buf = buf[end+1:] def send(self, data): self.tx_queue.put(data) def stop(self): self._stop.set()这段代码里几个参数值得说清楚。timeout=0.05是读写兼顾的关键,太小会导致空转耗 CPU,太大则发送延迟高,50ms 是实测比较平衡的值。read(256)的 256 是一次读取上限,设太小会频繁系统调用,设太大在低速率下会长时间阻塞,一般取 128 到 512。_parse里用find找帧头帧尾是最朴素的定界方式,遇到帧内出现帧尾字节的情况会误切,所以真实协议要么用长度字段,要么对特殊字节做转义。
2.3 统一接收队列与消费线程
所有串口线程把解析后的帧投递到同一个rx_queue,消费线程负责落盘、打时间戳、做统计。这样做的价值是:日志顺序统一、统计口径一致,而且消费慢不会阻塞串口读。
def consumer(rx_queue, stop_event): with open("comm_log.csv", "w", encoding="utf-8") as f: f.write("ts,port,hex\n") while not stop_event.is_set(): try: port, frame = rx_queue.get(timeout=0.1) except queue.Empty: continue ts = time.time() f.write(f"{ts:.3f},{port},{frame.hex()}\n")消费线程用get(timeout=0.1)而不是死等,是为了能定期检查停止标志,否则退出时会被卡住。落盘用 CSV 而不是二进制,是为了后续用表格工具直接分析,代价是体积大,长时间跑建议按小时切文件。
3. 串口参数配置与收发时序:把丢包率压到零
3.1 必调的六个串口参数
串口能不能通,八成问题出在参数不匹配。下面这张表是我每次联调都会逐项核对的清单。
| 参数 | 常见取值 | 配错的表现 |
|---|---|---|
| 波特率 | 9600 / 115200 / 921600 | 全是乱码或收不到 |
| 数据位 | 8 | 帧解析全错 |
| 停止位 | 1 | 偶发错帧 |
| 校验位 | None / Even | 随机误码 |
| 流控 | None / RTSCTS | 高速下丢数据 |
| 读超时 | 50ms | 发送延迟或空转 |
波特率不匹配时,收到的往往是规律性的乱码,而不是完全没数据,这点容易误导人以为线接错了。校验位配错则表现为大部分帧正常、偶尔一帧错,长时间跑才暴露。流控在波特率超过 115200 且数据量大时才需要开,普通测试关掉即可。
3.2 收发时序与缓冲区设置
串口驱动和操作系统都有缓冲区。接收缓冲区太小,高速数据来的时候来不及取就走溢出,数据直接丢,而且丢得悄无声息。Linux 下可以用stty或termios调整,Windows 下用SetupComm设置。
# Linux 查看当前串口参数 stty -F /dev/ttyUSB0 -a # 设置原始模式,关闭回显和行处理 stty -F /dev/ttyUSB0 raw -echo -echoe -echokraw模式很关键。默认的规范模式下,驱动会按行缓冲、处理特殊字符(比如 0x0D 被当成回车),二进制协议数据会被篡改。做串口测试工具必须切到 raw 模式,让字节原样进出。
发送侧要注意帧间隔。如果两帧之间不留间隔,接收方可能把它们粘成一帧。常见做法是帧间至少留 3.5 个字符时间(Modbus RTU 就是这么定的),115200 下大约 0.3ms。软件层面可以在两次write之间sleep一小段,或者依赖协议本身的长度字段来切分。
3.3 时间戳与数据对齐
多路串口做对比测试时,时间戳的精度直接决定结论可信度。用time.time()只能到毫秒级,而且受系统调度影响。要求高的话用time.perf_counter()拿单调时钟,避免系统时间被调整时出现负间隔。
t0 = time.perf_counter() # ... 收到帧 ... dt = (time.perf_counter() - t0) * 1000 # 毫秒另一个坑是:不同串口线程各自打时间戳,如果线程被调度延迟,时间戳会偏。要严格对齐,应该在数据到达驱动的那一刻打,但应用层拿不到这个时刻,只能尽量在read返回后立刻打,并接受毫秒级误差。做多路同步测试时,我会在日志里同时记录「读返回时刻」和「帧解析完成时刻」,两者差值能反映解析耗时,异常大就说明那一路处理被拖了。
4. 避坑与排查:多串口跑久了必踩的五个坑
4.1 跑几小时后某一路数据整段消失
现象:测试跑两三个小时,日志里某一路串口突然有几秒到几十秒的空白,之后又恢复,设备本身没重启。
原因:接收缓冲区溢出。串口线程如果被其他操作(比如写日志、加锁等待)阻塞太久,驱动缓冲区满了之后新数据直接丢弃,且不报错。
解决:把耗时操作从串口线程里挪出去,串口线程只做读和投递;接收缓冲区调大;消费线程落盘用独立文件句柄避免锁竞争。我一般还会在串口线程里加一个「两次 read 间隔」的监控,超过阈值就记一条告警。
4.2 拔插 USB 转串口后线程卡死
现象:测试中途拔掉一个 USB 转串口设备,对应线程不退出也不报错,整个程序像卡住了。
原因:read在设备消失后可能一直阻塞或抛异常,如果异常没被捕获,线程就静默死掉;如果阻塞没超时,线程永远醒不来。
解决:read必须带超时;run方法整体包try/except,捕获SerialException后标记该路为断开并尝试重连;主控线程定期检查各 worker 的存活状态。
try: chunk = self.ser.read(256) except serial.SerialException as e: self.rx_queue.put((self.port, f"ERROR:{e}".encode())) self._reconnect()4.3 多线程同时写日志导致内容交错
现象:日志文件里出现半行、两行混在一起的情况。
原因:多个线程共用一个文件对象,write不是原子的。
解决:所有日志走同一个队列,由单一消费线程写;或者每个线程写独立文件最后合并。前者更简单,也是我在 CommTest 类工具里默认采用的方式。
4.4 波特率拉高后误码率上升
现象:115200 正常,升到 921600 后开始随机错帧。
原因:除了线材和干扰,还可能是驱动缓冲区太小、CPU 占用高导致取数不及时。
解决:先确认硬件(短线、屏蔽、共地),再调大缓冲区,降低单次read的字节数提高取数频率,必要时开硬件流控。软件层面能做的有限,硬件问题别硬扛。
4.5 线程数上去了 CPU 反而飙高
现象:8 路串口,CPU 占用 30% 以上,明明数据量不大。
原因:read超时设得太小,线程频繁空转;或者while循环里没有阻塞点。
解决:超时从 50ms 起调,观察 CPU 和延迟的平衡点;确认循环里确实有阻塞调用,别写成忙等待。
5. 进阶:把串口测试工具做成可复用的验证平台
前面讲的是「能跑」,这一章讲「跑得久、跑得可信」。多串口测试工具真正的价值不在收发本身,而在于它能作为一套可复用的验证平台,把偶发问题变成可复现的数据。
第一个技巧是压力回环。把每路串口的收发接成回环(TX 接 RX,或用软件回环),让工具自己发自己收,跑 24 小时统计丢帧率和时延分布。这能在没有真实设备的情况下暴露缓冲区、线程调度、日志落盘的问题。我一般会准备一组固定长度的测试帧,按固定间隔发送,接收端校验序号连续性,缺号就是丢了。
第二个技巧是注入异常。在解析层故意插入半帧、错校验、超长帧,看工具会不会崩、会不会把错误帧当成正常帧投递。很多工具在正常数据下表现完美,一遇到脏数据就乱套。解析函数应该是纯函数,输入字节输出帧列表,不依赖任何全局状态,这样才好单独测。
第三个技巧是配置化。把串口列表、波特率、协议参数、日志路径全部放到一个配置文件里,工具启动时读取。这样同一套代码能跑不同项目,不用改代码重新编译。
# commtest.yaml ports: - name: /dev/ttyUSB0 baudrate: 115200 protocol: frame_aa55 - name: /dev/ttyUSB1 baudrate: 921600 protocol: frame_len log: dir: ./logs rotate_mb: 100配置化之后,加一路串口只是加一段配置,测试不同设备只是换一个文件。这一步做完,工具才真正从「一次性脚本」变成「能反复用的东西」。
最后一个习惯:每次跑长测之前,先跑 5 分钟短测确认所有通道都有数据、时间戳正常、日志能落盘,再挂长测。我吃过太多次亏——挂了一夜,早上发现日志路径写错,一个字都没存下来,只能重跑。这个 5 分钟预检现在是我所有串口测试的固定动作。希望帮到你。
本文还有配套的精品资源,点击获取