简介:这份资源是面向工业自动化、物联网设备开发者的C++ MFC RS485串口通信完整示例工程,适合具备一定Windows编程基础、希望快速掌握串口通信实现方法的开发者参考学习。资源包共22个文件,包含4个h头文件、3个cpp源文件,以及sln解决方案、vcxproj工程配置、rc资源脚本、ico图标等Visual Studio 2015项目文件,压缩包约52KB,导入后可直接编译运行。内容围绕CSerialPort类展开,覆盖串口对象创建、波特率与数据位等参数配置、串口打开与关闭、数据读写及异步事件响应等关键环节,并涉及错误处理与通信协议等实际项目细节。目前已有3199人学习下载,可帮助读者理解MFC与RS485结合的实现思路,为自身项目提供可复用的代码参考与排错借鉴。
1. 工控现场调试串口总翻车?这份 C++ MFC-RS485 通信 Demo 把链路跑通了
在工控现场调 RS485 串口通信,最让人头疼的不是协议本身,而是上位机软件那一层。你手里可能已经有 USB 转 485 模块、有温湿度传感器、有 PLC,但打开串口调试助手发几条指令,要么收不到回码,要么收到一堆乱码,要么第一次能通、第二次就卡死。问题往往出在串口参数配置、收发时序、线程阻塞这几个环节上。这份基于 C++ MFC 的 RS485 串口通信 Demo 完整版代码,就是针对这个场景做的:它把串口打开、参数设置、数据发送、接收解析、界面刷新这一整条链路用 MFC 框架串了起来,适合做上位机开发的工程师直接拿来改。如果你正在用 C++ 做 Windows 端工控软件,或者刚接触 RS485 半双工通信,这份代码能帮你省掉从零搭框架的时间。
2. RS485 半双工通信在 MFC 里的实现逻辑:先搞懂收发切换再动手
2.1 RS485 和 RS232 的本质区别:半双工与差分信号
很多人第一次用 RS485 会踩一个坑:以为它和 RS232 一样,发完数据就能立刻收。实际上 RS485 是半双工差分信号,同一时刻总线上只能有一个设备在发送。上位机通过 USB 转 485 模块连接时,模块内部会自动处理收发方向切换,但软件层面必须保证:发送指令后,给从站足够的响应时间,再去读缓冲区。如果发完立刻读,大概率读到空数据;如果发完等太久,界面又会卡顿。
这份 Demo 在发送函数里做了一个关键处理:发送完成后不立即读取,而是通过一个短延时或者等待事件的方式,让从站有时间把响应数据放上总线。这个延时不是随便设的,常见做法是根据从站的响应时间手册来定,一般 10ms 到 50ms 之间。代码里把这个值做成了可配置参数,方便不同设备适配。
另一个区别是差分信号抗干扰能力强,传输距离远。RS485 在 1200 米以内都能稳定通信,但前提是 A、B 线要双绞,终端要接匹配电阻。这些硬件层面的东西软件管不了,但软件层面如果收发时序不对,再好的硬件也白搭。
2.2 MFC 串口通信的三种方案选型:为什么这份 Demo 用 MSComm 或 Win32 API
在 MFC 里做串口通信,常见有三条路:一是用微软早期的 MSComm 控件,二是直接调 Win32 API(CreateFile、ReadFile、WriteFile),三是用第三方串口库。这份 Demo 走的是前两种结合的路子,具体用哪个取决于你拿到的代码版本。
MSComm 控件的优点是上手快,拖到对话框上就能用,事件驱动模式对新手友好。缺点是它依赖一个 ocx 文件,部署到干净系统上可能要注册,而且微软早就停止维护了。Win32 API 的方式更底层,用 CreateFile 打开 COM 口,用 DCB 结构体配置波特率、数据位、停止位、校验位,用 ReadFile 和 WriteFile 收发数据。这种方式没有额外依赖,编译出来的 exe 直接能跑,适合做产品级软件。
Demo 里如果用的是 Win32 API,你会看到类似这样的初始化流程:
// 打开串口,COM3 根据实际设备管理器里的端口号改 HANDLE hCom = CreateFile(_T("COM3"), GENERIC_READ | GENERIC_WRITE, // 允许读写 0, // 独占方式打开 NULL, OPEN_EXISTING, // 打开已存在的串口 FILE_ATTRIBUTE_NORMAL, // 同步方式,不用重叠结构 NULL); if (hCom == INVALID_HANDLE_VALUE) { AfxMessageBox(_T("串口打开失败,检查端口号是否被占用")); return; } // 配置串口参数:波特率9600,8位数据,1位停止,无校验 DCB dcb; memset(&dcb, 0, sizeof(dcb)); dcb.DCBlength = sizeof(dcb); GetCommState(hCom, &dcb); dcb.BaudRate = 9600; // 波特率,常见还有19200、38400、115200 dcb.ByteSize = 8; // 数据位,一般固定8位 dcb.Parity = NOPARITY; // 校验位,NOPARITY表示无校验 dcb.StopBits = ONESTOPBIT; // 停止位,ONESTOPBIT表示1位停止位 SetCommState(hCom, &dcb); // 设置读写超时,避免ReadFile永久阻塞 COMMTIMEOUTS timeouts; timeouts.ReadIntervalTimeout = 50; // 读间隔超时50ms timeouts.ReadTotalTimeoutConstant = 100; // 读总超时常量100ms timeouts.ReadTotalTimeoutMultiplier = 10; // 每字节读超时10ms timeouts.WriteTotalTimeoutConstant = 100; // 写总超时常量100ms timeouts.WriteTotalTimeoutMultiplier = 10; // 每字节写超时10ms SetCommTimeouts(hCom, &timeouts);这段代码的逻辑说明:CreateFile 的第三个参数设为 0 表示独占访问,防止其他程序同时打开同一个串口。DCB 结构体里 BaudRate 必须和从站设备一致,这是最常见的通信失败原因。COMMTIMEOUTS 里的 ReadIntervalTimeout 设成 50ms 意味着如果两个字节之间间隔超过 50ms,ReadFile 就返回,不会一直等下去。这个参数对半双工通信特别重要,因为从站可能只回几个字节就结束了,没有超时设置的话 ReadFile 会卡住整个线程。
2.3 发送与接收的线程模型:别让界面卡成 PPT
MFC 的界面线程和串口读写线程如果不分开,发送一条指令等响应的时候,整个窗口就会无响应。这份 Demo 在架构上做了区分:主线程负责界面刷新和用户操作,串口读写放在一个单独的工作线程里,通过自定义消息或者事件对象把收到的数据传回主线程。
工作线程的典型写法是用 AfxBeginThread 创建一个 UI 线程或者工作者线程。工作者线程里跑一个 while 循环,不断检查串口缓冲区有没有数据。收到数据后,用 PostMessage 发一个自定义消息给主窗口,主窗口的消息处理函数里再更新编辑框或者列表控件。
// 自定义消息,用于工作线程通知主线程收到数据 #define WM_COMM_RECEIVE (WM_USER + 100) // 工作线程函数 UINT CSerialDemoDlg::SerialThread(LPVOID pParam) { CSerialDemoDlg* pDlg = (CSerialDemoDlg*)pParam; DWORD dwBytesRead = 0; BYTE buffer[1024]; while (pDlg->m_bThreadRunning) { // 读串口,超时时间由COMMTIMEOUTS控制 if (ReadFile(pDlg->m_hCom, buffer, sizeof(buffer), &dwBytesRead, NULL)) { if (dwBytesRead > 0) { // 把收到的数据拷贝到堆上,通过消息传给主线程 BYTE* pData = new BYTE[dwBytesRead]; memcpy(pData, buffer, dwBytesRead); pDlg->PostMessage(WM_COMM_RECEIVE, (WPARAM)pData, dwBytesRead); } } Sleep(10); // 避免CPU占用过高 } return 0; }参数说明:m_bThreadRunning 是一个 bool 成员变量,关闭串口时置为 false,线程就会退出循环。Sleep(10) 是让线程每 10ms 查一次,这个值太小会占 CPU,太大响应会迟钝。PostMessage 传的 pData 是在堆上分配的,主线程处理完消息后必须 delete[],否则内存泄漏。
主线程的消息处理函数:
afx_msg LRESULT CSerialDemoDlg::OnCommReceive(WPARAM wParam, LPARAM lParam) { BYTE* pData = (BYTE*)wParam; int nLen = (int)lParam; // 把字节数组转成十六进制字符串显示 CString strShow; for (int i = 0; i < nLen; i++) { CString strByte; strByte.Format(_T("%02X "), pData[i]); strShow += strByte; } m_editRecv.SetSel(-1, -1); m_editRecv.ReplaceSel(strShow); delete[] pData; // 释放工作线程分配的内存 return 0; }这里用 ReplaceSel 而不是 SetWindowText,是为了追加显示而不是覆盖。十六进制格式显示是工控调试的常见做法,因为很多传感器的回码不是可读 ASCII,直接看十六进制更直观。
3. 从零跑通 Demo:环境配置、编译和第一次收发测试
3.1 开发环境准备与项目编译
拿到这份代码后,第一步是确认开发环境。Demo 是基于 MFC 的,所以 Visual Studio 必须安装「使用 C++ 的桌面开发」工作负载,并且勾选 MFC 组件。VS2015、VS2017、VS2019、VS2022 都能编译,但要注意项目属性里的「平台工具集」和「MFC 的使用方式」。
打开项目后,先检查几个关键配置:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 字符集 | 使用多字节字符集 或 Unicode | 看代码里用的是 CString 还是 char,不一致会报错 |
| MFC 使用方式 | 在共享 DLL 中使用 MFC | 编译出的 exe 小,但目标机器要有 MFC 运行库 |
| 平台工具集 | 和你 VS 版本匹配 | 不匹配会提示找不到 v143 之类的工具集 |
| 目标平台 | x86 或 x64 | 取决于你系统,一般 x86 兼容性更好 |
如果编译报错「无法打开包括文件 afxwin.h」,说明 MFC 组件没装,去 VS Installer 里补上。如果报错「找不到 mfc140.dll」,把 MFC 使用方式改成「在静态库中使用 MFC」,这样编译出的 exe 不依赖外部 dll,但体积会大几 MB。
3.2 串口参数配置与端口号确认
编译通过后,先别急着连设备。打开 Windows 设备管理器,展开「端口 (COM 和 LPT)」,找到你的 USB 转 485 模块对应的 COM 号。常见的是 COM3、COM4,但插拔不同 USB 口会变。如果设备管理器里看到黄色感叹号,说明驱动没装好,先装驱动。
Demo 界面上一般会有波特率下拉框、数据位、停止位、校验位选择,以及打开/关闭串口按钮。第一次测试建议用虚拟串口软件或者两个 USB 转 485 模块对接,一个发一个收,确认软件本身没问题。
配置参数时注意:波特率必须和从站设备完全一致。9600 和 19200 看着差不多,但差一位就全乱。数据位一般 8 位,停止位 1 位,校验位无。有些老设备用 7 位数据位、偶校验,这种就要在 DCB 里改 Parity 为 EVENPARITY,ByteSize 为 7。
3.3 发送第一条指令并解析回码
假设你连的是一个 Modbus RTU 温湿度传感器,从站地址 0x01,读温度寄存器。发送的指令是 01 03 00 00 00 01 84 0A。在 Demo 的发送编辑框里输入这串十六进制,点发送。
如果一切正常,接收区会显示类似 01 03 02 00 FA 38 4B 的回码。其中 01 是从站地址,03 是功能码,02 是字节数,00 FA 是温度数据,后面是 CRC 校验。
如果收不到回码,按这个顺序排查:
- 检查 A、B 线有没有接反。RS485 的 A 接 A,B 接 B,反了收不到数据。
- 检查波特率是否一致。用串口调试助手单独测一下从站,确认参数。
- 检查发送的指令 CRC 是否正确。Modbus RTU 的 CRC 是低字节在前,很多在线工具生成的 CRC 顺序可能不对。
- 检查串口是否被其他程序占用。串口是独占资源,打开一个软件后另一个打不开。
Demo 里如果带了 CRC 校验计算函数,可以直接调用生成正确指令。常见做法是写一个 CRC16 查表或者直接计算:
// Modbus RTU CRC16 计算,返回低字节在前的结果 WORD CSerialDemoDlg::CalculateCRC16(BYTE* pData, int nLen) { WORD wCRC = 0xFFFF; for (int i = 0; i < nLen; i++) { wCRC ^= pData[i]; for (int j = 0; j < 8; j++) { if (wCRC & 0x0001) { wCRC >>= 1; wCRC ^= 0xA001; // 多项式 0xA001 } else { wCRC >>= 1; } } } return wCRC; // 低字节在前,高字节在后 }这个函数算出来的 CRC 直接追加到指令末尾即可。注意返回的 WORD 在内存里是小端序,x86 机器上直接 memcpy 到字节数组就是低字节在前,符合 Modbus RTU 要求。
4. 避坑指南:RS485 串口通信调试中最容易翻车的五个点
4.1 现象:发送正常但接收全是乱码
原因:波特率不匹配,或者数据位/停止位/校验位设置和从站不一致。还有一种可能是 USB 转 485 模块的芯片质量差,在高波特率下误码率高。
解决:先用 9600 低波特率测试,确认通了再往上调。换一个 FT232 或 CH340 芯片的模块对比。检查 DCB 结构体里 ByteSize、Parity、StopBits 是否和从站手册一致。
4.2 现象:第一次通信成功,第二次就超时
原因:半双工收发切换没处理好。发送完成后没有给从站足够的响应时间,或者接收缓冲区没清空,上一次的残留数据干扰了下一次。
解决:在每次发送前调用 PurgeComm 清空缓冲区,发送后加 20ms 到 50ms 延时再读。Demo 里如果用了事件驱动,检查事件重置逻辑是否正确。
// 发送前清空缓冲区,避免残留数据干扰 PurgeComm(m_hCom, PURGE_RXCLEAR | PURGE_TXCLEAR);4.3 现象:界面卡死,点关闭按钮没反应
原因:串口读写放在了主线程里,ReadFile 阻塞导致消息循环卡住。或者工作线程退出时没有正确等待线程结束。
解决:确保串口读写在工作线程里。关闭串口时先把 m_bThreadRunning 置 false,然后 WaitForSingleObject 等线程句柄,最后再 CloseHandle 关闭串口。
4.4 现象:编译报错「error C2664: 无法将参数从 const char* 转换为 LPCWSTR」
原因:项目字符集设成了 Unicode,但代码里字符串用的是 char 类型。MFC 的 CString 在 Unicode 下是宽字符,和 char 不兼容。
解决:要么把项目字符集改成「使用多字节字符集」,要么在字符串前加 _T() 宏,比如 _T("COM3")。推荐后者,兼容性更好。
4.5 现象:换一台电脑就找不到串口或打不开
原因:目标电脑没装 USB 转 485 驱动,或者 COM 口号变了但软件里写死了。
解决:软件里做端口号下拉框,枚举系统可用串口。用 SetupAPI 或者简单点用注册表读 HKEY_LOCAL_MACHINE\HARDWARE\DEVICEMAP\SERIALCOMM 下的键值。部署时把驱动一起打包。
5. 进阶技巧:把 Demo 改成能用的工控上位机还差哪几步
这份 Demo 跑通之后,离真正能上产线的上位机还有距离。我一般会做这几件事:第一,把接收数据解析从「显示十六进制」升级成「按协议解析成物理量」。比如 Modbus 回码里的 00 FA 要转成 25.0 摄氏度,需要根据传感器手册做线性变换。第二,加一个定时发送功能,用 SetTimer 或者线程里的 Sleep 控制轮询周期,常见是 1 秒读一次。第三,把数据存到 SQLite 或者 CSV 里,方便追溯。第四,加日志功能,每次收发都记时间戳和原始字节,出问题时能回看。
还有一个容易忽略的点:串口热插拔。USB 转 485 模块拔掉再插上,COM 口号可能变,原来的句柄就失效了。成熟的做法是监听 WM_DEVICECHANGE 消息,检测到设备变化时自动关闭旧串口、重新枚举、打开新串口。这个逻辑不复杂,但能省掉现场运维很多电话。
// 在对话框消息映射里加设备变化处理 BEGIN_MESSAGE_MAP(CSerialDemoDlg, CDialogEx) ON_WM_DEVICECHANGE() END_MESSAGE_MAP() BOOL CSerialDemoDlg::OnDeviceChange(UINT nEventType, DWORD_PTR dwData) { if (nEventType == DBT_DEVICEARRIVAL || nEventType == DBT_DEVICEREMOVECOMPLETE) { // 设备插入或拔出,重新枚举串口列表 RefreshSerialPortList(); } return CDialogEx::OnDeviceChange(nEventType, dwData); }从那以后我每次交付带串口通信的上位机,都强制走一遍「拔插测试」:运行中拔掉 USB 转 485,等几秒再插上,看软件能不能自动恢复。这个习惯帮我挡掉了至少三次现场返工。希望这份 Demo 和上面的踩坑记录能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取