基于VC与MFC的串口调试工具开发:从线程模型到CRC校验的完整实践
发布时间:2026/9/2 15:41:17 作者:尧图编辑部 阅读量:1,286

简介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; }打开之后要做的是配置串口参数核心是 DCBDevice 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来更新接收框数据量一大必然会卡这两个函数需要每次都重新创建窗口内部的文本副本开销非常高。改用ReplaceSelSetSel的组合性能至少提升一个数量级。数据丢失的问题则更多出在接收线程的逻辑上。如果不采用事件驱动而是简单粗暴地用 Sleep(10) 循环去 ReadFile就会收到什么算什么Windows 串口缓冲区一旦满了旧数据就会被新数据覆盖导致抓不住完整的数据帧。解决方法是把串口缓冲区调大用 SetupComm 函数把接收缓冲区设置为 64KB 甚至更大同时确保接收线程在数据到达时第一时间就把数据读走。4.4 图标和版本信息让工具看起来靠谱最后分享一个很多人忽略的细节给工具设置一个像样的图标和版本信息。我从 VC6 时代做这个小工具开始一直用默认的 MFC 图标直到有一次同事说你这个工具怎么连个图标都没有像是没做完的作业才意识到这些小细节在别人眼里的重要性。换图标的方法是制作一个 ICO 文件在资源视图里导入然后修改工程资源文件中图标资源的 ID让窗口类默认使用这个自定义图标。版本信息在 .rc 资源文件里加一段 VERSIONINFO 块写上版本号、产品名称、版权信息。设置版本信息之后在资源管理器里右键文件属性可以看到详细的产品信息这在发版给团队内部使用时会显得专业。如果你准备把工具分享给社区的同行我建议在关于对话框里加上版本号和编译日期方便后续用户反馈问题时快速定位代码版本。我个人在实际开发中的体会是串口调试工具看似简单但把界面、线程、缓冲、编码转换这些细节都做好是一个很锻炼人的过程。如果自己动手实现了这么一套理解了背后的机制你再去用任何商业串口工具都会有一种知己知彼的感觉出了问题也能快速判断是工具问题还是设备问题。这个工具后续还可以扩展虚拟串口对测试、实时波形显示、数据自动保存到文件、Modbus 协议解析插件等功能每一步扩展都会让这个工具更贴合你的实际工作流。本文还有配套的精品资源点击获取