多串口并发测试工具CommTest:VC6多线程串口通信工程实践
简介CommTest是一款面向嵌入式开发、工业自动化与物联网设备调试人员的多串口通信测试工具针对多路串口并行传输场景下的性能验证与故障排查需求而设计。资源包共41个文件约7.21MB以exe可执行程序为主辅以h头文件、cpp源码、obj与pdb调试文件及rc资源脚本等既可直接运行多个测试版本也便于二次开发与参数定制。工具支持多串口同时通信与多线程独立运行可设置波特率、停止位、校验方式等参数并具备误码率计算、丢包检测等评估能力帮助工程师在产品设计阶段验证通信稳定性、在维护阶段快速定位串口故障。目前已有557人学习下载适合希望掌握串口通信与多线程测试技术的中高级开发者参考实践。1. 多串口并发测试的工程价值为什么我留着这套 VC6 老工程去年帮一个做工业数据采集的朋友排查产线问题现场 12 台设备通过 RS-232 转 USB 集线器接到一台工控机上位机每隔 200ms 轮询一次。现象很怪单台设备通信正常一旦超过 6 台同时在线就开始随机丢包换线、换集线器、换驱动都试过问题依旧。后来我把 CommTest 这套工具翻出来用它同时开了 12 个串口做并发收发10 分钟就定位到是上位机串口读取线程阻塞导致的缓冲区溢出——这就是多串口多线程串口测试工具存在的意义。CommTest 是一套基于 MFC 和 MSComm 控件的串口测试工程从工程文件结构看是典型的 VC6 时代产物.dsp/.dsw 工程文件、mscomm.h/mscomm.cpp 封装类、cnComm.h 扩展头文件但它解决的核心问题至今没过时同时打开多个串口每个串口跑独立线程互不阻塞地做收发测试。它适合三类人一是做嵌入式/工控上位机开发、需要验证多串口并发稳定性的工程师二是维护老旧 VC6 项目、需要参考串口线程模型的开发者三是做设备老化测试、需要长时间跑多串口吞吐的测试人员。这套工程的价值不在于界面多漂亮而在于它把多串口多线程的骨架搭好了你改改参数就能用。2. 工程结构与线程模型从 .dsp 到 mscomm 封装类拿到一个 .rar 压缩包别急着双击 exe。先看目录结构这套工程的代码组织方式决定了你后面改代码时会不会翻车。2.1 文件清单与模块划分解压后大致分三层工程配置层、核心代码层、编译产物层。工程配置层是 VC6 的标准三件套——.dsp项目文件、.dsw工作区文件、.opt用户配置这三个文件决定了编译选项和依赖关系。核心代码层是真正要读的部分我按职责拆一下文件职责改动频率CommTestDlg.cpp/.h主对话框逻辑串口开关、参数设置、UI 刷新高mscomm.cpp/.hMSComm 控件封装类串口打开/关闭/读写中cnComm.h串口配置扩展波特率/校验/停止位定义低CommTest.cpp/.h应用入口MFC 初始化低StdAfx.cpp/.h预编译头不动resource.h / .rc资源定义对话框控件 ID按需编译产物层里有一堆 exeCommTest16Bp400ms.exe、CommTest16Bp200ms.exe、CommTest16Bp800ms3s.exe、CommTest500.exe、CommTest1500.exe、CommTest961.exe、CommTestOC.exe、CommTestOPen.exe——从命名能看出这是不同测试场景的编译版本Bp 大概率是 Baud/bit per 的缩写后面的数字是波特率或超时参数。这些 exe 可以直接跑但你要改逻辑就得回到源码。2.2 多线程串口收发的实现骨架MSComm 控件本身是 ActiveX单线程里用没问题但多个串口同时跑就会互相卡。这套工程的做法是每个串口实例配一个工作线程线程里循环读缓冲区。核心逻辑在CommTestDlg.cpp的串口打开和线程启动部分我按常见实现还原一下骨架// 串口配置结构每个串口一份 struct SSerialPort { UINT nPortNo; // 串口号1~N DWORD dwBaudRate; // 波特率如 9600/115200 BYTE byParity; // 校验位0-4 BYTE byStopBits; // 停止位 BYTE byDataBits; // 数据位 BOOL bRunning; // 线程运行标志 HANDLE hThread; // 工作线程句柄 CMSComm m_Com; // MSComm 封装对象 }; // 线程函数每个串口独立跑 UINT SerialWorkerThread(LPVOID pParam) { SSerialPort* pPort (SSerialPort*)pParam; while (pPort-bRunning) { // 读缓冲区事件MSComm 的 GetInBufferCount if (pPort-m_Com.GetInBufferCount() 0) { VARIANT vData pPort-m_Com.GetInput(); // 处理收到的数据做计数/校验/落盘 ProcessRxData(pPort-nPortNo, vData); } // 发送队列非空则发 if (HasTxData(pPort-nPortNo)) { pPort-m_Com.SetOutput(GetTxData(pPort-nPortNo)); } Sleep(1); // 让出 CPU避免空转吃满 } return 0; }这段代码的关键点有三个。第一bRunning标志必须用volatile或加锁否则主线程改了标志工作线程可能读不到这是 VC6 多线程的经典坑。第二Sleep(1)不是随便加的MSComm 的GetInBufferCount是轮询式查询不加延时 CPU 直接飙到 100%但加多了又影响实时性1ms 是常见折中。第三GetInput()返回的是VARIANT类型里面可能是字节数组也可能是字符串取决于InputMode设置处理时要先判断类型。2.3 编译环境与依赖确认这套工程是 VC6 的用 VS2019/2022 直接打开会提示升级升级后 MSComm 控件可能注册不上。我的建议是如果只是跑测试直接用现成的 exe如果要改代码装一个 VC6 或者用 VS2010 兼容模式。MSComm 控件mscomm32.ocx需要注册# 以管理员身份运行 cmd注册 MSComm 控件 regsvr32 mscomm32.ocx # 如果提示找不到文件先确认 ocx 在系统目录 copy mscomm32.ocx C:\Windows\SysWOW64\ regsvr32 C:\Windows\SysWOW64\mscomm32.ocx注册成功后在 VC6 里打开.dsw文件检查 Project Settings 里的链接库是否包含mscomm32.lib。如果编译报CMSComm未定义说明 mscomm.h 没被正确包含检查StdAfx.h里有没有#include mscomm.h。提示VC6 在 Win10/Win11 上安装本身就有兼容性问题如果只是临时改代码用 VS2010 打开并升级工程更省事但升级后要手动重新添加 MSComm 控件到对话框。3. 多串口并发配置参数设置与线程调度实操工程能编译只是第一步真正要让它跑出有意义的测试结果得把串口参数和线程调度调对。这一章讲具体怎么配、怎么改、怎么验证。3.1 串口参数配置与代码修改点每个串口的参数在CommTestDlg.cpp的初始化函数里设置常见做法是在OnInitDialog里遍历串口列表逐个配置。MSComm 的参数设置通过SetSettings完成格式是波特率,校验,数据位,停止位// 在 OnInitDialog 或串口打开函数里配置 BOOL CCommTestDlg::OpenSerialPort(int nPort, int nBaud) { CString strSettings; // 格式波特率,校验(N/O/E),数据位,停止位 strSettings.Format(_T(%d,n,8,1), nBaud); m_Com.SetCommPort(nPort); // 串口号 m_Com.SetInBufferSize(4096); // 接收缓冲区默认 1024 偏小 m_Com.SetOutBufferSize(4096); // 发送缓冲区 m_Com.SetInputMode(1); // 1二进制0文本 m_Com.SetInputLen(0); // 0一次读全部缓冲区 m_Com.SetRThreshold(1); // 收到 1 字节就触发事件 m_Com.SetSThreshold(0); // 发送不触发事件 m_Com.SetSettings(strSettings); // 应用参数 m_Com.SetPortOpen(TRUE); // 打开串口 return m_Com.GetPortOpen(); }参数说明SetInBufferSize默认 1024 字节多串口高频收发时很容易溢出改成 4096 或 8192 更稳。SetInputMode(1)是二进制模式如果你测试的是文本协议如 AT 指令改成 0 更方便。SetRThreshold(1)表示收到 1 字节就触发OnComm事件但注意——如果你用的是独立线程轮询这个阈值设不设影响不大因为线程是主动查GetInBufferCount。SetInputLen(0)表示一次读取全部缓冲区内容设成固定值会导致数据被截断。3.2 线程数量与调度策略多串口多线程的核心矛盾是线程开多了CPU 上下文切换开销大线程开少了串口等待时间长。这套工程默认每个串口一个线程12 个串口就是 12 个线程。在普通工控机上12 个线程完全扛得住但如果串口数超过 32建议改成线程池模式。线程优先级也值得调。默认Normal优先级下如果某个串口数据量大可能挤占其他串口的时间片。常见做法是把串口工作线程设为THREAD_PRIORITY_ABOVE_NORMAL// 创建线程时指定优先级 hThread AfxBeginThread(SerialWorkerThread, pPort, THREAD_PRIORITY_ABOVE_NORMAL, // 略高于普通 0, CREATE_SUSPENDED); if (hThread) { hThread-m_bAutoDelete FALSE; // 手动管理生命周期 hThread-ResumeThread(); }CREATE_SUSPENDED标志让线程创建后先挂起等你把SSerialPort结构体填完再ResumeThread避免线程启动时读到未初始化的数据。m_bAutoDelete FALSE是为了在线程退出时能手动WaitForSingleObject确认它真的结束了否则可能出现线程还在跑、对象已经析构的崩溃。3.3 收发测试与结果验证配置好之后怎么验证多串口并发是否正常我的做法是用两个 USB 转串口模块做回环测试一个模块的 TX 接另一个模块的 RX然后 CommTest 同时打开两个串口一个发一个收对比发送字节数和接收字节数是否一致。// 发送端定时发送递增序列 void CCommTestDlg::OnTimerSend() { static BYTE seq 0; BYTE buf[256]; for (int i 0; i 256; i) { buf[i] seq; // 递增序列方便接收端校验 } for (int port 0; port m_nPortCount; port) { if (m_ports[port].bRunning) { m_ports[port].m_Com.SetOutput(COleVariant(buf, 256)); } } } // 接收端校验序列连续性 void ProcessRxData(int nPort, VARIANT vData) { // 将 VARIANT 转为字节数组 BYTE* pData (BYTE*)vData.parray-pvData; long nLen vData.parray-rgsabound[0].cElements; for (long i 0; i nLen; i) { if (pData[i] ! m_expectedSeq[nPort]) { m_nErrorCount[nPort]; // 序列不连续计一次错 } m_expectedSeq[nPort] pData[i] 1; } m_nRxCount[nPort] nLen; }跑 30 分钟如果m_nErrorCount为 0 且m_nRxCount等于m_nTxCount说明并发收发稳定。如果有错先看是不是波特率不匹配再看缓冲区是否溢出GetInBufferCount是否经常接近InBufferSize。注意回环测试时两个串口的波特率必须完全一致且停止位、校验位也要一致否则收到的就是乱码别问我怎么知道的。4. 避坑与排查多串口测试里那些翻车现场这套工程我用了不下 20 次踩过的坑能写满一页 A4 纸。挑 5 个最典型的按「现象 → 原因 → 解决」列出来你遇到问题时直接对号入座。4.1 串口打开失败提示“端口已被占用”现象调用SetPortOpen(TRUE)后GetPortOpen()返回 FALSE或者直接弹异常。原因三种可能——串口被其他程序占用如串口调试助手没关、串口号不存在USB 转串口没插好、MSComm 控件没注册。解决先用设备管理器确认串口号再用CreateFile测试串口是否可打开HANDLE hTest CreateFile(_T(COM3), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hTest INVALID_HANDLE_VALUE) { DWORD dwErr GetLastError(); // ERROR_ACCESS_DENIED 被占用 // ERROR_FILE_NOT_FOUND 串口不存在 } CloseHandle(hTest);如果CreateFile能打开但 MSComm 打不开检查控件注册。4.2 多串口同时收发时数据错乱现象A 串口收到的数据出现在 B 串口的显示框里或者数据内容对不上。原因线程函数里用了全局变量或静态变量存串口数据多个线程竞争同一块内存。解决每个串口的接收缓冲区必须是独立的放在SSerialPort结构体里不要用static或全局数组。如果确实需要共享计数器用InterlockedIncrement做原子操作。4.3 程序运行一段时间后无响应现象跑十几分钟后界面卡死但串口灯还在闪。原因工作线程里做了 UI 操作如直接SetWindowTextMFC 的 UI 更新必须在主线程做跨线程直接调会死锁。解决工作线程只负责收数据、存缓冲区通过PostMessage通知主线程刷新 UI// 工作线程里 ::PostMessage(pDlg-m_hWnd, WM_UPDATE_UI, nPort, 0); // 主线程消息处理 LRESULT CCommTestDlg::OnUpdateUI(WPARAM wParam, LPARAM lParam) { int nPort (int)wParam; UpdatePortDisplay(nPort); // 安全地刷新界面 return 0; }4.4 高波特率下丢包严重现象115200 波特率下接收字节数明显少于发送字节数。原因Sleep(1)的轮询间隔在高波特率下不够快115200 波特率约每 87μs 传 1 字节1ms 的轮询间隔意味着缓冲区可能已经堆了几十个字节如果InBufferSize不够大就会溢出。解决两个方向——把SetInBufferSize调到 8192 或更大把Sleep(1)改成WaitForSingleObject等待串口事件或者用OnComm事件驱动代替轮询。事件驱动模式下SetRThreshold(1)才有意义。4.5 编译报错“无法打开预编译头文件”现象换了一台机器编译报fatal error C1083: Cannot open precompiled header file。原因CommTest.pch是预编译头产物换机器后路径变了或者文件损坏。解决删除Debug目录下的.pch文件在 VC6 里执行Rebuild All。如果还不行检查StdAfx.cpp的编译选项里是不是设了Create Precompiled Header。5. 从测试工具到产线工具参数化改造与长时间跑测技巧这套工程直接拿来跑 demo 没问题但要放到产线上做老化测试还得做几处改造。我一般会做三件事参数外置、日志落盘、异常自恢复。参数外置是把串口号、波特率、发送间隔这些从代码里挪到配置文件改参数不用重新编译。常见做法是读一个config.ini// 读取配置文件 int nPortCount GetPrivateProfileInt(_T(Global), _T(PortCount), 4, _T(.\\config.ini)); for (int i 0; i nPortCount; i) { CString strKey; strKey.Format(_T(Port%d), i); m_ports[i].nPortNo GetPrivateProfileInt(strKey, _T(PortNo), i1, _T(.\\config.ini)); m_ports[i].dwBaudRate GetPrivateProfileInt(strKey, _T(Baud), 9600, _T(.\\config.ini)); }日志落盘是长时间跑测的后悔药。每个串口的收发计数、错误计数、时间戳每隔 10 秒写一次 CSV跑 72 小时后直接看日志就能定位问题发生在哪个时间段。我习惯用CStdioFile追加写注意多线程写同一个文件要加锁或者每个串口写独立文件。异常自恢复是产线工具和 demo 的分水岭。串口在长时间运行后可能因为驱动或硬件原因掉线工具要能检测到并自动重连。我的做法是在工作线程里加一个心跳检测如果连续 30 秒没有收到任何数据且发送队列非空就关闭串口重新打开。// 心跳检测放在工作线程循环里 if (GetTickCount() - pPort-dwLastRxTime 30000 pPort-bTxPending) { pPort-m_Com.SetPortOpen(FALSE); // 关闭 Sleep(500); // 等驱动释放 pPort-m_Com.SetPortOpen(TRUE); // 重开 pPort-dwLastRxTime GetTickCount(); }最后说一个验证技巧跑长时间测试时用另一个串口工具或者 CommTest 自己开一个串口监听被测串口的 TX 线对比两边计数。如果 CommTest 显示发了 100 万字节监听端只收到 99.8 万那 0.2 万就是真实丢包不是工具统计错误。从那以后我每次做多串口老化测试都强制在外部挂一个独立监听串口做交叉验证再也没被“工具显示正常但设备实际丢包”坑过。希望帮到你。本文还有配套的精品资源点击获取