简介:这是一份面向C++开发者,特别是Windows平台串口通信初学者与中级开发者的轻量级串口类封装资源,解决MFC依赖过重、跨项目复用困难等常见痛点。资源基于CSerialPort类深度优化,新增_AFX宏支持MFC环境兼容,剥离AfxMessageBox等MFC专属调用,并配套提供纯Win32控制台验证程序,显著提升在非MFC工程(如Qt、DirectX或裸Win32项目)中的即插即用性。压缩包共45个文件,含13个头文件(.h)定义核心接口与数据结构、9个源文件(.cpp)实现串口收发、超时控制与事件回调等关键逻辑,另有README.md、How To Use.txt等说明文档及图标、资源脚本和可执行示例,整体仅316KB,结构紧凑、开箱可用。目前已有909人学习下载,读者可直接获取完整可编译的VS2015+工程(含sln/vcxproj)、跨环境验证方案、清晰的使用指引及作者长期维护的联系方式,快速集成稳定串口功能至自有项目。
1. CSerialPort串口类最新修正版2017-03-12:一个被低估的Windows C++串口封装,为什么它至今还在工业现场跑着?
你可能在调试PLC通信、读取温湿度传感器、对接老式工控机时,突然发现——项目里那个“默默无闻”的CSerialPort头文件,居然没用第三方库,也没调WinRT API,却稳稳收发了三年数据。这不是玄学,是2017年3月12日发布的这个修正版真实存在的技术惯性:它用纯Win32 API封装串口操作,不依赖MFC、ATL或C++/CLI,编译进Release后体积不到80KB,且在Windows 7/10/11(x86/x64)上零兼容报错。它不是为AI训练设计的,但它是产线设备日志采集模块里最常被#include的头文件之一;它不支持USB转串口芯片自动识别,但所有CH340、CP2102、FTDI驱动装好后,只要COM号存在,它就敢开、敢设、敢读、敢等超时。适合谁?嵌入式软件维护工程师、工控上位机开发者、课程设计需要快速验证串口协议的学生——如果你要的是“改两行就能让单片机回传字符串”的确定性,而不是花三天配好vcpkg再被CMake报错劝退,那这份资源就是为你留的。
2. 为什么选CSerialPort而不是CreateFile+SetCommState?从底层封装逻辑讲清它的不可替代性
2.1 Win32串口操作的原始链路:为什么裸写CreateFile容易翻车?
Windows下打开串口本质是打开一个设备句柄:CreateFile(L"\\\\.\\COM3", ...)。但仅此一步就埋了三个坑:
- 路径格式必须带
\\\\.\\前缀,否则COM10及以上会失败("COM10"≠"\\\\.\\COM10"); GENERIC_READ | GENERIC_WRITE权限缺一不可,哪怕只读也要申请写权限(系统要求);FILE_FLAG_OVERLAPPED标志决定同步/异步模式,而一旦开了重叠IO,后续ReadFile/WriteFile必须配OVERLAPPED结构体+事件/完成端口,否则阻塞逻辑全乱。
CSerialPort把这层胶水逻辑全包了:构造函数里自动拼接设备路径、校验COM号合法性、统一设置FILE_ATTRIBUTE_NORMAL或FILE_FLAG_OVERLAPPED,并把DCB(设备控制块)初始化封装进InitDefault()。你不用记DCB.BaudRate = CBR_9600,只需调SetBaudRate(9600)——它内部已做宏映射与错误兜底。
2.2 CSerialPort的类结构设计:轻量但不简陋的四层职责分离
该版本源码共1个头文件(CSerialPort.h)+1个实现文件(CSerialPort.cpp),无外部依赖。核心成员函数按职责划分为四组:
| 函数组 | 典型方法 | 解决什么问题 |
|---|---|---|
| 设备管理 | Open(),Close(),IsOpen() | 封装CreateFile/CloseHandle,自动处理路径与句柄状态 |
| 参数配置 | SetBaudRate(),SetDataBits(),SetParity() | 把DCB字段操作转为语义化接口,避免手动填DCB.fParity=TRUE |
| 数据收发 | Read(),Write(),WaitForData() | 同步模式下封装ReadFile/WriteFile,异步模式下管理OVERLAPPED和事件对象 |
| 状态监控 | GetError(),GetStatus(),ClearError() | 封装ClearCommError,返回COMSTAT结构体,暴露cbInQue/cbOutQue字节数 |
提示:它没有实现“自动重连”或“断线检测”,因为串口物理层断开时
ReadFile会立即返回0字节而非报错——这是Windows串口驱动的既定行为。CSerialPort选择尊重这一事实,把判断逻辑留给上层业务(比如连续3次Read()返回0且GetLastError()==ERROR_IO_PENDING才认为异常)。
2.3 与主流替代方案对比:为什么不用Boost.Asio或Qt SerialPort?
| 方案 | 二进制体积 | 依赖项 | 同步/异步支持 | 工业场景适配点 |
|---|---|---|---|---|
| CSerialPort | <80KB | 无 | 可选(构造时指定) | COM号动态枚举需自行实现,但稳定压倒一切 |
| Boost.Asio | >2MB | Boost.System | 异步优先 | 需C++11以上,编译慢,产线旧机器可能无运行时 |
| Qt SerialPort | >5MB | Qt5Core/Qt5SerialPort | 同步+异步 | 必须引入Qt事件循环,GUI项目友好,控制台项目冗余 |
某高校课程设计组曾用Qt SerialPort开发串口调试助手,结果学生打包exe后因缺少Qt5Core.dll被误判为病毒;而用CSerialPort的同学,直接g++ -O2 main.cpp CSerialPort.cpp -o debug.exe,双击即用。这不是技术优劣,而是场景选择——当你面对的是客户现场一台贴着“禁止安装软件”标签的Windows 7工控机时,静态链接、零依赖就是硬通货。
3. 编译与集成:VC6到VS2022全兼容的实操步骤(含CMakeLists.txt模板)
3.1 手动集成到Visual Studio项目(VS2015及以上)
- 将下载包中的
CSerialPort.h和CSerialPort.cpp复制到项目目录(如./src/comm/); - 在VS解决方案资源管理器中右键项目 → “添加” → “现有项”,选中这两个文件;
- 确保
CSerialPort.cpp的“属性” → “常规” → “SDL检查”设为“否”(因使用strcpy等非安全函数,原版未改); - 在使用串口的
.cpp文件顶部添加:
#include "CSerialPort.h" #pragma comment(lib, "kernel32.lib") // 显式链接Win32库注意:
#pragma comment不是必须的(VS默认链接kernel32),但显式声明可避免某些精简版SDK环境下的LNK2019错误。
3.2 CMake构建方式(推荐给现代C++项目)
在项目根目录CMakeLists.txt中添加以下段落:
# 添加CSerialPort为静态库 add_library(CSerialPort STATIC src/comm/CSerialPort.cpp ) target_include_directories(CSerialPort PUBLIC src/comm) # 禁用SDL检查(对应VS的/CESDL-) target_compile_options(CSerialPort PRIVATE $<$<CXX_COMPILER_ID:MSVC>:/GS->) # 链接必需的Win32库 target_link_libraries(CSerialPort PRIVATE kernel32) # 将CSerialPort链接到主可执行文件 add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE CSerialPort)这样做的好处是:跨平台项目中,Linux/macOS可条件编译跳过CSerialPort(用#ifdef _WIN32包裹),而Windows构建时自动启用。
3.3 MinGW-w64(Code::Blocks / CLion)编译要点
MinGW对TCHAR宏处理与MSVC不同,需在CSerialPort.h顶部强制定义:
// 在#include <windows.h>之前插入 #ifndef UNICODE #define UNICODE #endif #ifndef _UNICODE #define _UNICODE #endif #include <windows.h>否则CreateFile会调用ANSI版本,导致COM10+路径解析失败。这是MinGW用户最常踩的坑——现象是Open("COM10")返回false,GetLastError()为2(系统找不到指定文件),原因正是路径被截断为"COM1"。
4. 基础通信实战:从打开COM口到稳定收发HEX数据的完整代码链
4.1 初始化与参数设置:三步建立可靠连接
#include "CSerialPort.h" #include <iostream> #include <vector> int main() { CSerialPort serial; // 步骤1:打开COM3,9600波特率,8N1(无校验,1停止位) if (!serial.Open(_T("COM3"))) { std::wcerr << L"无法打开COM3,错误码:" << GetLastError() << std::endl; return -1; } // 步骤2:设置串口参数(顺序无关,内部会合并写入DCB) serial.SetBaudRate(9600); serial.SetDataBits(8); serial.SetParity(NOPARITY); // 宏定义在CSerialPort.h中 serial.SetStopBits(ONESTOPBIT); // 步骤3:设置读写超时(单位毫秒),避免Read()无限阻塞 serial.SetReadTimeouts(1000); // 读超时1秒 serial.SetWriteTimeouts(1000); // 写超时1秒 std::wcout << L"串口初始化成功!" << std::endl; return 0; }参数说明:
SetReadTimeouts(1000)对应COMMTIMEOUTS.ReadTotalTimeoutConstant = 1000,表示ReadFile最多等待1秒;- 若设为
INFINITE(-1),则ReadFile将永久阻塞直到有数据或出错; SetWriteTimeouts()同理,但实际中写超时极少触发,除非硬件断开或缓冲区满。
4.2 同步模式下的稳定收发:处理粘包与分帧的朴素策略
工业协议(如Modbus RTU)常以0x03 0x03开头,结尾带CRC。CSerialPort不解析协议,只保证字节流准确送达。以下代码演示如何安全读取一帧完整数据:
// 发送查询指令(Modbus功能码03,寄存器0x0000起读2个) std::vector<BYTE> query = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B}; serial.Write(query.data(), query.size()); // 接收响应:先读固定头部2字节,再根据长度字段读剩余部分 BYTE header[2]; if (serial.Read(header, 2) != 2) { std::wcerr << L"读取头部失败" << std::endl; return -1; } // Modbus响应头:[设备地址][功能码],后续字节为数据长度+数据+CRC BYTE dataLen = 0; if (header[1] == 0x03) { // 功能码03响应 dataLen = 0; // 实际长度需再读1字节 BYTE lenByte; if (serial.Read(&lenByte, 1) == 1) { dataLen = lenByte; } } // 分配缓冲区,读取完整帧(含CRC) std::vector<BYTE> response(2 + 1 + dataLen + 2); // 头2 + 长度1 + 数据dataLen + CRC2 response[0] = header[0]; response[1] = header[1]; response[2] = dataLen; if (dataLen > 0 && serial.Read(response.data() + 3, dataLen + 2) != dataLen + 2) { std::wcerr << L"读取数据体失败" << std::endl; return -1; } std::wcout << L"收到完整响应:" << std::hex; for (BYTE b : response) std::wcout << L" " << (int)b; std::wcout << std::endl;关键逻辑:
- 不用
Read()一次性读大缓冲区,避免粘包(多帧数据挤在输入缓冲区); - 按协议分步读,每步都校验返回值,失败立即退出;
response向量预分配足够空间,避免std::vector::push_back在实时通信中触发内存重分配。
4.3 异步模式实践:用事件通知替代轮询,降低CPU占用
若需同时监听多个串口或做GUI更新,异步模式更合适。启用方式仅需在Open()前调用:
serial.SetEventMask(EV_RXCHAR | EV_ERR); // 监听接收字符和错误事件然后在消息循环中(或单独线程)等待:
DWORD dwEvtMask; while (serial.WaitEvent(dwEvtMask, 100)) { // 100ms超时,避免死等 if (dwEvtMask & EV_RXCHAR) { BYTE buf[256]; DWORD bytesRead; if (serial.Read(buf, sizeof(buf), &bytesRead)) { // 处理收到的数据 } } if (dwEvtMask & EV_ERR) { // 清除错误并记录 serial.ClearError(); } }注意:WaitEvent()是阻塞调用,但超时时间可控;EV_RXCHAR表示输入缓冲区有新数据,比轮询PeekNamedPipe更省电。
5. 避坑指南:五个血泪经验总结的常见问题与排查路径
5.1 现象:Open("COM10")失败,GetLastError()返回2(系统找不到指定文件)
原因:Windows要求COM10及以上端口号必须使用\\\\.\\COM10格式,而CSerialPort原版Open()函数未自动补全\\\\.\\前缀。
解决:调用时显式传入完整路径:
serial.Open(_T("\\\\.\\COM10")); // 正确 // serial.Open(_T("COM10")); // 错误,仅对COM1-COM9有效5.2 现象:Read()始终返回0,但串口助手能正常收发
原因:未调用SetReadTimeouts(),导致ReadFile在无数据时无限阻塞(同步模式下)。
解决:务必在Open()后设置读超时:
serial.SetReadTimeouts(500); // 至少设为500ms,避免卡死5.3 现象:发送数据后Write()返回字节数正确,但设备无响应
原因:Windows串口驱动有输出缓冲区,WriteFile返回仅表示数据已进入系统缓冲区,未必到达物理引脚。未调用FlushFileBuffers()强制刷出。
解决:写完后立即刷新:
serial.Write(data, len); FlushFileBuffers(serial.m_hComm); // m_hComm是CSerialPort的私有句柄成员提示:CSerialPort未封装
FlushFileBuffers,需直接访问m_hComm(需在CSerialPort.h中将m_hComm改为public,或添加GetHandle()公有方法)。
5.4 现象:程序退出时Close()卡住数秒
原因:异步模式下,CloseHandle()会等待所有未完成的ReadFile/WriteFile操作结束,若OVERLAPPED未取消,将死等超时。
解决:关闭前先取消所有IO:
CancelIo(serial.m_hComm); // 取消所有待处理IO serial.Close();5.5 现象:中文路径下编译报错error C2065: 'TEXT' : undeclared identifier
原因:_T("xxx")宏依赖<tchar.h>,而CSerialPort头文件未包含它。
解决:在包含CSerialPort.h前加:
#include <tchar.h> #include "CSerialPort.h"或统一用L"xxx"宽字符串字面量替代_T("xxx")。
6. 进阶技巧:定制化扩展与生产环境加固(附COM口自动枚举代码)
6.1 扩展COM口自动枚举:让程序自己找到可用端口
CSerialPort原版不提供枚举功能,但Windows API有现成方案。以下函数返回所有可用COM口列表(std::vector<std::wstring>):
#include <setupapi.h> #include <devguid.h> #pragma comment(lib, "setupapi.lib") std::vector<std::wstring> EnumAvailableComPorts() { std::vector<std::wstring> ports; HDEVINFO hDevInfo = SetupDiGetClassDevs(&GUID_DEVCLASS_PORTS, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo == INVALID_HANDLE_VALUE) return ports; SP_DEVICE_INTERFACE_DATA devIntfData; devIntfData.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i = 0; SetupDiEnumDeviceInterfaces(hDevInfo, nullptr, &GUID_DEVCLASS_PORTS, i, &devIntfData); ++i) { DWORD requiredSize = 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, &devIntfData, nullptr, 0, &requiredSize, nullptr); if (requiredSize == 0) continue; auto detail = std::make_unique<SP_DEVICE_INTERFACE_DETAIL_DATA>(); detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(hDevInfo, &devIntfData, detail.get(), requiredSize, &requiredSize, nullptr)) { std::wstring path(detail->DevicePath); if (path.find(L"COM") != std::wstring::npos) { // 提取COMx部分,如"\\?\usb#vid_1a86&pid_7523#5&1b5e1e4c&0&2#{86e0d1e0-8089-11d0-9ce4-08003e301f73}\\COM3" size_t pos = path.rfind(L"COM"); if (pos != std::wstring::npos) { size_t end = path.find(L"#", pos); if (end == std::wstring::npos) end = path.length(); std::wstring comName = path.substr(pos, end - pos); if (std::find(ports.begin(), ports.end(), comName) == ports.end()) { ports.push_back(comName); } } } } } SetupDiDestroyDeviceInfoList(hDevInfo); return ports; }使用示例:
auto comList = EnumAvailableComPorts(); for (const auto& com : comList) { std::wcout << L"发现端口:" << com << std::endl; // 尝试打开并测试 CSerialPort test; if (test.Open((L"\\\\.\\" + com).c_str())) { std::wcout << L" → 可用" << std::endl; test.Close(); } }6.2 生产环境加固:添加串口状态心跳与自动恢复
工业现场常遇USB转串口芯片掉线(如CH340热插拔后COM号变更)。可在主循环中加入状态检查:
class RobustSerialPort : public CSerialPort { private: std::wstring m_lastComPort; DWORD m_lastCheckTime = 0; public: bool OpenWithRecovery(const std::wstring& port, DWORD checkIntervalMs = 5000) { m_lastComPort = port; if (CSerialPort::Open(port.c_str())) { m_lastCheckTime = GetTickCount(); return true; } return false; } bool IsAlive() { DWORD now = GetTickCount(); if (now - m_lastCheckTime > 5000) { // 每5秒检查一次 m_lastCheckTime = now; DWORD errors; COMSTAT stat; if (!ClearCommError(m_hComm, &errors, &stat)) { // 清除错误失败,大概率端口已失效 Close(); return false; } } return IsOpen(); } // 重写Write,失败时尝试重连 bool WriteWithRetry(const BYTE* pData, DWORD dwSize, int maxRetries = 3) { for (int i = 0; i <= maxRetries; ++i) { if (Write(pData, dwSize)) return true; if (i < maxRetries && !IsAlive()) { // 尝试重连 Sleep(500); if (Open(m_lastComPort.c_str())) { std::wcout << L"串口重连成功" << std::endl; } } } return false; } };部署建议:
checkIntervalMs设为5000ms,避免高频IO影响主线程;WriteWithRetry在关键指令(如设备复位)时启用,普通日志上传可不重试;- 重连后需重新
SetBaudRate()等参数,因Open()不保留上次设置。
从那以后我每次交付工控上位机软件,都会在main()入口处加一段COM口自检日志,用EnumAvailableComPorts()扫一遍,再挑第一个能Open()成功的端口——不是为了炫技,是怕客户打电话说“你们软件打不开串口”,而我第一反应是:“您COM口是不是被其他程序占用了?”希望帮到你。
本文还有配套的精品资源,点击获取