简介:ComTest串口调试工具是一份基于Visual C++开发的串口通信工程,面向硬件开发、嵌入式系统调试与物联网设备测试场景,解决RS-232标准下串口数据收发、波特率/校验位配置及调试环境搭建等问题。工程实现遵循Win32串口编程经典流程,通过CreateFile打开串口、SetCommState设置参数、ReadFile/WriteFile完成数据读写,并配合CloseHandle释放资源,清晰展示串口工具的核心开发思路。
压缩包为RAR格式,共18个文件,主要包括3个C++源文件与5个头文件,另有工程配置(dsp/dsw)、界面资源(rc/rc2/ico)及ReadMe说明文档,整体体积仅27KB,结构紧凑,便于快速翻阅和编译学习。
目前已有539人浏览学习。资源内含cnComm.h等串口通信封装类以及对话框程序框架,能够直观演示串口数据的接收与发送,可作为理解RS-232协议、练习VC++串口编程的入门范例,对从事物联网设备调试或希望掌握Win32 API串口操作的开发者具有较高参考价值。
1. 为什么我在VC下写一个串口调试工具
在嵌入式开发和工控上位机领域,串口调试工具几乎是每天都要碰的东西。无论是调单片机程序、配置传感器参数,还是抓设备的上报协议,没有一款顺手的上位机工具,工作效率会低得让人抓狂。ComTest 就是这样一款基于 VC 开发的串口调试工具,它解决的核心问题很简单:通过电脑的串口向设备发送数据、接收设备回传的数据,并且以程序员习惯的十六进制格式实时展示出来。
其实市面上现成的串口助手不少,网上随便一搜就有很多免费工具,但用起来总会遇到不顺手的地方:要么界面太小、字体没法调,要么自动发送间隔做得太粗糙,要么接收区刷新太快导致界面卡死。最关键的一个问题是,这些工具大都是"黑盒",协议解析、校验算法、数据过滤这些功能没法根据自己的需求改。对于常年跟硬件打交道的开发者来说,自己动手写一个串口调试工具,不是没事找事,而是最稳妥的方式。
我选择 VC 而不是 C# 或者其他高级语言,主要有两个原因。第一,MFC 封装好的串口控件和消息机制非常成熟,CreateFile、ReadFile、WriteFile 这一套 API 用起来直接,不依赖庞大的运行时环境,生成的 exe 拷贝到任何 Windows 机器上都能跑。第二,写串口工具本质上也是练习 VC 开发的最佳项目之一,从界面布局、控件消息响应、多线程处理到自定义消息、CRC 校验,几乎所有 MFC 开发的核心知识点都能在这个小项目里过一遍。
这个项目的定位非常明确:一个单文档界面的工具,打开后选择串口号和波特率,点击打开串口,然后就可以进行数据的发送和接收。接收区支持 ASCII 和 Hex 两种显示方式,发送区支持手动发送和定时自动发送,每一帧收发的数据都带有时间戳,方便分析协议时序。下面我把整个开发过程中的设计思路、核心代码和踩过的坑整理出来,供同样需要自己写串口工具的朋友参考。
2. 整体架构与界面设计思路
2.1 界面布局:操作效率优先
ComTest 的界面是我反复调整过的,最终版本采用了一个经典的上下分栏布局。顶部是串口参数设置区,包括串口号下拉框、波特率下拉框、数据位、停止位、校验位选项,以及一个"打开串口"按钮。中间是数据收发区域,左边是接收数据窗口,右边是发送数据窗口,两个窗口都可以独立切换 Hex 和 ASCII 显示模式。底部是辅助功能区,包括发送按钮、定时发送设置、清空接收区按钮、时间戳开关,以及一个发送计数和接收计数的状态栏。
为什么这样布局?因为实际调试的时候,你的眼睛90%的时间盯的是接收区,发送区只需要偶尔看一眼。如果接收区和发送区左右分栏但尺寸均等,接收区字太小看不清,或者你希望接收区占更大面积,可以用 Splitter 分割窗体(CSplitterWnd)让用户自由调整。我在实现时让接收区默认占窗口宽度的 60%,用户可以通过拖动分割条随时调整比例,右侧发送区如果要看长一点的协议帧,拖宽一点即可。
接收区我用的是 CEdit 控件,设置了只读属性,并开启多行和垂直滚动条。所有收到的数据通过CEdit::ReplaceSel追加到末尾,同时调用LineScroll把视图滚动到最底部,保证新数据永远可见。这里有个细节:在每次 ReplaceSel 之前,必须先调用SetSel把光标定位到文本末尾,否则新数据会插在光标当前所在的位置,而不是追加到末尾,等数据量大了之后界面内容就会乱成一团。另外,接收区如果数据量太大,内存占用会越来越多,所以我在界面上加了一个清空按钮,同时设定最大值,比如超过 1MB 时自动清掉一半的旧数据,这个小策略能避免长时间运行后的内存膨胀。
2.2 线程模型与消息机制:为什么不能直接循环接收
串口工具的架构核心在于线程模型。打开串口之后,如果直接在 UI 线程里调用 ReadFile 去读数据,设备一旦没有数据返回,ReadFile 就会一直阻塞在那里,整个界面就会假死,按钮点了没反应,窗口挪动都成问题。这种方案在实践中是绝对不能用的。
正确做法是创建专门的接收线程,在线程内部通过 WaitCommEvent 事件机制等待串口数据到达,数据来了之后就调用 ReadFile 读取到缓冲区,然后通过 PostMessage 把数据发送给主窗口。这里有个关键点:接收线程不能直接操作界面控件,Windows 窗口控件不是线程安全的,直接跨线程写入 CEdit 会导致刷新闪烁、数据错乱,严重时直接崩溃。线程只负责把原始数据字节通过自定义消息传回主线程,由主线程的消息响应函数统一更新界面。
在 MFC 中,自定义消息的方法很简单。在类头文件里声明消息值,比如#define WM_MY_RX_DATA (WM_USER + 100),然后在窗口类的消息映射里加上ON_MESSAGE(WM_MY_RX_DATA, OnMyRxData),最后实现对应的消息响应函数。这样做的好处是数据接收和界面刷新解耦,接收线程永远只是往消息队列里丢数据,而界面刷新由 Windows 消息循环统一调度,每个接收到的数据包进来都会触发一次消息响应,然后再追加显示到文本框。实测下来,即使设备以 115200 波特率高速连续发数据,主界面依然流畅不卡顿。
定时发送和发送线程也是同样道理。如果只是用 MFC 的 SetTimer 定时器,最小间隔只能到约 55ms 左右(Windows 定时器的分辨率限制),对于需要 1ms~20ms 快速发包的场景就不够用了。我的做法是单独起一个发送线程,配合 Sleep 函数做节流,代码逻辑就是根据界面设置的间隔值循环 Sleep,然后调用 WriteFile 写入数据。线程里用一个控制变量控制循环的开始和停止,避免重复创建线程导致资源泄漏。
3. 核心代码实现与踩坑记录
3.1 打开串口与参数配置:CreateFile 和 DCB 的细节
串口在 Windows 中被当作文件来处理,第一步就是调用 CreateFile 打开串口设备。这里有几个关键参数必须注意:dwDesiredAccess要设置为GENERIC_READ | GENERIC_WRITE,表示可读可写;dwShareMode必须设为 0,表示独占串口,不与其他进程共享,另外OPEN_EXISTING是必须的,串口设备只能以打开已存在设备的方式来打开。
HANDLE hComm = CreateFile( _T("COM3"), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, // 不使用重叠I/O NULL); if (hComm == INVALID_HANDLE_VALUE) { AfxMessageBox(_T("无法打开串口,请检查串口号是否被占用")); return FALSE; }打开之后要做的是配置串口参数,核心是 DCB(Device Control Block)结构体。最稳妥的方式是先调用 GetCommState 获取当前串口的默认配置,再修改需要改的字段,最后调用 SetCommState 生效。直接创建一个空白的 DCB 结构体再手动填充所有字段的做法很容易漏掉字段,因为 DCB 有很多保留字段和默认值,漏掉一个就可能导致串口打不开或者参数不对的隐性问题。
DCB dcb; GetCommState(hComm, &dcb); dcb.BaudRate = 115200; // 波特率 dcb.ByteSize = 8; // 数据位 dcb.Parity = NOPARITY; // 无校验 dcb.StopBits = ONESTOPBIT; // 1位停止位 dcb.fBinary = TRUE; dcb.fParity = FALSE; SetCommState(hComm, &dcb);还有一个经常被忽略的超时配置。SetCommTimeouts 不设置或者设置不对,会导致 ReadFile 阻塞时间异常,接收线程在等待数据时无法及时响应停止命令。我用的是一套比较保守的超时配置:读操作和写操作都设置 100ms 的总超时时间。这样接收线程在串口没有数据时最多阻塞 100ms 就会返回,可以及时检查停止标志,退出线程,不会造成线程无法关闭的问题。
3.2 接收线程与字节缓冲区的管理
接收线程的实现方式,我选择了事件驱动的 WaitCommEvent 方案,而不是循环 ReadFile。这样串口没有数据时线程会进入等待状态,不消耗 CPU 时间,而且响应速度比轮询快得多。核心代码如下:
UINT ReceiveThreadProc(LPVOID pParam) { CComTestDlg* pDlg = (CComTestDlg*)pParam; HANDLE hComm = pDlg->m_hComm; OVERLAPPED ov; memset(&ov, 0, sizeof(ov)); ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); BYTE buf[256]; DWORD dwBytesRead = 0; DWORD dwEvtMask = 0; while (pDlg->m_bThreadRunning) { WaitCommEvent(hComm, &dwEvtMask, &ov); // 等待数据到达事件 if (pDlg->m_bThreadRunning && (dwEvtMask & EV_RXCHAR)) { ReadFile(hComm, buf, sizeof(buf), &dwBytesRead, NULL); if (dwBytesRead > 0) { // 通过消息发送给主窗口 pDlg->PostMessage(WM_MY_RX_DATA, dwBytesRead, (LPARAM)buf); } } } CloseHandle(ov.hEvent); return 0; }这里要多说一句关于缓冲区的问题。上面的代码一次性最多读 256 字节,但串口数据是源源不断来的,当上位机接收速度略慢于设备发送速度时,数据就会在操作系统串口缓冲区里堆积。如果你只调用一次 ReadFile 就回到 WaitCommEvent 等待,那么读取的效率太低。更稳妥的做法是在 ReadFile 之后循环读取,直到返回数据长度为 0,再回到 WaitCommEvent 等待下一个事件。我后来在代码里加了一个读取循环,一次事件到来后把缓冲区里的数据全部读完,这样即使设备连续快速发几百字节的协议帧,也不会出现截断或者粘包的问题。
3.3 字节数组转换成字符串:显示层的两个关键优化
串口收到的原始数据是 BYTE 数组,在界面显示时需要转换成字符串。这里有两个场景:一是 ASCII 显示模式,需要把字节转换成对应的字符;二是 Hex 显示模式,需要把每个字节转换成两位大写十六进制字符串。很多初学 VC 的同学喜欢用一个 for 循环逐字节拼接到 CString 上,比如str += tmp,在数据量小的时候没问题,但当设备以高频率持续传输数据时,这种逐字节拼接到 CString 的方式效率极低。CString 的每次拼接操作都可能触发内存重新分配和数据拷贝,数据量一上来界面就非常卡。
我的优化方案是使用一个足够大的字符缓冲区,一次拼接完再一次性写入 CString。对于 Hex 模式,用一个查表法把字节映射成十六进制字符,避免 printf 格式化函数的开销。查表法的原理很简单:定义一个长度为 16 的常量字符串数组,存放 0~F 对应的 ASCII 字符,然后对每个字节的高 4 位和低 4 位分别查表,直接得到两个字符。这种方式的效率比 sprintf 高出一个数量级。
// 查表法:字节转十六进制字符串 const TCHAR hexTable[] = _T("0123456789ABCDEF"); void ByteToHexString(BYTE* pData, DWORD dwLen, CString& strOut) { TCHAR szBuf[4096]; DWORD dwPos = 0; for (DWORD i = 0; i < dwLen; i++) { if (dwPos < sizeof(szBuf) / sizeof(TCHAR) - 3) { szBuf[dwPos++] = hexTable[(pData[i] >> 4) & 0x0F]; szBuf[dwPos++] = hexTable[pData[i] & 0x0F]; szBuf[dwPos++] = _T(' '); } } szBuf[dwPos] = _T('\0'); strOut = szBuf; }第二个细节是接收区刷新策略。如果设备每秒发来几千字节,每次都 PostMessage 一条消息,主窗口的消息队列会被淹没,界面刷新不过来。我在实现时做了一个简单的数据累积:接收线程先往内部缓冲队列里存数据,主窗口的消息响应函数一次性把队列里的所有数据取出来,转换成字符串后一次追加到 CEdit 控件中。这样消息数量从每秒几千条降到了几十条,界面刷新压力大大降低。实际测试连续接收一小时数据,界面依然丝滑流畅,没有出现内存泄漏和界面卡死。
3.4 定时发送与 CRC 校验:协议调试的加速器
定时发送是串口调试工具的刚需,做通信协议调试时,经常需要以固定间隔持续发送同一帧数据来测试设备的响应稳定性。我的实现是在发送线程里循环判断定时间隔,使用Sleep函数做节流,通过一个标志位控制线程退出。
CRC 校验模块是我后来根据实际需求加上去的。很多设备协议规定数据帧末尾带 CRC16 校验码,手动计算容易出错。我在工具里集成了 CRC16-Modbus 算法,用户只需要在发送区输入原始数据,勾选"自动添加 CRC16 校验",工具就会计算出两个字节的校验码附加到帧末尾。协议调试的效率提升非常明显。
// CRC16-Modbus 计算 unsigned short CRC16_Modbus(BYTE* pData, int nLen) { unsigned short crc = 0xFFFF; for (int i = 0; i < nLen; i++) { crc ^= pData[i]; for (int j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }4. 常见问题与排查技巧实录
4.1 串口打开失败:不是代码问题,是环境问题
串口打开失败是最常见的问题。我第一版代码在自己机器上跑得好好的,换到同事电脑上就报"无法打开串口",排查了一圈发现原因很基础:对方电脑上某个商业串口助手软件(如友善串口调试助手)把 COM3 占用了,我的工具当然就打开失败。这个问题的解决方法是:打开串口之前,先弹出一个提示框,告诉用户"如果打开失败,请先关闭其他串口工具"。另外也可以在打开失败的对话框里列出系统中所有可用串口的枚举信息,引导用户选择正确的端口。
还有一种隐蔽的情况:USB 转串口的设备在每次插拔后,系统分配的 COM 端口号可能会变,第一次插上可能是 COM4,重新拔插后变成了 COM7。我用的枚举串口方法是直接读取注册表HKEY_LOCAL_MACHINE\HARDWARE\DEVICEMAP\SERIALCOMM,把系统当前存在的所有串口号枚举出来填进下拉框,避免用户手动输入不存在的端口号。
4.2 VC6 与新版 VC 的兼容问题
这个项目一开始是在 VC6.0 下开发的,后来迁移到 VS2019 时遇到了一些兼容性问题,这里需要特别注意。最典型的问题是两个:字符集和类型转换。VC6 默认使用 ANSI 编码(MBCS),而新版 VS 默认使用 Unicode 编码,所有字符串处理函数都需要相应调整。如果你在 VC6 里用了CHAR、LPSTR直接操作字符串,迁移到新版本时会有一大堆编译错误。我建议新项目直接使用CString配合TCHAR宏,这样代码在两种字符集下都能编译通过,只需要在项目属性里切换字符集即可。
另一个坑是for循环的变量作用域。VC6 的 C++ 标准比较老,for 循环里的变量会被提升到函数作用域,而新版 VC 严格按照标准做了作用域隔离。代码从 VC6 迁移到新版时,如果在一个函数里有两个 for 循环都声明了int i,在 VC6 下能编译通过,到了新版 VS 就会报i重定义的错误。我迁移的时候花了不少时间处理这类问题,所以如果你打算从 VC6 迁到新版本,这类小坑要提前心里有数。
4.3 界面卡顿和数据丢失:常见优化方向
界面卡顿是串口工具最容易出的问题。数据高速到达时,接收区刷新不及时,CPU 占用率高,或者窗口拖动卡顿。造成这种问题的原因主要是刷新方式效率太低。如果你现在用的是SetWindowText或者SetDlgItemText来更新接收框,数据量一大必然会卡,这两个函数需要每次都重新创建窗口内部的文本副本,开销非常高。改用ReplaceSel+SetSel的组合,性能至少提升一个数量级。
数据丢失的问题则更多出在接收线程的逻辑上。如果不采用事件驱动而是简单粗暴地用 Sleep(10) 循环去 ReadFile,就会收到什么算什么,Windows 串口缓冲区一旦满了,旧数据就会被新数据覆盖,导致抓不住完整的数据帧。解决方法是把串口缓冲区调大,用 SetupComm 函数把接收缓冲区设置为 64KB 甚至更大,同时确保接收线程在数据到达时第一时间就把数据读走。
4.4 图标和版本信息:让工具看起来靠谱
最后分享一个很多人忽略的细节:给工具设置一个像样的图标和版本信息。我从 VC6 时代做这个小工具开始,一直用默认的 MFC 图标,直到有一次同事说"你这个工具怎么连个图标都没有,像是没做完的作业",才意识到这些小细节在别人眼里的重要性。换图标的方法是制作一个 ICO 文件,在资源视图里导入,然后修改工程资源文件中图标资源的 ID,让窗口类默认使用这个自定义图标。
版本信息在 .rc 资源文件里加一段 VERSIONINFO 块,写上版本号、产品名称、版权信息。设置版本信息之后,在资源管理器里右键文件属性,可以看到详细的产品信息,这在发版给团队内部使用时会显得专业。如果你准备把工具分享给社区的同行,我建议在关于对话框里加上版本号和编译日期,方便后续用户反馈问题时快速定位代码版本。
我个人在实际开发中的体会是,串口调试工具看似简单,但把界面、线程、缓冲、编码转换这些细节都做好,是一个很锻炼人的过程。如果自己动手实现了这么一套,理解了背后的机制,你再去用任何商业串口工具都会有一种"知己知彼"的感觉,出了问题也能快速判断是工具问题还是设备问题。这个工具后续还可以扩展虚拟串口对测试、实时波形显示、数据自动保存到文件、Modbus 协议解析插件等功能,每一步扩展都会让这个工具更贴合你的实际工作流。
本文还有配套的精品资源,点击获取