VS2013 C++ MFC串口调试助手源码解析:从建工程到调通全流程
2026/9/9 20:20:43 网站建设 项目流程

简介: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 API(CreateFile、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++ 在收发数据时可以做到真正的零拷贝处理,收到缓冲区数据后直接按字节解析协议帧;相比 Qt,MFC 的 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,是接收时判断“一帧数据结束”的重要依据。下位机通常是一包一包地发数据,每包之间有时间间隔。只要间隔超过 50ms,ReadFile 基本就会返回当前缓冲区里已有的数据,这就是串口常用的“按超时分包”思路。

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++ 基础的嵌入式工程师试用我的开发思路去重写自己的串口助手——你会发现,很多问题在写的过程中就已经被提前解决了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询