简介:面向C++与MFC开发者的串口通信类库修正版,由itas109维护,主打轻量、可裁剪的CSerialPort串口类,适用于需要在MFC或普通Win32程序中快速集成串口收发功能的项目。整个zip压缩包共45个文件,以头文件(13个h)、源文件(9个cpp)和说明文档(3个txt)为主体,同时附带2个sln解决方案、2个工程文件、2个exe演示程序及3个ico图标,整体仅316KB,结构清晰便于移植。本次修正重点包含:新增_AFX宏定义以适配MFC必要函数Hkey2ComboBox;进一步去除MFC依赖并修改AfxMessageBox调用;增加Win32示例程序验证非MFC环境的可用性。读者可以拿到可直接编译的串口类源码、MFC与Win32双环境Demo、使用说明,以及作者在去MFC依赖过程中的改动思路,适合希望深入理解串口封装或需要跨环境复用串口代码的开发者。目前已有909人学习下载。 串口这玩意,在嵌入式、仪器仪表、工业控制这些场景里存在了几十年,但地位一直没被真正替代。很多做上位机开发的同行,早年间练手就是靠 CSerialPort 这类串口类入的门,后来有人转到 C# 的 SerialPort,有人用 Qt 的 QSerialPort,可真到接手老项目、处理老设备厂家给的协议 Demo,或者需要精细控制 DCB 参数、自己处理事件驱动时,最后还是得绕回这个 C++ 类。前段时间我整理工作电脑上那一堆历代工程代码,发现 2017-03-12 这版 CSerialPort 串口类修正版居然还被好几个老项目引用,所以决定借这个机会,把这玩意儿的来龙去脉、修正内容,以及把它接到新工程里会踩的坑,完整梳理一遍。
这也算给正在被“老代码 + 新编译器”折磨的人一个参考。串口通信不像普通文件读写,关闭时机、线程回调、字符集处理,一个不对就是偶发崩溃或者收不到数据。下面这些内容都是我在实际项目里验证过的东西,摊开讲。
1. CSerialPort 的前世今生,为什么 2017 年还在改
1.1 一个封装了 Win32 串口 API 的经典轮子
早年在 Windows 上写串口程序,没有现成控件可用,标准做法是直接用 CreateFile 打开 COM 口,然后通过 GetCommState、SetCommState 操作 DCB 结构体,再用 ReadFile、WriteFile 收发数据。听起来不复杂,但一旦涉及数据到达通知、超时控制、线程安全关闭,代码很容易变成一团乱麻。CSerialPort 这种类的核心价值,就是把“打开-配置-监视-收发-关闭”这个过程收敛到一个对象里,对外暴露 InitPort、StartMonitoring、WriteToPort、ClosePort 这些接口,底层逻辑则交给一个后台工作线程去跑。
这个类最初流传最广的是 Remon Spekreijse 的版本,后来经过大量开发者修补,国内外不少技术社区和个人站点上都有变种。大家搜 CSerialPort 时能看到好几个长得差不多的版本,其中一条比较有代表性的维护线就是 naughter 那边整理的版本,文件命名、函数签名都非常接近。2017-03-12 这个修正版,很多地方能看到 naughter 维护路线的影子,改动重点集中在编译兼容性和生命周期管理上。
1.2 为什么 2017-03-12 这个日期值得关注
2017 年前后,老一批 VC6、VS2008 项目开始大规模往 VS2015、VS2017 迁移。CSerialPort 这类老代码在迁移过程中暴露出一堆问题:不是 C4996 安全函数告警,就是 wchar_t 类型不匹配,或者干脆在 Win10 上打开串口偶尔失败。2017-03-12 这版修正,基本上就是针对这些问题做的累积更新。
这个时间点的修改也很有代表性:它不搞大改动,不重构成现代 C++ 风格,而是保持旧接口不动,只修内部实现。好处是项目升级成本极低,把 CSerialPort.h 和 CSerialPort.cpp 换掉,上层代码几乎不用动。对于讲究稳定、不能随便回归的工控项目来说,这是最稳妥的维护方式。我现在接手老项目,第一件事就是看它用的串口类是哪个版本,如果是 2017 年之前的老版本,会优先考虑替换成这版再继续改业务。
2. 这次修正版到底修了哪些东西
2.1 新编译器下的警告和错误清理
我在 VS2015 和 VS2017 下都编译过这版,最直观的感受是告警少了很多。老版本里常见的 strcpy、sprintf 等函数,在新编译器下会触发 C4996 安全警告,修正版把内部这些调用改成了带长度限制的版本,或者通过中间变量避免越界。不要小看这些告警,在大型工程里如果开启了“将警告视为错误”,老代码根本编不过去。
还有一类是默认参数重复声明的问题。C++ 规定默认参数只能在声明或定义处二选一,老代码经常在头文件和 cpp 文件里各写一份,老编译器睁一只眼闭一只眼,VS2017 会直接报错。2017-03-12 这版把这些重复默认参数都清理了,编译过程变得清爽很多。如果你在升级工程时遇到莫名其妙的 C2535 错误,多半就是这种老毛病。
2.2 打开、拔出、关闭过程中的稳定性修正
串口通信最怕的不是慢,而是偶发崩溃。修正版在几个关键生命周期节点上做了加固:打开端口失败时,资源清理不会重复调用 CloseHandle;设备被拔出或端口被占用时,工作线程能正确退出,而不是在等待事件时死等;析构函数里增加了线程状态判断,避免对象已经销毁、线程还在调用回调的情况。
这里多说一句,串口类的难点从来不在怎么发一个字节,而在于“什么时候能安全关闭”。修正版把 StartMonitoring 启动的线程句柄、事件句柄、端口句柄的释放顺序理了一遍,明显比早期版本更能扛得住异常场景。实际在 USB 转串口设备上做插拔测试,稳定度提升是能感知到的,设备突然断开时不会让整个上位机进程一起挂掉。
2.3 Unicode/ANSI 字符集下的细节修正
老代码很多默认是 ANSI 编译,但 VS2015 之后的新工程向导经常默认 Unicode。CSerialPort 内部有部分代码直接用 char,也有部分用 TCHAR 宏,混用时最容易出问题。修正版把构造函数的端口名参数、日志输出、调试字符串都统一了处理方式,在 Unicode 工程下不会出现端口名乱码,也不会因为字符串长度计算错误导致数据发不完整。
这一条对很多人来说可能觉得不起眼,但真遇到“Debug 正常、Release 乱码”这种问题时,才知道字符集处理有多折腾。当初我自己排查一个扫码枪乱码问题,最后定位到是 ANSI 和 Unicode 混用导致的,换掉那版串口类之后就再没出现过。
3. 核心设计拆解:从 CreateFile 到消息通知
3.1 工作线程和事件掩码怎么配合
CSerialPort 底层的原理其实很清晰:打开串口时用 CreateFile 拿到句柄,然后设置 DCB 和超时;StartMonitoring 之后,内部起一个监视线程,线程里执行 WaitCommEvent 等待串口事件。比如初始化时传入 EV_RXCHAR,表示“收到一个字符”就触发事件。线程被唤醒后,通过 ReadFile 把数据读到缓冲区,再通过 PostMessage 抛给上层窗口。
为什么用 PostMessage 而不是 SendMessage?因为 PostMessage 是异步的,不会阻塞工作线程,这样即便上层界面卡住,串口接收线程也能继续收数据,不会造成缓冲堆积和数据丢失。这一点在设计上位机软件时非常关键,很多自己封装串口的开发者容易忽略,一旦用 SendMessage 处理不当,就会把接收线程卡死,最后表现为“设备一收数据程序就没响应”。
3.2 向上层通知的两种姿势
传统 CSerialPort 是窗口消息通知制:你指定一个 HWND,串口线程收到数据后就往这个窗口发 WM_COMM_RXCHAR 之类的自定义消息。这种方式的优点是代码简单、天然和 MFC 消息映射契合;缺点是不方便用在纯控制台或非窗口线程里。
后来很多变种版本增加了回调函数接口,比如给 InitPort 传一个函数指针,收到数据后直接回调。两者各有适用场景,我的建议是:如果项目本身有界面,用消息通知就够了;如果是服务程序或者数据中转程序,可以改用回调版,避免为了收串口数据硬造一个隐藏窗口。至于这版 CSerialPort,默认还是消息通知为主,和 MFC 工程配合最顺。
4. 把 CSerialPort 接到自己项目里的实操记录
4.1 文件拷贝和工程配置
我自己的习惯是:新建一个底层模块目录,把 CSerialPort.h 和 CSerialPort.cpp 放进去,然后通过“现有项”添加到工程。这里有个容易踩坑的点:这个类并不是纯 Win32 对象,部分实现里依赖窗口消息和 MFC 的头文件,所以工程要么是 MFC 工程,要么在项目设置里把“使用 MFC”从“使用标准 Windows 库”改成“在共享 DLL 中使用 MFC”。
如果不小心漏掉这一步,编译时会看到一大串和 afxwin.h、CWinApp 有关的错误,新手容易懵,其实就是 MFC 支持没开。如果是 VS2017 之后的版本,还要注意把工程的字符集、平台工具集确认好,工具集选 v141 或 v140 都可,选成 v142 一般也能编,但个别老版本 SDL 检查会有告警,建议在“配置属性-常规-SDL 检查”里关掉。
4.2 一个完整可用的收发示例
假设要在对话框程序里打开 COM3,波特率 115200,初始化代码是这样:
// 头文件中声明 CSerialPort m_serial; // 对话框 OnInitDialog 中 if (!m_serial.InitPort(this, _T("COM3"), 115200, 'N', 8, 1, EV_RXCHAR, 512)) { AfxMessageBox(_T("串口打开失败,请检查设备")); return FALSE; } if (!m_serial.StartMonitoring()) { AfxMessageBox(_T("启动串口监视失败")); return FALSE; }消息映射按 CSerialPort 惯例,接收事件消息名是 WM_COMM_RXCHAR。头文件里声明处理函数,cpp 里建立映射和实现:
afx_msg LRESULT OnCommReceive(WPARAM wParam, LPARAM lParam); BEGIN_MESSAGE_MAP(CComDemoDlg, CDialogEx) ON_MESSAGE(WM_COMM_RXCHAR, OnCommReceive) END_MESSAGE_MAP() LRESULT CComDemoDlg::OnCommReceive(WPARAM wParam, LPARAM lParam) { char ch = static_cast<char>(wParam); m_strReceive += ch; // 把字符追加到接收缓冲区 return 0; }发送数据时调用 WriteToPort:
char szSend[] = "AT\r\n"; m_serial.WriteToPort(szSend, sizeof(szSend) - 1);这段代码在实际项目里已经够用了。需要注意的是,如果上位机界面上需要实时显示接收内容,不要在 OnCommReceive 里直接 UpdateData 或操作控件,因为消息虽然是在 UI 线程收到的,高频刷新也会把界面拖得很卡。更合理的做法是把字符追加到一个缓存,然后用定时器或者累计到一定长度再刷新界面。
4.3 关闭和析构的顺序不能乱
我见过不少崩溃案例,都是直接在对话框 OnClose 里 delete 串口对象,结果串口工作线程还没来得及退出,窗口已经销毁,消息没地方投递,或者句柄被提前释放,线程继续访问非法内存。
正确的关闭顺序是:
- 先调用 StopMonitoring,让工作线程退出;
- 再调用 ClosePort,释放串口句柄;
- 最后才销毁串口对象。
如果担心 StopMonitoring 阻塞 UI,可以在退出标志上下点功夫,或者干脆在收到关闭通知后先隐藏窗口,再用 PostMessage 触发真正的清理逻辑。这样用户看到界面关了,程序还能在后台安全地完成线程收尾,不会弹“应用程序错误,正在关闭”这种框。
5. 排查实录和避坑清单
5.1 编译报错排查速查表
| 报错特征 | 常见原因 | 处理办法 |
|---|---|---|
| 找不到 afxwin.h | MFC 支持未开启 | 项目属性里改为“在共享 DLL 中使用 MFC” |
| C4996 安全告警 | 老接口被标记废弃 | 换成带 _s 版本,或暂时关闭 SDL 检查 |
| C2535 默认参数重复 | 头文件和 cpp 都写了默认值 | 保留声明处,删除定义处的默认参数 |
| Unicode 下端口名乱码 | TCHAR/char 混用 | 统一用宽字符或确保内部都走 TCHAR 宏 |
这张表基本覆盖了迁移到新编译器时 90% 的编译问题。剩下 10% 多半是工程的预编译头、运行时库不匹配造成的,优先检查“代码生成”里的运行库设置是否一致,Debug 和 Release 的配置分开看。
5.2 收不到数据的几个常见原因
运行期收不到数据,最先查三样东西:串口号对不对、波特率和其他设备端是否一致、串口是否被别的程序占用。确认这些都正常后,再看事件掩码。如果初始化时没有传入 EV_RXCHAR,或者写成 EV_ERR,工作线程就永远等不到接收事件,看起来就像设备没反应。
还有一个隐蔽点:超时设置。CSerialPort 内部会把 COMMTIMEOUTS 设置为一种“立即返回”的模式,如果上层代码修改了超时或者重新设置过 DCB,读操作可能阻塞或返回异常。建议把超时相关配置收敛到串口类内部,不要在业务代码里到处改,否则排查起来非常费劲。
5.3 长时间运行和高频收发的优化经验
CSerialPort 本身能跑,但要在长时间运行中保持稳定,必须解决两个问题:一是接收缓冲区不能被撑爆,二是 UI 不能跟着串口频率一起抖。我的做法是把接收到的原始数据先累积到一个独立的环形缓冲区,由专门的解析线程按协议帧切包;只有完整解析出一帧数据时,才通过消息通知界面刷新。
这种做法改起来不大,但收益非常明显。早年我做过一个条码数据采集程序,波特率 115200,数据两三百毫秒来一次,如果用最原始的逐个字符 PostMessage 刷新,CPU 占用长期 10% 以上;改成帧缓冲通知之后,CPU 占用降到 1% 左右,界面也不卡了。核心思路其实就一句话:不要让 UI 工作在串口频率上,要让它工作在业务频率上。
6. 使用这个类几年后的一点个人体会
从最早接手的老工控机,到后来 Win10 下用 USB 转串口的小盒子,CSerialPort 这个类在我的项目里反复出现过很多次。说句实话,它不是一个现代设计意义上的漂亮类,没有完整的 RAII、没有智能指针、也没有模板化的事件处理,但它胜在一个“稳”字。大多数用它的项目不是追求代码美感,而是要一个经过千锤百炼、行为可预期、出问题能在网上搜到解决方案的轮子。
我个人给后来者的建议是:如果新项目完全从零开始,可以选 C#、Qt 或者更现代的 C++ 串口库;但如果老项目已经基于 CSerialPort 跑了好几年,完全没必要推倒重来。先把这版 2017-03-12 修正版替换进去,解决新编译器兼容问题,再在业务层做好收发缓冲和线程生命周期管理,它的生命周期还能再续很久。
最后分享一个小技巧:如果你在公司内部维护多个上位机项目,最好把 CSerialPort 这类公共组件抽出来单独作为一个模块,用统一版本管理,并且保留一份变更记录。这样“同一个串口类在不同项目里演化出三个互相不兼容的版本”这种破事,就不会再发生了。我用这个办法收拾过好几台历史遗留电脑,亲测有效。
本文还有配套的精品资源,点击获取