CSerialPort串口只发一次?事件复位与缓冲区管理修复方案 如果你做过串口调试大概率遇到过这种诡异事第一次往设备发指令设备正常响应第二次或第三次再发数据就卡在缓冲区里出不去程序还像是死锁了一样。我之前调试一个工控项目时就被这个坑折磨了很久。问题出在一个流传很广的 C 串口封装类CSerialPort上它号称开箱即用结果在连续发送场景下暴露了一个致命缺陷串口只能发送一次。设备通讯、仪器仪表上位机、嵌入式主板调试都会用到这种基于 Win32 API 的串口封装。CSerialPort类把CreateFile、WriteFile、ReadFile、WaitCommEvent这些 Windows 串口操作包了一层省得每个项目都重复写初始化逻辑。可惜网络流传的版本参差不齐不少版本都有这个共性问题首次发送成功第二次发送要么静默失败要么直接卡死。这篇文章我会给出一个完整有效的修改版源码讲清楚问题到底出在哪、怎么定位、修改后如何验证。适合正在被串口反复发送问题折磨的开发者也适合想把手头旧工程里串口模块盘活的朋友。1. 先聊清楚CSerialPort 类为什么会出现“只能发送一次”1.1 这个类是什么常见应用场景CSerialPort类本质上是 Windows 串口 API 的面向过程封装。在 Win32 里操作串口无非就是打开设备、配置 DCB、读写、关闭句柄但把这些散落在各处的调用整合成一个带接收线程、带回调的类使用体验会好很多。早期很多嵌入式上位机、生产测试工具、工业控制面板里的串口通讯模块用的都是类似结构。这个类最常见的用法是这样打开指定串口比如 COM3、COM5配置波特率、数据位、校验位、停止位注册一个接收回调串口收到数据后在回调里处理业务代码调用Send往设备发指令听起来很简单但正因为封装层把这些操作藏了起来一旦底层状态管理出问题排错就非常费劲。尤其是网上流传的版本经常被改来改去有的是从 MFC 工程里抠出来的有的是给某个具体设备定制过的接口不一致内部实现也千差万别只能发送一次这类问题就这样被反复继承了下来。1.2 问题复现路径与典型特征我遇到的现场情况是这样的程序启动后打开串口点击界面按钮向下位机发送一条查询指令设备正常返回数据。再点一次按钮界面无报错但设备完全没有反应。如果在代码里打断点你会发现Send函数返回了 true可数据根本没出去。还有一种更严重的情况第二次发送时整个程序卡死主线程像被什么东西锁住了一样点哪儿都没反应。这种问题有几个非常典型的特征第一次发送总是成功后续发送失败程序不报错或者返回失败但原因不明把串口关闭再重新打开又能发送一次换一根串口线或换一个 USB 转串口模块问题依旧我在排查时最先怀疑的是硬件因为能发一次太像电路问题。但换设备、换线、换电脑都没用最后只能回到代码层面逐行查。这个重新打开就好一次的特征其实是很强的信号说明问题不是出在物理链路上而是出在软件内部某个状态没有被复位。1.3 三层根因事件状态、线程生命周期、缓冲区残留我翻过好几个版本的CSerialPort源码又把问题按生命周期拆开看最后定位到三个层面的原因它们往往同时出现第一重叠 I/O 的事件状态没有复位。Windows 串口一旦用FILE_FLAG_OVERLAPPED方式打开所有读写操作都是异步的。写入时WriteFile会返回ERROR_IO_PENDING系统完成后会触发OVERLAPPED结构里的hEvent。但如果这个事件是手动重置类型而且代码在写完一次后没有调用ResetEvent第二次写入时WaitForSingleObject会立刻返回程序以为写完了实际上数据还在驱动缓冲区里没真正发出去。第二发送缓冲区的悬挂状态没有清理。某些老版本代码在每次发送前不做PurgeComm上一次发送残留的缓冲数据会干扰下一次写入。特别是当上一次写入因为某种原因被中断驱动侧还保留着半截数据时后续写入很可能直接被驱动拒绝或吞掉。第三线程生命周期没有对齐。有些版本把发送逻辑放在独立线程里第一次发送结束后线程退出了但标志位没有复位第二次发送时事件虽然被触发却没有线程去处理表现为只能发一次。这三个原因互相纠缠单纯修一个地方往往不够。下面我给出的修改版就是同时把这三层问题都处理掉。2. 修改版设计从根上规避“一次性发送”缺陷2.1 设计原则读写分离、状态可复位、超时兜底重新设计这个类之前我先定了三条原则之后所有代码都围绕它们来写。第一条原则就是读写分离。读操作和写操作各自使用独立的OVERLAPPED结构读写事件互不共享。旧版代码里经常用一个m_overlap同时给WaitCommEvent、ReadFile、WriteFile用这在连续发送的场景下很容易出问题。接收线程正在等待数据时如果发送线程又往同一个重叠结构里塞写操作等于直接把系统状态搞乱。独立结构是最基本的隔离手段。第二条原则是状态可复位。包括两部分发送前必须把写事件ResetEvent到无信号状态发送完成后也要复位一次保证下一次调用从干净状态开始。同时发送前用PurgeComm清掉缓冲区残留。很多人觉得PurgeComm太粗暴怕把有用的数据冲掉其实在已经出现异常的情况下留着半截缓冲数据才是更大的坑。第三条原则是超时兜底。串口操作很容易因为驱动异常、设备掉线等原因卡死所以等待重叠结果时必须设置超时。写操作给 3 秒读等待给 1 秒轮询退出标志。这样即使驱动出了问题程序也能及时返回失败而不是无限期挂在那里。2.2 关键成员与内部资源规划修改后的类核心成员如下我先解释每个成员存在的意义再看源码就不会晕。成员作用注意事项m_hComm串口句柄由CreateFile返回关闭前必须CloseHandlem_hRecvThread接收线程句柄关闭串口时先停线程再回收m_overlapRead读操作专用重叠结构事件在OpenPort中创建手动重置m_overlapWrite写操作专用重叠结构每次发送前后都要ResetEventm_writeCS发送临界区防止多线程同时调用Send导致写状态错乱m_stopReceive接收线程退出标志设为普通 volatile 成员定期在循环中检查m_recvCallback接收数据回调函数指针在接收线程中触发注意不要在回调里做耗时操作为什么OVERLAPPED里的事件要用手动重置而不是自动重置自动重置事件在WaitForSingleObject返回后会自动变回无信号状态看起来更省事但它有一个隐患如果事件在写入还未完成时就被其他代码意外等待一次信号就被偷走了。手动重置事件把复位时机完全掌握在手里虽然要多写两行ResetEvent但状态是可预期的。2.3 事件与缓冲区的生命周期管理一个容易忽略的细节是OVERLAPPED结构在创建时的状态。很多人只在构造函数里ZeroMemory一下然后直接使用但hEvent在CreateEvent创建后是无信号状态。问题是如果上一次发送结束时没有复位第二次进入发送函数时事件就带上了旧信号。所以最稳妥的做法不是依赖创建时的初始状态而是在每次使用前显式ResetEvent。缓冲区也是一样。PurgeComm必须在发送前调用一次它的两个关键参数是PURGE_TXABORT和PURGE_TXCLEAR分别表示中止正在进行的写操作、清空输出缓冲区。这个操作能解决两种情况一是驱动侧还有上一次没写完的数据二是旧代码遗留的悬挂写操作。如果你在断点里看到第二次发送时WriteFile返回了奇怪的状态码多半就是缓冲区残留导致。3. 完整修改版源码解析从初始化到发送模块逐个拆解3.1 串口打开与 DCB 参数配置打开串口是第一步也是最容易埋雷的一步。CreateFile必须使用FILE_FLAG_OVERLAPPED因为后续读写都依赖重叠 I/O。然后依次配置 DCB、超时、串口事件掩码、创建事件、清理缓冲区、启动接收线程。顺序不能乱尤其是超时设置很多老版本代码直接省略了这一步导致读写永久阻塞。bool CSerialPort::OpenPort(const char* portName, DWORD baudRate, BYTE byteSize, BYTE stopBits, BYTE parity) { if (m_isOpen) ClosePort(); if (!portName) return false; m_hComm CreateFileA(portName, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (m_hComm INVALID_HANDLE_VALUE) { m_hComm NULL; return false; } DCB dcb; ZeroMemory(dcb, sizeof(dcb)); dcb.DCBlength sizeof(dcb); if (!GetCommState(m_hComm, dcb)) { ClosePort(); return false; } dcb.BaudRate baudRate; dcb.ByteSize byteSize; dcb.Parity parity; dcb.StopBits stopBits; dcb.fBinary TRUE; dcb.fOutxCtsFlow FALSE; dcb.fOutxDsrFlow FALSE; dcb.fDtrControl DTR_CONTROL_ENABLE; dcb.fRtsControl RTS_CONTROL_ENABLE; dcb.fInX FALSE; dcb.fOutX FALSE; dcb.fAbortOnError FALSE; if (!SetCommState(m_hComm, dcb)) { ClosePort(); return false; } // 超时设置必须做否则读写可能永久阻塞 COMMTIMEOUTS to; to.ReadIntervalTimeout 50; to.ReadTotalTimeoutConstant 50; to.ReadTotalTimeoutMultiplier 10; to.WriteTotalTimeoutConstant 100; to.WriteTotalTimeoutMultiplier 0; SetCommTimeouts(m_hComm, to); // 只监听收到数据事件和错误事件 SetCommMask(m_hComm, EV_RXCHAR | EV_ERR); // 两个独立事件手动置位便于自己控制复位时机 m_overlapRead.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); m_overlapWrite.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); if (!m_overlapRead.hEvent || !m_overlapWrite.hEvent) { ClosePort(); return false; } PurgeComm(m_hComm, PURGE_RXABORT | PURGE_RXCLEAR | PURGE_TXABORT | PURGE_TXCLEAR); m_stopReceive false; m_isOpen true; m_hRecvThread (HANDLE)_beginthreadex(NULL, 0, ReceiveThreadEntry, this, 0, NULL); if (!m_hRecvThread) { ClosePort(); return false; } return true; }注意dcb.fAbortOnError最好设为 FALSE。有些驱动在串口出错后会因为 TRUE 值自动中止所有读写操作反而增加不确定性。我们更希望在错误后能通过PurgeComm手动恢复。ReadTotalTimeoutMultiplier的取值也要说两句。它表示每个字节乘以的毫秒数如果设备接收缓慢这个值太小会导致读超时太大又会让接收线程卡太久。上面给的参数是针对大多数常规设备调出来的实际项目里可以按设备响应速度微调。3.2 发送模块每次写入都是全新而干净的状态发送模块是这次修改的重中之重。核心思路是每次调用Send都要保证事件无信号、缓冲区干净、写锁处于独占状态然后执行写入并等待完成完成后再次复位事件并释放锁。bool CSerialPort::Send(const void* data, DWORD length) { if (!m_isOpen || !m_hComm || !data || length 0) return false; EnterCriticalSection(m_writeCS); // 每次发送前把驱动侧悬挂/残留的状态清掉 PurgeComm(m_hComm, PURGE_TXABORT | PURGE_TXCLEAR); // 把写事件复位到无信号状态避免上一次的信号残留 ResetEvent(m_overlapWrite.hEvent); DWORD written 0; BOOL ok WriteFile(m_hComm, data, length, written, m_overlapWrite); if (!ok) { DWORD err GetLastError(); if (err ERROR_IO_PENDING) { DWORD waitRes WaitForSingleObject(m_overlapWrite.hEvent, 3000); if (waitRes ! WAIT_OBJECT_0) { CancelIo(m_hComm); LeaveCriticalSection(m_writeCS); return false; } if (!GetOverlappedResult(m_hComm, m_overlapWrite, written, FALSE)) { LeaveCriticalSection(m_writeCS); return false; } } else { LeaveCriticalSection(m_writeCS); return false; } } // 写完后复位事件下次发送才能重新使用 ResetEvent(m_overlapWrite.hEvent); LeaveCriticalSection(m_writeCS); return (written length); }很多人会忽略written length这个判断。串口写操作存在部分写入的可能比如系统忙或者驱动缓存不足一次WriteFile只写入了一部分数据。如果代码只检查函数返回值遇到部分写入时很容易误判。返回 false 之后由上层决定是重试还是报错比闷头往下走要好。注意PurgeComm的两个参数PURGE_TXABORT和PURGE_TXCLEAR已经把正在挂起的写操作中止了所以如果上一次写入因为某种原因还没完成这次调用也能把它打断。这是解决第二次发送卡死的关键一步。临界区在这里是必要的。实际项目里可能存在多个线程调用Send的情况一个业务线程发心跳一个界面线程发指令。如果没有临界区保护两个线程同时进入发送函数会抢同一个m_overlapWrite后果比只能发送一次更严重。3.3 接收线程与发送互不干扰接收线程的核心是WaitCommEvent加ReadFile的组合。它只负责监视串口收数据完全不碰发送相关的结构从机制上和发送模块隔离。unsigned int __stdcall CSerialPort::ReceiveThreadEntry(void* param) { CSerialPort* self static_castCSerialPort*(param); self-ReceiveLoop(); return 0; } void CSerialPort::ReceiveLoop() { unsigned char buffer[4096]; DWORD mask 0; while (!m_stopReceive) { if (!WaitCommEvent(m_hComm, mask, m_overlapRead)) { DWORD err GetLastError(); if (err ERROR_IO_PENDING) { DWORD waitRes WaitForSingleObject(m_overlapRead.hEvent, 1000); if (waitRes WAIT_TIMEOUT) continue; // 定期醒来检查退出标志 if (waitRes ! WAIT_OBJECT_0) break; } else { break; } } if (mask EV_RXCHAR) { DWORD bytesRead 0; ResetEvent(m_overlapRead.hEvent); if (!ReadFile(m_hComm, buffer, sizeof(buffer), bytesRead, m_overlapRead)) { DWORD err GetLastError(); if (err ERROR_IO_PENDING) { if (WaitForSingleObject(m_overlapRead.hEvent, 1000) WAIT_OBJECT_0) { GetOverlappedResult(m_hComm, m_overlapRead, bytesRead, FALSE); } } } if (bytesRead 0 m_recvCallback) { m_recvCallback(buffer, bytesRead, m_recvUser); } } ResetEvent(m_overlapRead.hEvent); } }这里的WaitForSingleObject(m_overlapRead.hEvent, 1000)是刻意设计的。串口没有数据时WaitCommEvent一直处于等待状态如果线程没有任何超时机制ClosePort里的WaitForSingleObject(m_hRecvThread, 2000)就会等不到线程退出。定时 1 秒醒来一次检查m_stopReceive关闭串口时线程就能在 2 秒内顺利收尾。回调函数是在接收线程里执行的这一点特别提醒一下。如果在回调里做耗时的数据处理比如写数据库、弹窗、加解密会拖慢接收线程导致串口数据堆积甚至丢包。最好的做法是把接收线程收到的数据投递到业务线程处理回调里只做数据拷贝。3.4 资源释放与关闭流程关闭串口的顺序也有讲究先停线程再取消挂起 I/O再关闭事件句柄最后关闭串口句柄。顺序反了容易出现线程还在跑但句柄已经被关闭的问题到时候WaitCommEvent返回一堆莫名其妙的错误线程卡死在异常路径里。void CSerialPort::ClosePort() { m_stopReceive true; if (m_hRecvThread) { WaitForSingleObject(m_hRecvThread, 2000); CloseHandle(m_hRecvThread); m_hRecvThread NULL; } if (m_hComm) { CancelIo(m_hComm); } if (m_overlapRead.hEvent) { CloseHandle(m_overlapRead.hEvent); ZeroMemory(m_overlapRead, sizeof(m_overlapRead)); } if (m_overlapWrite.hEvent) { CloseHandle(m_overlapWrite.hEvent); ZeroMemory(m_overlapWrite, sizeof(m_overlapWrite)); } if (m_hComm) { CloseHandle(m_hComm); m_hComm NULL; } m_isOpen false; }很多人只记得CloseHandle(m_hComm)忽略了CancelIo。如果在关闭串口时还有正在挂起的读写操作不取消的话系统会一直持有这个设备对象有可能导致串口句柄无法完全释放下一轮OpenPort就失败。CancelIo会把所有与这个句柄相关的挂起 I/O 全部取消掉。3.5 把类用起来一个最小可运行的示例代码库有了还得有一个能立即跑起来验证的程序。下面这个最小示例会打开 COM3设置 115200 波特率连续发送 10 次消息。如果串口 TX 和 RX 短接接收回调会把每次收到的数据打印出来。void OnReceive(const unsigned char* buf, DWORD len, void* user) { for (DWORD i 0; i len; i) printf(%02X , buf[i]); printf(\n); } int main() { CSerialPort sp; sp.SetReceiveCallback(OnReceive, NULL); if (!sp.OpenPort(COM3, 115200)) { printf(open err\n); return -1; } const char* msg hello; for (int i 1; i 10; i) { bool ok sp.Send(msg, 5); printf(send %d: %s\n, i, ok ? ok : fail); Sleep(50); } Sleep(500); sp.ClosePort(); return 0; }编译前注意引入头文件Windows 下还需要链接user32和kernel32一般默认就有。_beginthreadex需要包含process.h别漏了。4. 实操验证让“只能发一次”变成“连续发三天”4.1 测试环境搭建代码改完不能直接上线我先搭了一个最简单的回环测试环境。把串口线做成自收自发TXD 和 RXD 短接地线连上就行。这样电脑串口发出的数据会原封不动从接收端回来不用接任何外部设备就能验证收发链路。如果没有万用表确认哪根线需要短接直接买一个成熟的 DB9 回环头或者 USB 转串口回环模块更方便。第二个测试场景是双机互联。一台电脑上插两个 USB 转串口模块A 模块的 TXD 接 B 模块的 RXDA 模块的 RXD 接 B 模块的 TXD两边地线相连。程序里一个串口发送另一个串口接收更接近真实设备通信场景。我的主要验证数据来自回环测试因为不需要同时操纵两个程序。4.2 连续发送压测过程我写了一个压测小工具对修改后的Send函数做连续调用每 50 毫秒发送一次每次 5 字节总共发 1000 次。测试期间界面没有任何阻塞任务管理器里进程句柄数稳定在 3 个左右。这是关键指标如果每次发送都多一个句柄说明有句柄泄漏发一段时间后必然出问题。压测输出的一段日志长这样send 1: ok, bytes5, recv[hello] send 2: ok, bytes5, recv[hello] send 3: ok, bytes5, recv[hello] ... send 998: ok, bytes5, recv[hello] send 999: ok, bytes5, recv[hello] send 1000: ok, bytes5, recv[hello]连续 1000 次全部成功没有一次卡住。后来我把发送间隔缩短到 10 毫秒依旧稳定。要知道修改之前同一台电脑、同一条串口线第二次发送就失败了。这个对比足以说明问题出在软件状态管理而不是硬件链路。4.3 压测中发现的隐藏问题压测并不是一帆风顺中间遇到过两个小问题顺便分享出来。第一个是 USB 转串口模块在高压测下偶发丢第一个字节。这不是CSerialPort的问题而是某些低端 USB 转串口芯片在连接建立后需要几十毫秒稳定时间。解决办法是在OpenPort成功后延迟 100 毫秒再开始发送或者在做正式通信前发一个空数据包唤醒链路。第二个问题出在我自己代码里。刚开始测试时回调函数里直接用了printf而主线程也在用printf打印发送结果两个线程同时访问输出流导致日志乱序。后来我改成先拷到本地缓冲区再集中打印问题消失。这也是为什么我在上面强调接收回调里不要做耗时操作。5. 踩坑实录与排查工具箱5.1 高频问题速查表如果改完这个类之后还是遇到相关问题可以参考下面这张表快速定位现象常见原因排查与处理第二次发送返回失败GetLastError为 87参数错误可能是 DCB 被外部改动检查是否在发送前误调了SetCommState发送超时卡住WaitForSingleObject等不到写事件没有复位或上一次写操作悬挂确认Send结束前ResetEvent超时后CancelIo数据其实发出去了但程序认为失败written ! length部分写入检查返回值必要时重发剩余部分关闭串口后无法重新打开句柄未完全释放或接收线程未退出确认ClosePort先停线程再CancelIo偶尔丢包回调里处理耗时过长接收回调只做拷贝数据交业务线程处理5.2 调试利器与实用技巧串口问题最大的难点是看不见数据流。我调试这个类时主要靠三样工具。第一是系统自带的串口调试助手用来确认程序逻辑之外的数据收发是否正常。把数据发送和接收放在调试助手里测一遍能快速区分是硬件问题还是代码问题。第二是进程句柄数监控。Windows 任务管理器可以加句柄数列。串口程序反复开关串口时如果句柄数只增不减几乎可以断定某处句柄泄漏。我之前排查的一个版本就是每次发送都多一个句柄发 1000 次之后系统资源被吃光现象恰恰就是偶尔发不出去。第三是GetLastError返回码。串口 API 失败后必须立刻取错误码这是最直接的线索。重点记住几个ERROR_IO_PENDING不是错误是正常异步挂起ERROR_OPERATION_ABORTED表示操作被CancelIo打断这在关闭串口时是预期行为。还有一个技巧打开 WinDbg 或 Visual Studio 的调试器在Send函数里设置条件断点观察第二次发送时m_overlapWrite.hEvent的信号状态。你会发现它已经被触发过了这就是只能发送一次最直接的证据。5.3 如果还想继续改进这个类现在的版本已经能稳定连续发送但它还谈不上完美。如果把它用进正式项目我建议再做三件事。一是把接收缓冲区改成环形队列。现在回调函数直接拿原始字节流如果上层协议是分包结构每次都要自己拼接拆包。环形缓冲区能先把字节流缓存起来再按协议帧边界去解析代码会清晰很多。二是增加错误事件处理。EV_ERR事件我虽然监听了但当前版本没有深入处理。串口帧错误、溢出、奇偶校验错误都可能会触发EV_ERR正式项目里最好在这里做一次清错处理否则某些错误状态会一直粘住驱动。三是把事件改成完全的事件驱动模式。目前的接收线程是 1 秒轮询一次WaitCommEvent对大多数应用够用但如果对实时性要求很高可以采用更精细的事件唤醒机制让线程在收到数据瞬间立刻醒来。我在实际调试中最大的体会是串口程序的问题九成出在状态管理而不是 API 本身。这个修改版把事件复位、缓冲区清理、超时兜底三件事做扎实了只能发送一次自然就消失了。如果你手头还有别的串口类存在类似问题也可以用同样的思路排查一遍。下次如果再遇到串口发不出数据先别急着换硬件打开代码看看事件状态是不是被上一次操作留在了不该有的状态。