ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VS2013 C++ MFC串口调试助手源码解析:从建工程到调通全流程

VS2013 C++ MFC串口调试助手源码解析:从建工程到调通全流程 简介VS2013 C串口助手源码是一份基于Visual Studio 2013与MFC框架开发的串口调试工具面向需要调试串口设备、学习Windows串口通信的C开发者。源码以MSComm控件为核心完整演示了串口参数的初始化流程包括打开/关闭串口、设置波特率、数据位、检验位与停止位并通过Input、Output属性及OnComm事件实现数据的接收、发送与异常响应。项目还展示了MFC对话框程序的消息映射与界面布局适合初学者快速建立串口编程的整体认识也可作为后续项目的基础框架。压缩包共29个文件主要包含C头文件与实现文件h/cpp、MFC资源描述文件rc/rc2、图标和位图ico/bmp、Visual Studio解决方案与工程文件sln/vcxproj等整体约190KB结构紧凑便于直接打开查看核心逻辑。目前已有682人学习下载借助这份源码开发者可以理清MSComm控件的常用属性与方法并在此基础上扩展多线程数据收发、日志记录、协议解析等实用功能。 做上位机这些年串口调试助手几乎是每天都要摸一遍的工具。早些年用的是别人编译好的小软件后来项目里要对接一个自定义协议的下位机现成的助手要么不支持脚本解析要么没法按帧自动发送越用越别扭。索性花了一个周末用 VS2013 加 C 把串口助手从零写了一个源码一直留到现在后来还靠它带过好几个刚入门的上位机新人。如果你也想自己写一个、或者打算基于源码二次开发这篇文章就把我从建工程到调通的完整思路和关键代码拆开讲一遍尽量做到拿过去就能用。为什么选 VS2013 和 C 来写这个串口助手原因很实际。VS2013 的 MFC 工程对串口这类偏底层的设备操作支持很直接C 调用 Win32 APICreateFile、ReadFile、WriteFile没有任何中间层开销时序可控、线程模型清晰非常适合需要接收不定长数据帧的调试场景。团队里很多老项目也是 VS2013 维护的源码能直接编进现有解决方案里复用省去跨版本折腾的功夫。1. 项目整体设计与功能拆解1.1 串口助手到底需要哪些功能写串口助手之前先别急着敲代码把自己日常调试时最常用的操作列清楚。我自己的清单是这样的打开和关闭指定串口配置波特率、数据位、停止位、校验位支持十六进制发送和十六进制显示能定时自动发送接收区能暂停显示、清空发送区能记录历史数据。后来根据实际使用还加了两个很实用的功能按帧间隔分割显示接收数据以及把接收数据自动保存到文件里。功能清单确定之后源码结构就跟着清楚了。核心要解决三件事串口设备的打开和配置、数据的收发、界面与收发逻辑之间的数据交换。这三件事互相独立又紧密相关是串口助手源码里最值得花心思的地方。有一个容易被新手忽略的点串口助手不等于简单的“把串口数据搬到屏幕上”。嵌入式开发里下位机可能随时上报状态帧、告警帧、心跳包如果你的助手连数据接收都会丢、界面还会卡顿那调试效率会大打折扣。所以写这个源码的时候我给自己定了一个底线在 115200 波特率、每 10ms 收到一帧数据的压力下界面不能卡死数据不能丢帧。1.2 为什么选 MFC 而不是 Qt 或纯 Win32实现串口助手的 UI 框架有很多我见过有人用 C# 的 WinForms 写也有人用 Qt 写都很好用。但我用 MFC 是有具体原因的。MFC 对 Win32 API 的封装很薄甚至在串口这个场景下很多时候你要直接调用 Win32 API 才能获得更精确的控制。相比 C#C 在收发数据时可以做到真正的零拷贝处理收到缓冲区数据后直接按字节解析协议帧相比 QtMFC 的 CWinThread 和消息映射机制与 Windows 消息循环天然契合收发线程往界面窗口 PostMessage 非常简单不需要额外引入信号槽机制。不过这里要强调MFC 只是工具串口通信的核心还是那几个 Windows API 函数。你如果不想用 MFC纯 Win32 写个对话框程序也完全能实现同样的效果。MFC 的意义在于帮你省去了对话框资源、控件绑定这类重复劳动让你能把精力集中在串口逻辑上。1.3 源码文件结构与模块划分用 VS2013 新建一个 MFC 对话框应用后项目的核心文件会分成两类。一类是框架自动生成的xxxDlg.cpp 负责界面逻辑xxxDlg.h 是对话框类的头文件Resource.h 管控件 ID。另一类是你自己添加的串口通信类比如 CSerialPort以及可能需要的协议解析类或者 CRC 校验类。我习惯把串口通信单独封装成一个类而不是把 CreateFile 那些代码直接写进对话框类里。这样做的好处是串口收发逻辑与界面显示逻辑解耦以后如果项目要改成控制台程序只需要把界面部分换掉串口类原封不动就能复用。源码里模块划分清楚后排错也会容易很多。2. 串口通信的核心原理与关键代码2.1 打开串口与参数配置Windows 下操作串口本质上是把串口当作文件来读写。所以打开串口用 CreateFile读写用 ReadFile / WriteFile关闭用 CloseHandle这套逻辑和读写普通文件几乎一样。先看打开串口的代码HANDLE hCom CreateFile( _T(COM3), // 串口名注意格式是 COM3 GENERIC_READ | GENERIC_WRITE, // 可读可写 0, // 串口必须独占不支持共享 NULL, OPEN_EXISTING, // 串口必须用 OPEN_EXISTING FILE_ATTRIBUTE_NORMAL, NULL);有几个细节一定要处理好。第一串口名超过 COM9 之后要写成 \\.\COM10 的格式否则打开会失败。第二dwShareMode 必须是 0串口不支持共享模式。第三这里没有用 FILE_FLAG_OVERLAPPED目的是让读写操作简化为同步模式后面再讲为什么需要单独开线程。串口打开后紧接着就是配置 DCB 结构体。DCB dcb; GetCommState(hCom, dcb); dcb.BaudRate 115200; // 波特率 dcb.ByteSize 8; // 数据位 dcb.StopBits ONESTOPBIT; // 停止位 dcb.Parity NOPARITY; // 校验位 SetCommState(hCom, dcb);DCB 里的参数全部定义了串口通信的物理层格式。波特率决定了每秒传输的比特数数据位常见的是 8 位停止位通常是 1 位校验位一般选无校验。下位机如果配置的是奇校验这里就改成 ODDPARITY两边不一致会直接导致通信乱码。除了 DCB还有两个容易被忽略但很重要的设置超时和缓冲区。COMMTIMEOUTS timeouts; timeouts.ReadIntervalTimeout 50; // 两个字符之间的最大间隔 timeouts.ReadTotalTimeoutMultiplier 10; timeouts.ReadTotalTimeoutConstant 100; SetCommTimeouts(hCom, timeouts); SetupComm(hCom, 4096, 4096); // 设置收发缓冲区大小ReadIntervalTimeout 这里设置的 50ms是接收时判断“一帧数据结束”的重要依据。下位机通常是一包一包地发数据每包之间有时间间隔。只要间隔超过 50msReadFile 基本就会返回当前缓冲区里已有的数据这就是串口常用的“按超时分包”思路。2.2 数据接收线程与界面刷新同步读串口的最大问题就是ReadFile 在没有任何数据到达时会被阻塞程序会卡在那里一动不动。所以必须把接收逻辑放到一个独立线程里让主线程界面线程可以继续响应用户点击。工作线程的核心逻辑是这样的UINT CSerialPort::ReceiveThread(LPVOID lpParam) { CSerialPort* pSerial (CSerialPort*)lpParam; char* szBuf new char[2048]; DWORD dwReadLen 0; while (pSerial-m_bThreadRunning) { BOOL bResult ReadFile( pSerial-m_hCom, szBuf, 2048, dwReadLen, NULL); if (bResult dwReadLen 0) { // 把接收数据通过自定义消息发送到窗口 ::PostMessage( pSerial-m_hWnd, WM_MY_RECEIVE, dwReadLen, (LPARAM)szBuf); } else { // 没读到数据时让出 CPU避免空转 Sleep(10); } } delete[] szBuf; return 0; }这段代码里有两个关键点。第一szBuf 是 new 出来的PostMessage 只是发送了指针接收方处理完这条消息后要负责 delete否则就会内存泄漏。第二ReadFile 同步模式下在工作线程里调用即使长时间没有数据主线程也不受影响界面能保持流畅。值得一提的是ReadFile 在没有数据时会立即返回 FALSE并不会阻塞这是因为我们在 COMMTIMEOUTS 里设置了 ReadIntervalTimeout。实际效果就是每次 ReadFile 最多等 50ms 就会返回线程可以继续循环既不会一直空转也不会错过数据。接收方如何拿到数据并显示我自定义了一个 Windows 消息#define WM_MY_RECEIVE (WM_USER 101) ON_MESSAGE(WM_MY_RECEIVE, CMySerialDlg::OnReceive) LRESULT CMySerialDlg::OnReceive(WPARAM wParam, LPARAM lParam) { DWORD dwLen (DWORD)wParam; char* szBuf (char*)lParam; CString strText; for (DWORD i 0; i dwLen; i) { strText szBuf[i]; } m_editReceive.AppendText(strText); // 追加到接收区 delete[] szBuf; // 消息处理完释放内存 return 0; }接收线程和界面线程就是通过 PostMessage 完成数据传递的。PostMessage 是异步的调用后立即返回接收线程不会被界面刷新速度拖累这样就能保证高速接收时数据不丢失。2.3 发送数据的几种方式发送相对简单直接调 WriteFile 就行。CStringA strSend; GetDlgItemText(IDC_EDIT_SEND, strSend); WriteFile(m_hCom, strSend.GetBuffer(), strSend.GetLength(), dwWriteLen, NULL);默认情况下WriteFile 是同步阻塞的如果串口线有问题或者对端不读数据数据会一直积压在内核缓冲区WriteFile 也可能不正常返回。解决方法是创建串口句柄时加上 FILE_FLAG_OVERLAPPED用重叠 I/O 配合事件对象来发送。不过实际调试场景里我只在写“连续大量发送”功能时才用重叠 I/O普通的手动发送直接同步调用就够。十六进制发送是串口助手必备功能。把文本框里的字符串按字节解析出来再发送实现就是一个十六进制字符串转字节数组int HexStringToBytes(const CString strHex, BYTE* pOut, int nMaxLen) { int nLen 0; for (int i 0; i strHex.GetLength(); i 2) { if (nLen nMaxLen) break; TCHAR ch1 strHex[i]; TCHAR ch2 strHex[i 1]; pOut[nLen] (HexCharToVal(ch1) 4) | HexCharToVal(ch2); nLen; } return nLen; }要注意这种解析方式要求用户输入的十六进制字符串是连续的中间不能有空格。想要对用户更友好可以在解析前先把空格过滤掉。我还在源码里加了一个判断如果用户勾选了十六进制发送但内容里有非十六进制字符就弹提示框阻止发送避免误操作。3. 实操过程从 VS2013 建工程到跑通3.1 VS2013 环境准备与工程创建VS2013 装好之后第一步新建项目选择“MFC 应用程序”应用程序类型选“基于对话框”其它选项保持默认。如果你的 VS2013 没装 MFC 相关组件新建工程时会提示找不到模板需要在 Visual Studio Installer 里把“适用于 MFC 的 C 支持”勾上补装。工程创建完成后VS2013 会自动生成一个空对话框。先在资源视图里把对话框的 Caption 改成“串口调试助手”再往上面放几个控件。我常用的控件清单如下控件用途控件类型变量名说明串口号选择Combo Boxm_cboComPort可编辑波特率选择Combo Boxm_cboBaudRate预设常用值打开/关闭按钮Buttonm_btnOpenClose状态切换接收数据显示Edit Boxm_editReceive多行、只读发送数据输入Edit Boxm_editSend多行十六进制接收Check Boxm_chkHexReceive勾选后按十六进制显示十六进制发送Check Boxm_chkHexSend勾选后按十六进制发送控件变量关联好之后VS2013 会自动在 xxxDlg.h 里生成 DoDataExchange 绑定代码不需要自己写绑定逻辑。3.2 把串口类集成到对话框打开串口、关闭串口、接收线程、发送函数都封装在 CSerialPort 类里对话框只需要在“打开”按钮点击事件里调用 Open在“关闭”按钮里调用 Close。这个集成方式是我后来反复改过的。最初版本把所有串口代码直接写进对话框里功能是没问题但只要牵扯到界面控件的读写串口类就没法单独做单元测试定位问题还要翻几百行代码。单独封装之后串口类可以脱离对话框单独运行用命令行模拟收发排查问题方便得多。打开按钮的实际处理逻辑是void CMySerialDlg::OnBnClickedBtnOpen() { if (!m_bOpened) { CString strPort; m_cboComPort.GetWindowText(strPort); BOOL bRet m_serialPort.Open( strPort, m_dwBaudRate, 8, ONESTOPBIT, NOPARITY); if (bRet) { m_bOpened TRUE; m_btnOpenClose.SetWindowText(_T(关闭串口)); m_serialPort.StartReceiveThread(m_hWnd); } else { AfxMessageBox(_T(打开串口失败请检查串口号是否被占用)); } } else { m_serialPort.Close(); m_bOpened FALSE; m_btnOpenClose.SetWindowText(_T(打开串口)); } }这里有一点很值得注意串口打开成功之后要立即启动接收线程。启动线程时把对话框窗口句柄 m_hWnd 传进去这样串口类才知道把 WM_MY_RECEIVE 消息往哪个窗口发。3.3 编译踩坑与源码排错VS2013 默认使用 Unicode 字符集所以 CString 默认是 CStringW。串口发送的数据往往是 char 类型两者混用最容易出编译错误。我的处理方法是接收显示用 CStringA发送时用 CStringA 保存内容调用 WriteFile 时直接强制转换为 LPCSTR。还有一处容易踩坑ON_MESSAGE 宏处理自定义消息时消息处理函数的签名必须是固定的两个参数一个 WPARAM一个 LPARAM。新手容易把它写成无参数的函数编译通过但程序运行到消息到达时就会崩。正确写法就是前面代码里那种格式。另外一个常见报错是 error C2872: DCB: 不明确的符号。这是因为 MFC 里同时引入了全局的 DCB 结构和 CDC 类两者在某些头文件组合下会冲突。解决办法是使用的时候写全名 ::DCB或者干脆把变量名改成 dcbs避免模板头文件解释错误。3.4 回调方式与线程安全设计在源码里接收线程向主窗口发消息用的是 PostMessage 这种异步投递方式。PostMessage 是非阻塞的接收线程发出消息后立刻返回不管主窗口是否已经处理完上一条消息。这样就保证了即使界面刷新较慢接收线程也不会被拖慢数据能持续读入。那问题来了如果接收线程发消息的速度远快于界面刷新速度消息会不会堆积会的。所以我在代码里对刷新做了节流比如接收编辑框每累计 100ms 的数据一次性刷新一次而不是来一条消息就重绘一次。这个优化对大流量接收比如 GPS 数据、传感器流非常有效实测界面基本不卡顿源码里保留了对应注释。线程安全方面还要处理一个细节关闭串口的时候必须先停止接收线程再 CloseHandle。如果顺序反了接收线程正阻塞 ReadFile 时句柄被关闭可能会触发无效句柄异常。我的 Close 函数实现是先通过一个 BOOL 标志让接收线程跳出循环然后用 WaitForSingleObject 等待线程退出最后才 CloseHandle。4. 常见问题与排查技巧实录4.1 打开串口失败这个问题的排查顺序我总结成了固定的套路。先确认串口号是否被占用用系统自带的设备管理器看端口号或者直接用当前源码里的枚举功能避免手动输入错误。再检查串口名格式COM10 以上的口要记得用 \\.\COM10 格式。最后检查下位机是否已经占用串口两个软件同时打开同一个串口必然失败。如果是 USB 转串口模块比如 CH340、CP2102 这种还要检查驱动是否装好。设备管理器里如果能看到设备但显示感叹号说明驱动有问题。插拔一次或者换一个 USB 口通常能解决。4.2 收不到数据或收到的全是乱码收不到数据时我一般先用串口助手的“自发自收”功能来验证把 TX 和 RX 短接如果能收到自己发的数据说明串口硬件和软件链路没问题问题在下位机收不到的话就检查串口线是不是交叉线、波特率是否一致。乱码问题排查路径要短先查波特率、数据位、停止位、校验位两边是否一致这一项占了九成原因。再查是不是接到了带电的设备上共地不好会导致信号漂移。最后再想是不是协议本身有问题比如下位机发的是带协议的数据帧你把它当裸数据显示了。4.3 接收界面卡死界面卡死优先怀疑主线程被阻塞了。最典型的原因是接收消息处理里干了耗时的操作比如把接收数据实时写文件。正确的做法是接收消息只负责往缓冲区里追加数据写文件交给单独的定时器或者写文件线程。还有一个常见的坑是死锁。在接收消息处理函数里如果调用了 AfxMessageBox 弹窗消息循环会被弹窗阻塞此时接收线程 PostMessage 的消息排不上队看起来就像界面卡死了。所以接收区里遇到数据千万不要直接弹窗提示最多在状态栏里改个数字。4.4 数据丢帧或者接收帧被拆开串口接收无法保证一次 ReadFile 就能读回完整的一帧数据这是新手最容易懵的地方。下位机发了 100 个字节ReadFile 返回时可能只读出 60 个另外 40 个要下一次循环才到。这不是程序写错了而是串口的天然特性所以接收处理必须按“流”来理解而不是按“包”来理解。我用的解决方法是在自己的协议上增加帧头和帧尾定义。每次读到的数据先放进一个累积缓冲区再按帧头帧尾或者固定长度来切包处理。不必依赖 ReadIntervalTimeout 做分包因为超时分包在系统负载高时并不可靠。真正要稳定分包还是得靠协议结构来完成。4.5 串口热插拔检测调试过程中下位机的 USB 转串口经常会被拔掉再插回来这时串口号可能会变COM3 变 COM5。如果源码里还一直开着旧的 COM3之后所有收发都会失败。我后来在源码里加了一个 WM_DEVICECHANGE 消息处理设备变更事件到达时自动枚举一遍可用串口并刷新下拉列表如果当前打开的串口已经不存在就自动关闭。这个功能虽然只是锦上添花但实际使用中能省掉大量反复“插拔后重启程序”的时间。5. 源码的扩展方向顺着“vs2013 c串口助手源码”这份基础代码继续做可以往几个方向延展。项目中经常需要按一定格式解析下位机代码比如温度、湿度、电压等数据位可以给串口助手加上一个简单的“数据解析脚本”用 C 实现一个轻量解析器。这样测试人员不需要改代码只写一行规则就能可视化验证数据。另一个方向是保存和回放。把接收到的原始数据存成 bin 文件再用串口助手把文件回放出来可以方便地在没有真机的情况下复现问题、联调测试。这个功能实现起来也不复杂核心就是把 ReadFile 拿到的字节流追加到文件回放时用定时器按实际时间间隔 WriteFile 发送。写在最后串口助手这种工具市面上现成的一抓一大把自己动手写一遍收获的不只是一个工具而是把 Windows 串口编程的整个链路彻底吃透了。从那之后我再接任何串口设备都不用靠猜直接看代码、看 DCB 参数、看收发线程的时序逻辑问题定位速度提高了不止一倍。真心推荐有 C 基础的嵌入式工程师试用我的开发思路去重写自己的串口助手——你会发现很多问题在写的过程中就已经被提前解决了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进