VS2015+MFC串口调试助手:从CSerialPort封装到二次开发实践
发布时间:2026/9/16 7:09:58 锦皓数字建站

简介基于Visual Studio 2015与MFC编写的串口调试助手完整VS工程面向C/MFC初学者、嵌入式开发者以及需要快速搭建串口调试工具的工程师。工程围绕CSerialPort类实现串口打开/关闭、波特率与数据位等参数配置、数据收发及事件处理界面采用对话框设计可直接编译运行并作为二次开发基础。压缩包共22个文件大小约541KB包含6个C头文件、4个源文件、VS2015解决方案与工程文件、RC资源脚本、图标/图片素材及ReadMe说明等目录结构清晰便于定位核心逻辑。目前已有60人学习下载。使用这套源码可省去从零搭建MFC串口框架的时间重点参考SerialPort封装与对话框实现即可理解串口通信在Windows平台下的完整调用流程同时适合课程设计或毕业设计场景按需扩展数据保存、自动发送、协议解析等功能。1. 为什么不建议跳过VS2015MFC的串口调试助手手头有一块STM32通过USB转串口接电脑我要确认固件里的波特率到底是不是115200。拿起一个现成的commix或者sscom当然简单但公司安全策略不允许装第三方工具。此时这个用VS2015写的MFC串口调试助手就有用了它是源码可以直接编译没有依赖还能按需改功能。这个项目基于CSerialPort类把CreateFile、WaitCommEvent这些Windows串口API封装成了Open、Read、Write这样的接口配合MFC的消息循环实现了一个典型的串口收发界面。适合正在学C和Windows编程的人也适合需要维护老设备上位机的工程师。我们把它拆开看。2. MFC串口通信的核心机制与CSerialPort封装原理2.1 从CreateFile到CSerialPort的封装Windows对串口设备的管理方式是通过设备句柄。任何串口操作都以CreateFile打开COM口为起点后续使用SetCommState配置参数ReadFile/WriteFile收发数据最后CloseHandle关闭。CSerialPort类最主要的贡献是把这些API函数收敛成一组面向C的接口。Windows APICSerialPort封装说明CreateFileOpen / OpenPort打开串口句柄需要指定COM口号、共享模式SetCommStateSetBaudRate / SetDataBits设置波特率、数据位等DCB参数SetCommTimeoutsSetTimeouts配置读写超时避免ReadFile死等WaitCommEventStartMonitoring / OnReceive事件驱动接收数据到达时触发ReadFileRead / ReadAsync读取驱动缓冲区中的数据WriteFileWrite把用户数据写入串口CloseHandleClose释放句柄注意这个表不是标准MFC文档而是这个类最常见的设计方式。在项目里SerialPort.cpp就是把上述API和MFC消息机制绑在一起。2.2 CSerialPort类的事件驱动模型很多初学串口的人会直接把ReadFile放在一个死循环里等不到数据就卡住。更好的做法是事件驱动。这个项目中CSerialPort的工作方式是打开串口后创建一个工作线程在线程中调用SetCommMask(m_hComm, EV_RXCHAR | EV_TXEMPTY | EV_ERR)来监听字符到达等事件然后进入WaitCommEvent阻塞。一旦有数据到达WaitCommEvent返回线程通过ReadFile把数据取出来再用PostMessage通知MFC主窗口。核心代码示意// SerialPort.cpp 中监控线程的核心循环 DWORD WINAPI CSerialPort::CommThreadFunc(LPVOID pParam) { CSerialPort *pSerial (CSerialPort *)pParam; DWORD dwEvent 0; OVERLAPPED ov; // 常见实现用同步事件这里示意为循环 while (!pSerial-m_bStop) { WaitCommEvent(pSerial-m_hComm, dwEvent, NULL); if (dwEvent EV_RXCHAR) { COMSTAT comStat; DWORD dwErrors; ClearCommError(pSerial-m_hComm, dwErrors, comStat); if (comStat.cbInQue 0) { // 向主窗口投递消息由OnReceive处理 ::PostMessage(pSerial-m_hWnd, WM_COMM_RXCHAR, 0, 0); } } else if (dwEvent EV_ERR) { ::PostMessage(pSerial-m_hWnd, WM_COMM_ERROR, 0, 0); } } return 0; }这段代码中pSerial-m_bStop是控制线程退出的标志位在Close函数里会被置为TRUE并等待线程结束。WaitCommEvent返回后先通过ClearCommError拿到comStat.cbInQue这个字段是驱动缓冲区中当前等待读取的字节数。如果直接ReadFile可能只读到一个不完整的数据包所以先查队列再读是更稳妥的做法。这里有个细节同步方式下WaitCommEvent只有在串口事件发生时才返回。如果用户界面还需要处理其他消息不能在这里直接调用ReadFile否则界面的控件事件会阻塞。所以代码中用的是PostMessage而不是SendMessage。PostMessage是异步投递不会等待主窗口处理完成这是保持接收线程及时返回的关键。2.3 为什么事件驱动比轮询更适合GUI程序如果使用while循环轮询ReadFileCPU占用率会明显升高。我在实际项目里测过一个不设超时的轮询线程可以让单核CPU占用率达到15%以上。事件驱动在静默时没有额外开销。更重要的是MFC主窗口本身有消息循环事件驱动模式可以把串口数据和按钮点击、定时器、绘制消息统一调度不会出现“界面卡死”的体验。这也不是没有代价。事件驱动的缺点是处理逻辑分散在消息响应函数里数据包拆包逻辑要自己维护。所以这个串口助手里接收消息响应函数中的缓冲区管理和数据拼包才是真正需要花时间看的部分。这就解释了为什么这个项目有SerialPort.cpp和SerialCommunicationTestDlg.cpp两个主要文件前者负责串口底层和事件线程后者负责把接收到的字节流显示到界面上。3. 基于VS2015的串口调试助手实现从工程到收发流程3.1 从zip到可以编译运行的VS2015工程压缩包结构是一个标准的MFC对话框程序SerialCommunicationTest.sln是整个解决方案SerialCommunicationTestDlg.cpp是主对话框类SerialPort.cpp是串口类实现stdafx.h是预编译头文件res目录下有对话框资源。在VS2015里直接双击sln按F7编译就会生成exe。如果你现在用的是VS2019或VS2022打开时会提示升级工程一般选择不升级也能编译只要安装过MFC组件。需要注意的是如果出现“错误LNK1120无法解析的外部符号”多半是工程没有包含MFC静态库或附加依赖项设置问题。项目中有一个MFCkilll.bat批处理文件。从名字看这是用来清理MFC编译残留或强制结束调试进程的我一般会在编译前先执行它防止上次运行的程序没有完全退出导致串口无法打开。类似这样的脚本在这个场景里很实用。3.2 在OnInitDialog中完成串口初始化主对话框初始化时通常会在OnInitDialog里创建CSerialPort对象并打开默认串口。这里展示最常见的初始化流程BOOL CSerialCommunicationTestDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 创建串口对象并关联窗口句柄 m_SerialPort.Init(this); // 打开COM3波特率1152008位数据1位停止位无校验 BOOL bOpened m_SerialPort.Open(3); if (bOpened) { m_SerialPort.SetBaudRate(115200); m_SerialPort.SetDataBits(8); m_SerialPort.SetStopBits(1); m_SerialPort.SetParity(0); m_SerialPort.StartMonitoring(); } else { AfxMessageBox(_T(COM3打开失败请检查串口是否被占用)); } return TRUE; }参数说明Open(3)的3是COM端口号也可以改成Open(_T(COM3))的形式取决于CSerialPort类的具体实现。SetDataBits(8)表示每帧数据8位这是最常见的数据位配置协议里很少用7位。SetStopBits(1)是1位停止位而SetParity(0)中的0对应无校验。这里必须注意不是所有CSerialPort类都提供这几个分离方法有的类必须一次性传入一个DCB结构。如果编译时发现没有这些成员函数就去看SerialPort.h头文件里的接口定义。3.3 发送数据从编辑框到WriteFile发送按钮的响应函数获取编辑框内容后调CSerialPort::Write即可。代码void CSerialCommunicationTestDlg::OnBnClickedBtnSend() { CString strData; GetDlgItemText(IDC_EDIT_SEND, strData); CStringA strA(strData); // 把Unicode字符串转成多字节 int nLen strA.GetLength(); if (nLen 0) return; DWORD dwWritten m_SerialPort.Write(strA.GetBuffer(0), nLen); strA.ReleaseBuffer(); CString strInfo; strInfo.Format(_T(已发送 %lu 字节), dwWritten); SetDlgItemText(IDC_STATIC_STATUS, strInfo); }这段代码先把CString转成CStringA因为串口驱动是按字节发送的如果直接用CString在工程使用Unicode字符集时发出去的是UTF-16编码设备和普通串口上位机不兼容。Write的返回值的每个字都是实际写入驱动缓冲区的字节数它不等于底层硬件已经发完只能表示写入缓冲区成功。如果你要确认发送完成需要靠EV_TXEMPTY事件。3.4 接收数据从驱动到界面接收逻辑比较分散主对话框需要自己定义一个映射函数这个消息在SerialPort.cpp中由PostMessage投递。在类头文件里添加声明和消息映射// SerialCommunicationTestDlg.h protected: afx_msg LRESULT OnCommReceive(WPARAM wParam, LPARAM lParam);然后在cpp的消息映射里加一行BEGIN_MESSAGE_MAP(CSerialCommunicationTestDlg, CDialogEx) ON_MESSAGE(WM_COMM_RXCHAR, CSerialCommunicationTestDlg::OnCommReceive) END_MESSAGE_MAP()WM_COMM_RXCHAR这个宏在SerialPort.h里被定义为WM_USER101之类的自定义消息。接着实现接收函数LRESULT CSerialCommunicationTestDlg::OnCommReceive(WPARAM wParam, LPARAM lParam) { BYTE szBuffer[2048] { 0 }; DWORD dwLength m_SerialPort.ReadAsync(szBuffer, sizeof(szBuffer) - 1); if (dwLength 0) { // 这里可以用十六进制或ASCII方式追加到接收区 CString strHex; for (DWORD i 0; i dwLength; i) { strHex.AppendFormat(_T(%02X ), szBuffer[i]); } strHex.TrimRight(); CString strRecv; GetDlgItemText(IDC_EDIT_RECV, strRecv); strRecv strHex; strRecv _T(\r\n); SetDlgItemText(IDC_EDIT_RECV, strRecv); } return 0; }这里ReadAsync会把当前驱动缓冲区中的所有数据一次性读出来返回实际读取字节数。使用两位十六进制格式化是为了方便检查设备返回的原始帧。如果接收的是可见字符比如AT指令那么换成直接CString输出会更直观。从代码可以看出整个收发流程就是界面控件 - CString - SerialPort::Write - 串口设备 - 事件线程 - PostMessage - OnCommReceive - 编辑框。3.5 对话框资源与控件ID的对应关系在VS2015的资源编辑器里这个工程用了IDC_EDIT_SEND、IDC_EDIT_RECV、IDC_BTN_SEND这些控件ID。如果二次开发要调整布局需要保证这些ID和代码一致。很多人改完界面后编译通过但运行闪退大概率是没有把控件变量和DDX绑定好。在这个类里不用DDX直接用GetDlgItemText这样减少了一层关联关系但代价是如果控件ID改动编译期不会报错运行期会得到空串。4. 串口参数配置、常见坑与调试技巧4.1 参数组合选择什么时候用115200-8-N-1串口参数不是随便选的。对于绝大部分嵌入式模块例如AT指令模块、GPS模块、蓝牙透传模块出厂配置普遍是115200 8数据位 1停止位 无校验。老设备更多用9600 8 1 无校验。如果设备没有文档只能用逻辑分析仪去抓UART电平或者试着读寄存器。下面是常见组合表应用场景波特率数据位停止位校验备注通用AT指令模块11520081N最常用老式PLC960081N兼容性最好Modbus RTU9600/1920081N很多从站只支持RTU工业仪表480072E7位数据在部分仪表中还在用无线数传电台5760081N与空中波特率分离在代码里修改参数时要确保调用顺序。常见做法是先Open成功再设置DCB参数最后StartMonitoring。如果先启动监听再设置参数监听线程可能已经用错误参数读取数据了导致收包错乱。4.2 十六进制与字符串收发在代码上的不同处理串口调试助手一般都有“HEX显示”和“HEX发送”勾选框。字符串发送和HEX发送在底层都是字节区别在于从控件文本转换的方式。字符串发送直接把编码后的字节数组写入串口HEX发送则要做一次“30 31 32”到0x30 0x31 0x32的转换。代码BOOL ConvertHexStringToBytes(CString strHex, BYTE *pOut, DWORD *pOutLen) { strHex.Trim(); strHex.Remove(_T( )); strHex.Remove(_T(,)); int nLen strHex.GetLength(); if (nLen % 2 ! 0) return FALSE; for (int i 0; i nLen; i 2) { TCHAR t1 strHex[i]; TCHAR t2 strHex[i 1]; BYTE bValue CharToHex(t1) * 16 CharToHex(t2); pOut[i / 2] bValue; } *pOutLen nLen / 2; return TRUE; }这个函数中CharToHex需要自己把字符0-9、A-F映射成0-15。注意如果用户在编辑框里输入了“41 42 43”调用Remove后变成“414243”逐对转换成三个字节0x41,0x42,0x43。很多新手在发送时直接把这串字符的ASCII码发出去设备会把字符‘4’和‘1’分别当作两个字节0x34和0x31等于完全发错数据。这就是在代码里单独做HEX转换模块的原因。4.3 串口打开失败最常见的三个原因第一个原因是端口被占用。VS2015调试程序时如果上一次崩溃的进程还在后台打开同一个串口会返回ERROR_ACCESS_DENIED。这时候打开任务管理器结束对应exe或者执行工程目录下那个MFCkilll.bat。第二个原因是枚举到的COM号超过9了。Windows对COM10以上端口需要用\.\COM10格式CSerialPort类内部如果只拼了COM%d就永远打不开超过9的端口。这个类如果支持COM10它的Open函数里应该处理了。第三个原因是USB转串口驱动的延迟。插入USB转串口线后系统需要几秒才在设备管理器中创建COM号立刻打开会失败。所以调试助手里最好加一个自动刷新串口列表的功能读取注册表枚举所有串口。4.4 预编译头文件与VS版本迁移VS2015工程的SerialCommunicationTest.cpp里包含了stdafx.h表示使用预编译头。在VS2019或VS2022中打开时常见错误是“fatal error C1853: 预编译头文件来自早期版本的编译器”解决方法是右键项目属性C/C - 预编译头选择“使用”而不是“创建”然后重新生成。另一个常见问题是MFC头文件不识别需要确认在VS安装器中勾选了“适用于桌面的VC工具”和“MFC支持”。如果项目中使用了ANSI字符串函数这套代码在Unicode字符集设置下可能报C2664错误这时可以把项目字符集临时改成“使用多字节字符集”或者像3.3节里那样显式使用CStringA。在VS2015里调试串口助手建议打开“工具 - 选项 - 调试 - 符号设置”启用微软符号服务器这样WaitCommEvent内部报错时不会只看到异常地址。5. 二次开发把串口助手改造成自己的自动化测试工具5.1 增加定时发送功能在实际测试一个透传模块时我会疯狂点“发送”按钮这不是有效压力测试。更好的办法是加一个定时器每50ms发送一组递增的数据帧。在MFC里可以用SetTimerSetTimer(1, 50, NULL);然后在OnTimer里判断勾选状态发送测试数据。停止时KillTimer(1)。定时发送要注意的是串口驱动缓冲区可能被打满Write返回0并不一定说明数据没有生效要看ClearCommError的cbOutQue是否一直增长。这里可以给出一个验证命令在循环发送的同时用设备管理器的端口监视器或逻辑分析仪观察TX脚是否有连续波形。5.2 对收到的数据做自动校验一个实用的改造是加入CRC16校验。接收到的数据先放到一个临时数组按Modbus RTU规则计算CRC再比对。和校验的计算比较简单每帧开头1字节帧头1字节长度N字节数据1字节校验和用累加和取低8位。代码BOOL VerifyFrame(BYTE *pFrame, DWORD dwLen) { if (dwLen 3) return FALSE; BYTE bSum 0; for (DWORD i 0; i dwLen - 1; i) { bSum pFrame[i]; } return (bSum pFrame[dwLen - 1]); }这是最基础的校验完整实现还要考虑超时和粘包。粘包的典型表现是每次读到的dwLength可能包含多个帧你需要先把数据追加到一个环形缓冲区再从缓冲区里按帧头查找完整帧。如果发现数据包频繁对不齐优先怀疑波特率配置错误而不是校验函数问题。5.3 验证改造结果验证方法很简单把串口助手的发送端和接收端通过USB转串口线对接接一个USB转TTL模块然后从设备端回显所有收到的字节。使用Windows下自带的mode命令查看当前串口状态mode COM3看到波特率和数据位是否正确。如果模式显示115200,8,N,1并且回显数据无乱码说明二次开发的基础通信链路是通的。对于压力测试持续跑12小时观察内存占用是否增长。MFC对话框程序如果接收消息处理不当字符串追加到编辑框会不断分配内存时间长了会导致界面变卡所以我会限制接收编辑框的最大行数并且在OnDestroy里把CEdit内容写入日志文件避免测试数据白白丢失。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。