尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Windows下C++串口通信类封装:异步I/O与线程安全实现

Windows下C++串口通信类封装:异步I/O与线程安全实现 1. 项目概述为什么我们需要一个自己的串口通信类在工业控制、嵌入式开发、仪器仪表对接这些领域串口通信就像设备之间的“方言”虽然古老但极其可靠和通用。无论是调试单片机、连接PLC还是与各种传感器、扫码枪、串口屏打交道你几乎都绕不开它。在Windows平台上用VC这里特指基于MFC的C开发做这类上位机软件直接调用Win32 API操作串口是基本功但每次新项目都从头写一遍CreateFile、SetupComm、ReadFile、WriteFile再处理重叠I/O和通信事件不仅繁琐还容易在异步读写、超时处理、错误恢复这些“坑”里栽跟头。网上能找到的CSerialPort、CPort类很多但用起来常常不尽如人意有的对多线程支持不好数据接收一频繁就卡死或丢包有的封装过度把配置和回调接口做得非常死板难以适配特殊协议还有的干脆就是个半成品错误处理简陋稳定性堪忧。所以自己动手封装一个SerialPort类绝不是重复造轮子而是打造一个完全贴合自己项目需求、稳定可控的核心工具。这个类要做的就是把复杂的Win32串口API封装成一个简洁、健壮、易用的C对象让你能像操作文件一样轻松地进行串口通信同时把线程安全、异步处理、超时重试这些脏活累活都藏在内部。2. 核心需求与设计思路拆解2.1 明确串口通信的核心痛点在动手封装之前我们先得想清楚一个工业级可用的串口类需要解决哪些实际问题异步非阻塞这是串口通信的命门。主线程绝不能因为等待串口数据而被阻塞否则界面会“卡死”。必须采用异步I/OOverlapped I/O模型让读写操作在后台进行。实时性与稳定性数据接收要快不能丢包。特别是在高速率如115200甚至更高下需要有高效的缓冲区管理和事件驱动机制。灵活的配置与兼容性波特率、数据位、停止位、校验位这些基本参数自不必说。还要能处理流控制RTS/CTS, DTR/DSR兼容各种奇奇怪怪的设备。健壮的错误处理串口线被拔了、设备断电、缓冲区溢出、奇偶校验错误……各种异常情况都要有明确的处理路径和错误信息上报。线程安全与易用性类的接口应该设计得线程安全方便在多线程环境中被调用。同时对外接口要简洁直观降低使用者的心智负担。2.2 技术选型为什么是重叠I/O 事件驱动Windows下进行异步串口通信主流有几种方式查询方式效率低CPU占用高、事件驱动方式使用WaitCommEvent和重叠I/O方式。我们选择重叠I/OOverlapped I/O结合通信事件的方式这是最强大、最灵活也是相对复杂的一种。重叠I/O的优势它允许一个线程同时发起多个I/O操作读、写并且这些操作在后台执行线程可以继续处理其他任务。操作完成后通过事件Event、回调函数或可等待的句柄来通知。这完美契合了串口通信异步、并发的需求。事件驱动的必要性仅仅用重叠I/O进行读写还不够。我们还需要监听串口的状态变化比如线路状态CTS、DSR等信号变化和通信事件如接收到数据EV_RXCHAR、发送缓冲区空EV_TXEMPTY。通过SetCommMask设置关注的事件并用WaitCommEvent同样使用重叠I/O来等待这些事件发生构成了一个完整的事件循环。我们的SerialPort类将围绕这个核心模型构建一个独立的工作线程或利用I/O完成端口负责执行重叠I/O操作并等待通信事件对外提供打开、关闭、发送、接收等同步接口内部将这些请求转化为异步操作通过回调函数或消息通知机制将接收到的数据和通信状态变化传递给应用层。3. SerialPort类的接口设计与封装要点3.1 类的主要公共接口设计一个设计良好的类接口应该一目了然。我们的SerialPort类可能包含以下核心公共方法class CSerialPort { public: CSerialPort(); virtual ~CSerialPort(); // 1. 打开与关闭 BOOL Open(int nPort 1, int nBaud 9600, int nParity NOPARITY, int nDataBits 8, int nStopBits ONESTOPBIT, DWORD dwCommEvents EV_RXCHAR | EV_CTS | EV_DSR | EV_RLSD, DWORD dwReadBufferSize 4096, DWORD dwWriteBufferSize 4096); BOOL Open(LPCTSTR lpszPortName, ...); // 重载支持COM10以上端口名 void Close(); // 2. 参数配置可在打开后动态调整部分参数 BOOL SetupPort(int nBaud, int nParity, int nDataBits, int nStopBits); BOOL SetTimeout(DWORD dwReadInterval, DWORD dwReadMultiplier, DWORD dwReadConstant, DWORD dwWriteMultiplier, DWORD dwWriteConstant); // 3. 数据读写同步接口内部异步实现 DWORD Write(const BYTE* pData, DWORD dwLength); DWORD Read(BYTE* pBuffer, DWORD dwLength); // 4. 流控制与线路状态 BOOL SetDTR(BOOL bState); BOOL SetRTS(BOOL bState); BOOL GetCTS(BOOL bState); BOOL GetDSR(BOOL bState); // ... 其他线路状态 // 5. 状态与错误 BOOL IsOpen() const; DWORD GetLastError() const; CString GetLastErrorStr() const; // 6. 事件/回调注册关键 void SetReceiveCallback(std::functionvoid(const BYTE*, DWORD) fnCallback); void SetEventCallback(std::functionvoid(DWORD dwEventMask) fnCallback); // 通信事件回调 };注意这里使用了C11的std::function作为回调类型比传统的静态函数void*参数或Windows消息更灵活、更现代。如果项目环境不支持C11可以回退到定义回调接口抽象基类的方式。3.2 关键内部成员与线程模型类的内部需要维护一系列状态和资源private: HANDLE m_hComm; // 串口设备句柄核心 HANDLE m_hThread; // 工作线程句柄 HANDLE m_hEvtStop; // 用于通知工作线程退出的事件 volatile BOOL m_bThreadRunning; // 线程运行标志 // 重叠I/O结构体与事件 OVERLAPPED m_ovRead; OVERLAPPED m_ovWrite; OVERLAPPED m_ovWaitEvent; HANDLE m_hEventArray[3]; // 用于WaitForMultipleObjects的事件数组 // 缓冲区管理 CCriticalSection m_csWriteBuffer; // 写缓冲区锁 std::vectorBYTE m_vecWriteBuffer; // 发送缓冲区队列可能需要 // 回调函数 std::functionvoid(const BYTE*, DWORD) m_fnReceiveCallback; std::functionvoid(DWORD) m_fnEventCallback; // 工作线程入口函数静态成员函数或友元函数 static UINT __cdecl CommThreadProc(LPVOID pParam); UINT CommThreadWorker(); // 实际的工作函数线程模型解析我们创建一个独立的工作线程CommThreadWorker。在这个线程中主要做三件事使用WaitCommEvent重叠I/O等待通信事件如EV_RXCHAR有数据到达。当EV_RXCHAR事件触发时发起一个异步读操作ReadFilewithOVERLAPPED。同时这个线程也监听一个“停止事件”m_hEvtStop和一个“写请求事件”。当有数据需要发送时主线程将数据放入写缓冲区并触发“写请求事件”工作线程被唤醒执行异步写操作。这样所有耗时的、阻塞的I/O操作都在这个后台线程中完成主线程通常是UI线程只需调用Write和Read后者可能同步等待即可保证了界面的流畅。4. 核心实现细节与避坑指南4.1 串口的正确打开与初始化流程打开串口远不止一个CreateFile。一个健壮的Open函数需要按顺序完成以下步骤每一步都可能失败生成设备名处理COM10以上的端口。CreateFile需要的名字是\\.\COM10而不是简单的COM10。CString strPort; if (nPort 9) strPort.Format(_T(\\\\.\\COM%d), nPort); else strPort.Format(_T(COM%d), nPort);以重叠I/O方式打开CreateFile的dwFlagsAndAttributes参数必须包含FILE_FLAG_OVERLAPPED。m_hComm CreateFile(strPort, GENERIC_READ | GENERIC_WRITE, 0, // 独占方式打开 NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 关键 NULL); if (m_hComm INVALID_HANDLE_VALUE) { /* 错误处理 */ }清空缓冲区并设置超时打开后立即清空可能存在的旧数据。SetupComm(m_hComm, dwReadBufferSize, dwWriteBufferSize); PurgeComm(m_hComm, PURGE_TXABORT | PURGE_RXABORT | PURGE_TXCLEAR | PURGE_RXCLEAR); // 设置超时将Read/WriteIntervalTimeout设为MAXDWORD配合重叠I/O实现非阻塞读 COMMTIMEOUTS timeouts { MAXDWORD, 0, 0, 0, 0 }; SetCommTimeouts(m_hComm, timeouts);配置DCB设备控制块这是设置波特率、数据位等核心参数的地方。最容易出错的是DCB结构体的初始化必须用DCBlength填充。DCB dcb {0}; dcb.DCBlength sizeof(DCB); if (!GetCommState(m_hComm, dcb)) { /* 错误处理 */ } dcb.BaudRate nBaud; dcb.ByteSize nDataBits; dcb.Parity nParity; dcb.StopBits nStopBits; dcb.fBinary TRUE; // 必须为TRUE dcb.fOutxCtsFlow FALSE; // 根据是否需要硬件流控制设置 dcb.fRtsControl RTS_CONTROL_DISABLE; // 同上 // ... 设置其他fOutxDsrFlow, fDtrControl等 dcb.fInX dcb.fOutX FALSE; // 软件流控制通常关闭 if (!SetCommState(m_hComm, dcb)) { /* 错误处理 */ }初始化重叠结构并启动工作线程为m_ovRead、m_ovWrite、m_ovWaitEvent创建手动重置事件然后创建并启动工作线程。避坑指南1DCB结构初始化很多崩溃源于未初始化的DCB。dcb.DCBlength必须赋值否则SetCommState大概率失败。使用GetCommState获取当前配置再修改是稳妥的做法。避坑指南2超时设置对于重叠I/O通常将ReadIntervalTimeout设置为MAXDWORD其他超时设为0。这样ReadFile在调用时会立即返回即使没有数据真正的等待发生在WaitForSingleObject对重叠事件上。这是实现非阻塞读的关键。4.2 工作线程的事件循环与数据接收这是类的核心引擎。线程函数大致逻辑如下UINT CSerialPort::CommThreadWorker() { m_hEventArray[0] m_hEvtStop; // 停止事件 m_hEventArray[1] m_ovWaitEvent.hEvent; // 通信事件 // m_hEventArray[2] 可以用于其他内部事件如写请求 DWORD dwEventMask 0; SetCommMask(m_hComm, EV_RXCHAR | EV_CTS | EV_DSR | EV_RLSD | EV_ERR); // 设置关心的事件 while (m_bThreadRunning) { // 发起一个异步的WaitCommEvent if (!WaitCommEvent(m_hComm, dwEventMask, m_ovWaitEvent)) { DWORD dwError GetLastError(); if (dwError ! ERROR_IO_PENDING) { // 严重错误退出线程 break; } // ERROR_IO_PENDING是正常情况表示操作已挂起 } // 等待多个事件停止信号、通信事件完成、写事件... DWORD dwWait WaitForMultipleObjects(3, m_hEventArray, FALSE, INFINITE); switch (dwWait) { case WAIT_OBJECT_0: // m_hEvtStop m_bThreadRunning FALSE; break; case WAIT_OBJECT_0 1: // 通信事件完成 { DWORD dwTransferred; // 获取WaitCommEvent的结果 if (!GetOverlappedResult(m_hComm, m_ovWaitEvent, dwTransferred, FALSE)) { // 处理错误 break; } // 处理具体的事件 if (dwEventMask EV_RXCHAR) { // 有数据到达发起异步读操作 OnReceiveEvent(); } if (dwEventMask EV_ERR) { // 处理通信错误 DWORD dwErrors; ClearCommError(m_hComm, dwErrors, NULL); // 根据dwErrors (CE_FRAME, CE_OVERRUN等)进行错误处理 } // 处理其他线路状态事件EV_CTS, EV_DSR等可通过回调通知应用层 if (m_fnEventCallback) m_fnEventCallback(dwEventMask); // 重置事件准备下一次WaitCommEvent ResetEvent(m_ovWaitEvent.hEvent); } break; case WAIT_OBJECT_0 2: // 写请求事件 OnWriteEvent(); break; case WAIT_TIMEOUT: // 本例中为INFINITE不会发生 default: break; } } return 0; }OnReceiveEvent函数负责发起异步读void CSerialPort::OnReceiveEvent() { BYTE buffer[1024]; // 临时缓冲区 DWORD dwRead 0; if (!ReadFile(m_hComm, buffer, sizeof(buffer), dwRead, m_ovRead)) { if (GetLastError() ! ERROR_IO_PENDING) { // 读操作立即失败非预期错误 return; } // 等待异步读完成 if (!GetOverlappedResult(m_hComm, m_ovRead, dwRead, TRUE)) // TRUE表示等待 { // 异步读失败 return; } } // 成功读取到dwRead字节数据 if (dwRead 0 m_fnReceiveCallback) { m_fnReceiveCallback(buffer, dwRead); // 通过回调将数据抛给应用层 } // 重置读重叠事件为下一次读做准备 ResetEvent(m_ovRead.hEvent); }避坑指南3缓冲区与粘包处理OnReceiveEvent中使用了固定大小的栈缓冲区。在高波特率或大数据量下一次EV_RXCHAR事件可能对应多个字节到达但ReadFile不一定能一次读完。更健壮的做法是使用一个动态增长的环形缓冲区或队列在工作线程中持续读取直到返回ERROR_IO_PENDING且已读取字节为0。应用层的回调函数拿到的是经过组装的完整数据块但这又引入了粘包/拆包的问题。串口是字节流没有边界。因此协议解析如帧头帧尾、长度字段、超时分帧通常是应用层的责任不应在SerialPort类中硬编码。我们的类只负责可靠地传递原始字节流。4.3 数据发送的线程安全实现发送接口Write可能被主线程或其他线程调用而实际的异步写操作在工作线程执行因此需要线程安全的缓冲区队列。DWORD CSerialPort::Write(const BYTE* pData, DWORD dwLength) { if (!IsOpen() || pData NULL || dwLength 0) return 0; // 加锁保护写缓冲区 CSingleLock lock(m_csWriteBuffer, TRUE); // 将数据追加到发送队列 size_t oldSize m_vecWriteBuffer.size(); m_vecWriteBuffer.resize(oldSize dwLength); memcpy(m_vecWriteBuffer[oldSize], pData, dwLength); lock.Unlock(); // 尽早释放锁 // 触发工作线程中的写事件通知它有新数据要发送 SetEvent(m_hEventArray[2]); // 假设索引2是写事件 return dwLength; }工作线程中的OnWriteEvent函数则从队列中取出数据并执行异步WriteFile。这里的关键是一次WriteFile可能无法发送完队列中的所有数据特别是大块数据时需要记录发送偏移量并在本次写操作完成后通过GetOverlappedResult如果队列还有剩余数据继续发起下一次异步写。避坑指南4发送阻塞与流量控制即使使用异步写如果对方设备接收慢或流控制未启用Windows的串口驱动程序缓冲区可能会满导致WriteFile即使是异步的等待很长时间。在实际项目中需要监控写操作完成的时间如果超时可能需要进行重试或丢弃。对于关键数据实现一个带超时和重试机制的发送队列是必要的。4.4 资源的正确清理析构函数和Close函数必须确保所有资源被安全释放顺序很重要设置停止标志m_bThreadRunning FALSE。触发停止事件SetEvent(m_hEvtStop)通知工作线程退出。等待工作线程结束WaitForSingleObject(m_hThread, INFINITE)。取消所有未完成的重叠I/O操作CancelIo(m_hComm)。关闭串口句柄CloseHandle(m_hComm)。关闭所有事件句柄CloseHandle(m_ovRead.hEvent)等。清理缓冲区。避坑指南5CancelIo与线程关联CancelIo只取消调用线程发起的I/O操作。因此最好在工作线程即将退出前由它自己调用CancelIo来取消自己发起的读写操作。或者使用CancelIoExVista及以上系统支持它可以指定取消哪个句柄上的所有操作更为安全。5. 高级应用与实战技巧5.1 与MFC界面线程的协作使用消息或自定义事件工作线程不能直接操作MFC的UI控件。将接收到的数据传递到界面线程有几种方式PostMessage/自定义消息这是MFC里最经典的方式。在工作线程中PostMessage到主窗口消息参数可以是指向数据的指针。务必注意数据内存必须在接收方消息处理函数被正确释放且要防止线程竞争。通常将数据复制到消息结构体中或者使用线程安全的共享队列。CWinThread::PostThreadMessage如果接收方不是窗口而是线程可以使用此方法。使用事件对象CEvent和线程安全队列这是更解耦的方式。工作线程将数据放入一个线程安全的队列如用CCriticalSection保护的std::deque然后触发一个CEvent。界面线程或专门的解析线程等待这个事件事件触发后从队列中取出数据处理。这种方式更灵活能缓冲突发数据。C11的std::function回调到主线程这需要框架支持将任务派发到主线程执行。例如在Qt中可以使用QMetaObject::invokeMethod在MFC中可以结合PostMessage和std::function包装成一个可调用对象。5.2 协议解析层的设计建议SerialPort类只负责透明传输字节流。在实际项目中我们通常会在其之上再封装一个协议解析层。这个层订阅SerialPort的数据接收回调对原始字节流进行解析。基于帧头帧尾例如0xAA 0x55 [长度L] [数据...] [校验和] 0x0D 0x0A。解析器需要在缓冲区中搜索0xAA 0x55然后根据长度字段取出完整一帧验证校验和最后将有效数据帧提交给业务逻辑。基于长度字段帧头包含固定长度的数据长度。解析器先读取固定长度的帧头解析出后续数据长度然后读取指定长度的数据。超时分帧对于没有明显帧结构的简单协议可以规定一段时间内如20ms没有新数据到达则认为当前接收到的数据为一帧。这可以在SerialPort类的接收回调中用一个定时器来实现但更推荐在协议解析层做。一个简单的协议解析器骨架class CProtocolParser { public: void OnComDataReceived(const BYTE* pData, DWORD dwLength) { m_buffer.insert(m_buffer.end(), pData, pData dwLength); ProcessBuffer(); } private: void ProcessBuffer() { while (m_buffer.size() MIN_FRAME_LENGTH) { // 1. 查找帧头 auto it std::search(m_buffer.begin(), m_buffer.end(), FRAME_HEADER.begin(), FRAME_HEADER.end()); if (it m_buffer.end()) { // 没找到完整帧头可能数据不完整保留可能的部分帧头 if (m_buffer.size() FRAME_HEADER.size()) m_buffer.erase(m_buffer.begin(), m_buffer.end() - FRAME_HEADER.size()); break; } // 2. 擦除帧头之前无效数据 m_buffer.erase(m_buffer.begin(), it); // 3. 检查是否有一帧完整的数据 if (m_buffer.size() FRAME_HEADER.size() LENGTH_FIELD_SIZE) break; // 数据不够等待下次接收 DWORD dwFullFrameLength ParseFrameLength(m_buffer[FRAME_HEADER.size()]); if (m_buffer.size() dwFullFrameLength) break; // 数据不够一帧 // 4. 提取一帧验证校验和等 std::vectorBYTE oneFrame(m_buffer.begin(), m_buffer.begin() dwFullFrameLength); if (ValidateFrame(oneFrame)) { // 解析有效数据通知上层 OnFrameDecoded(ExtractPayload(oneFrame)); } // 5. 从缓冲区中移除已处理帧 m_buffer.erase(m_buffer.begin(), m_buffer.begin() dwFullFrameLength); } } std::vectorBYTE m_buffer; };5.3 调试与日志记录串口通信调试尤其是硬件联调非常依赖日志。应在SerialPort类中加入详细的日志输出功能例如输出到文件或调试窗口。记录关键操作打开、关闭、配置参数、发送数据、接收数据、通信错误。记录原始数据以十六进制格式记录每次发送和接收的字节这是排查协议问题的最直接证据。注意在生产版本中可能需要关闭数据日志以减少开销。记录时间戳每条日志都带上精确到毫秒的时间戳有助于分析时序问题。使用条件编译用#ifdef _DEBUG宏来控制调试日志的开启和关闭。6. 常见问题排查与性能优化6.1 典型问题速查表问题现象可能原因排查步骤与解决方案打开串口失败 (INVALID_HANDLE_VALUE)1. 端口号错误如COM10以上未加\\.\前缀2. 端口被其他程序占用3. 权限不足某些系统4. 硬件不存在或驱动问题1. 检查端口名格式。2. 使用设备管理器或Putty等工具确认端口是否可用。3. 以管理员身份运行程序。4. 检查设备连接和驱动。能打开但发送/接收不到数据1. 波特率等参数与设备不匹配2. 流控制设置错误特别是RTS/CTS3. 线路接错TX/RX交叉4. 设备未上电或故障1. 使用串口调试助手确认参数。2. 尝试关闭所有流控制fOutxCtsFlow,fRtsControl设为RTS_CONTROL_DISABLE。3. 检查硬件连接TX接RXRX接TXGND对接。4. 用万用表或示波器检查信号。接收数据不完整、断断续续1. 接收缓冲区溢出2. 工作线程处理慢回调函数耗时过长3. 串口驱动缓冲区设置太小4. 硬件干扰或线缆过长1. 增大SetupComm中的接收缓冲区大小。2. 在接收回调中只做最简单的数据拷贝如入队将耗时操作如解析、UI更新移到其他线程。3. 检查DCB中是否启用了fInX/fOutX软件流控制如果设备不支持会导致问题。程序运行一段时间后崩溃或内存泄漏1. 重叠结构体OVERLAPPED或事件句柄未正确初始化/释放。2. 跨线程内存访问冲突如回调中直接操作已销毁的UI对象。3. 工作线程未正常退出。1. 确保所有HANDLE在析构函数中被CloseHandle。2. 使用线程同步如临界区保护共享数据或使用消息传递。3. 在Close时确保等待工作线程结束。使用调试工具如VS诊断工具检测内存泄漏。发送大量数据时程序“卡死”1. 发送同步写未用异步。2. 异步写但未处理缓冲区满的情况导致WriteFile等待。3. 写队列无限增长耗尽内存。1. 确认使用FILE_FLAG_OVERLAPPED打开串口。2. 实现带超时和流量控制的发送队列。监控写操作完成时间超时则尝试取消或丢弃旧数据。3. 为发送队列设置一个最大长度。6.2 性能优化要点缓冲区大小SetupComm设置的缓冲区是驱动层的缓冲区。适当调大如8192或16384可以应对短暂的突发数据避免因应用层处理不及时导致的溢出CE_OVERRUN错误。接收回调效率这是性能瓶颈的关键。回调函数里绝对不能做复杂的运算、数据库操作、同步的UI更新。应该只做数据拷贝到线程安全队列这一件事。协议解析和业务处理交给另一个专门的线程。写操作合并对于高频小数据包的发送可以考虑在应用层做一个简单的合并。比如每积累50ms的数据或数据量达到一定阈值再一次性调用Write减少系统调用和线程切换的开销。避免频繁的打开关闭串口打开和初始化的开销较大。对于需要持续通信的设备尽量在程序生命周期内保持串口打开状态而不是每次收发都开关。封装一个稳定高效的SerialPort类是深入理解Windows异步I/O和线程编程的绝佳实践。它没有太多高深的算法但极其考验程序员对系统机制、资源管理和异常处理的功底。把这个基础打牢了无论是做工业上位机、物联网网关还是设备调试工具你都会多一份从容和自信。在实际项目中这个类往往会根据具体需求不断演进比如增加自动重连机制、支持热插拔检测通过窗口消息WM_DEVICECHANGE、集成到更高级的通信框架中。记住好的封装不是一次到位的而是在解决一个又一个实际问题的过程中打磨出来的。
返回列表