简介:面向Windows平台C++开发者的串口通信全代码资源,围绕RS-232等常用串行通信标准,覆盖串口初始化、参数配置、全双工数据收发及界面交互等关键环节,适合需要快速构建串口工具或学习串口机制的工程师、学生及嵌入式开发者使用。资源包含一个完整的串口调试助手工程,基于对话框应用程序结构,代码组织清晰,涵盖串口打开/关闭、波特率与校验位设置、收发数据展示等常见功能,可在Visual Studio环境下直接打开编译。压缩包共51个文件,约22.09MB,主要文件类型有C++头文件与源文件(.h/.cpp)、Visual Studio工程配置(.dsw/.vcxproj)、动态/静态链接库(.dll/.lib)以及可直接运行的exe和调试中间产物,工程信息齐全,兼顾源码学习和运行验证。该代码包已有5262人学习,对于串口通信课程设计、上位机开发入门或现有工具改造而言,既是可运行成品,也是值得对照学习的参考资料。
1. 串口通信(windows C++)全代码:别急着抄,先弄懂串口这套机制
串口通信(windows C++)全代码,听起来像是一份能直接编译运行的工程,但实际拆过之后你会发现,真正的坑不在 API 本身,而在你对串口机制的认知。我处理过一个模拟项目,前期把 CreateFile、SetCommState 都写对了,结果读写还是丢数据,最后发现是超时参数没配。这篇文章把整套串口代码拆开讲:先定参数,再走 API 调用链,然后给出一份能跑的封装类代码,最后是五个踩坑现场和一段进阶技巧。适合做嵌入式上位机、工控设备通信的新手和熟手,照着复现就能落地。
2. 串口参数与 API 选型:为什么 CreateFile 是一切的基础
Windows 下的串口通信,从本质上看是把 COM 口当作一个设备对象来操作。你拿到的是一个 HANDLE,后续所有的读写、配置、清理都必须围绕这个 HANDLE 展开,而这个 HANDLE 来自 CreateFile。很多人一上来就搜 SetCommState 怎么配置,却忽略了 CreateFile 的打开方式会直接影响后面的超时和异步行为,所以这一章先把地基打牢。
2.1 串口参数:波特率、数据位、停止位、校验位
在调用任何 API 之前,先把串口的物理层参数定下来。这些参数决定了一帧数据长什么样,收端和发端必须完全一致,否则就会出现乱码或者偶发丢帧。参数不匹配是串口通信最隐蔽的问题,因为在某些波特率下,数据看起来能通,但内容全错。
| 参数 | 常见值 | 适用场景 | 说明 |
|---|---|---|---|
| 波特率 | 9600 | 低速传感器、GPS 模块 | 抗干扰强,但对长帧数据吞吐不利 |
| 波特率 | 115200 | 高速采集、上位机交互 | 常用,但线缆质量不佳时容易丢帧 |
| 数据位 | 8 | 大多数二进制帧 | 一个字节 8 位,做 16 进制解析最方便 |
| 数据位 | 7 | ASCII 字符通信 | 老式设备常用,传中文会有问题 |
| 停止位 | 1 | 默认配置 | 一帧结束标志,1 位足够 |
| 停止位 | 2 | 对时序要求高的场合 | 很少用 |
| 校验位 | NOPARITY | 无校验 | 最常见 |
| 校验位 | PARITY_ODD | 奇校验 | 对噪声敏感的场合 |
| 校验位 | PARITY_EVEN | 偶校验 | 同上 |
选择参数时,我一般先问对接设备的硬件手册。如果手册写的是 9600, 8, N, 1,那就不要自己猜测。8N1 是最常见的组合,绝大多数 MCU 和传感器都支持。如果遇到乱码,第一步不是怀疑代码,而是用串口调试工具把收发的参数核对一遍。
2.2 Windows 串口核心 API 调用链
Windows 串口编程绕不开六个 API:CreateFile、GetCommState、SetCommState、SetupComm、SetCommTimeouts、ReadFile/WriteFile,最后用 CloseHandle 收尾。调用顺序是固定的,我从先到后说明。
第一步是打开串口。CreateFile 的参数值得仔细看,尤其是 dwShareMode 必须是 0,串口不支持共享。打开方式选择 FILE_FLAG_OVERLAPPED,让它支持异步操作,这样后续不会因为一次读取阻塞整个线程。
// 打开串口:用重叠 I/O 方式打开,不阻塞调用线程 HANDLE hCom = CreateFileA( "COM3", GENERIC_READ | GENERIC_WRITE, 0, // 串口必须独占,不能共享 NULL, OPEN_EXISTING, // 只打开已经存在的端口 FILE_FLAG_OVERLAPPED, NULL); if (hCom == INVALID_HANDLE_VALUE) { // 常见失败原因:端口号不存在、端口被占用、权限不足 return false; }这里插几句:CreateFileA 用的是 ANSI 字符串,如果你的工程是 Unicode 编码,最好用 CreateFileW 并传入宽字符端口名,或者在字符串前加 L。端口名写法是 "COM3" 而不是 "\.\COM3",只有 COM10 以上的端口才需要加设备路径前缀,这是个经典易错点。
打开成功后,接下来要获取当前的设备控制块(DCB)并修改参数。GetCommState 把当前配置填充到 DCB 结构体中,然后改字段,最后 SetCommState 写回。很多人图省事直接 memset 清零 DCB,那会丢掉该保留的默认字段,导致配置后行为异常。
DCB dcb; memset(&dcb, 0, sizeof(dcb)); dcb.DCBlength = sizeof(dcb); if (!GetCommState(hCom, &dcb)) { // 获取失败,通常是句柄无效或被占用 return false; } // 按手册配参数:115200, 8, N, 1 dcb.BaudRate = CBR_115200; // 波特率 dcb.ByteSize = 8; // 数据位 dcb.Parity = NOPARITY; // 无校验 dcb.StopBits = ONESTOPBIT; // 一位停止位 // 关键:关闭硬件流控和软件流控,很多设备不支持 dcb.fOutxCtsFlow = FALSE; dcb.fOutxDsrFlow = FALSE; dcb.fOutX = FALSE; dcb.fInX = FALSE; dcb.fRtsControl = RTS_CONTROL_DISABLE; dcb.fDtrControl = DTR_CONTROL_DISABLE; if (!SetCommState(hCom, &dcb)) { return false; }代码里的 fOutX、fInX 控制 XON/XOFF 软件流控,fOutxCtsFlow 控制 CTS 硬件流控。如果你不确定对端设备是否实现了握手协议,我建议先把这几项全部关掉,等链路调通之后再按需开启,这是血泪经验。很多设备手册写着支持流控,实际固件并没有完整实现,开着流控反而把收发卡住了。
配完 DCB 之后,还需要设置收发缓冲区大小和超时。这一步经常被跳过,但缓冲区配太小会导致数据溢出丢失,超时配太大会让读取线程长时间阻塞。SetupComm 用来设置缓冲区,SetCommTimeouts 用来设置超时策略,我会在第四章细讲,这里先给出最小配置。
// 设置接收和发送缓冲区,单位是字节 SetupComm(hCom, 4096, 4096); // 配置超时结构体,必须初始化避免随机值 COMMTIMEOUTS timeouts = {0}; timeouts.ReadIntervalTimeout = 50; timeouts.ReadTotalTimeoutMultiplier = 10; timeouts.ReadTotalTimeoutConstant = 100; SetCommTimeouts(hCom, &timeouts);到这里,串口已经完成了打开、参数配置和超时设置,可以开始读写。后面所有操作都复用这个 HANDLE。也就是说,CreateFile 是整个过程的地基,任何一步配置失误,都表现为读写阶段的神奇故障,这在第五章会有具体的踩坑记录。
3. 一个可用的串口类封装:从打开到收发数据的完整代码
这一章给出一套可以在工程里直接用的 SerialPort 封装类,完整的 SerialPort.cpp、SerialPort.h 和一个串口回环测试 Demo 都整理在下载包里。下面把核心部分拆开讲。
3.1 打开串口与初始化
先把头文件写出来,对外只暴露 Open、Read、Write、Close 四个方法。内部持有两个 OVERLAPPED 结构体,分别服务读和写,这样读线程和写线程各自有独立的事件对象,不会互相干扰。
// SerialPort.h #pragma once #include <windows.h> #include <string> class SerialPort { public: SerialPort(); ~SerialPort(); // 打开串口并配置参数 bool Open(const std::string& portName, unsigned long baudRate, unsigned char byteSize = 8, unsigned char stopBits = ONESTOPBIT, unsigned char parity = NOPARITY); // 读取数据,按实际返回字节数取值 int Read(unsigned char* buffer, int bufferSize); // 发送数据,返回实际写入字节数 int Write(const unsigned char* buffer, int length); void Close(); private: HANDLE handle_; OVERLAPPED readOverlapped_; OVERLAPPED writeOverlapped_; bool isOpen_; };Open 方法内部依次执行 CreateFile、GetCommState、SetCommState、SetupComm、SetCommTimeouts。每一步失败都要回滚,避免留下半开状态。
// SerialPort.cpp #include "SerialPort.h" #include <cstring> SerialPort::SerialPort() : handle_(INVALID_HANDLE_VALUE), isOpen_(false) { memset(&readOverlapped_, 0, sizeof(readOverlapped_)); memset(&writeOverlapped_, 0, sizeof(writeOverlapped_)); readOverlapped_.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); writeOverlapped_.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); } SerialPort::~SerialPort() { Close(); if (readOverlapped_.hEvent) CloseHandle(readOverlapped_.hEvent); if (writeOverlapped_.hEvent) CloseHandle(writeOverlapped_.hEvent); } bool SerialPort::Open(const std::string& portName, unsigned long baudRate, unsigned char byteSize, unsigned char stopBits, unsigned char parity) { handle_ = CreateFileA(portName.c_str(), GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (handle_ == INVALID_HANDLE_VALUE) return false; DCB dcb; memset(&dcb, 0, sizeof(dcb)); dcb.DCBlength = sizeof(dcb); if (!GetCommState(handle_, &dcb)) { Close(); return false; } dcb.BaudRate = baudRate; dcb.ByteSize = byteSize; dcb.StopBits = stopBits; dcb.Parity = parity; dcb.fOutxCtsFlow = FALSE; dcb.fOutxDsrFlow = FALSE; dcb.fOutX = FALSE; dcb.fInX = FALSE; dcb.fRtsControl = RTS_CONTROL_DISABLE; dcb.fDtrControl = DTR_CONTROL_DISABLE; if (!SetCommState(handle_, &dcb)) { Close(); return false; } SetupComm(handle_, 4096, 4096); COMMTIMEOUTS timeouts; memset(&timeouts, 0, sizeof(timeouts)); timeouts.ReadIntervalTimeout = 50; timeouts.ReadTotalTimeoutMultiplier = 10; timeouts.ReadTotalTimeoutConstant = 100; SetCommTimeouts(handle_, &timeouts); isOpen_ = true; return true; }构造函数里 CreateEvent 的第二个参数为 TRUE,表示手动复位事件。这是为了配合 WaitForSingleObject 等待读事件时不会被自动重置,避免漏掉一次完成通知。SetupComm 设置的 4096 字节缓冲区对大多数工控报文是够用的,如果你的单帧数据超过 2KB,建议改成 8192 或者 16384,要根据实际帧长来定。
3.2 同步读写的实现
读写是这个封装类的核心。这里用同步等待方式实现,读线程里调用 Read 时最多阻塞到超时时间,不会无限等下去。
int SerialPort::Read(unsigned char* buffer, int bufferSize) { if (!isOpen_ || buffer == NULL || bufferSize <= 0) return 0; unsigned long bytesRead = 0; BOOL result = ReadFile(handle_, buffer, bufferSize, &bytesRead, &readOverlapped_); if (!result) { DWORD error = GetLastError(); if (error == ERROR_IO_PENDING) { // 数据尚未到达,等待完成事件,最多 2 秒 DWORD wait = WaitForSingleObject(readOverlapped_.hEvent, 2000); if (wait == WAIT_OBJECT_0) { GetOverlappedResult(handle_, &readOverlapped_, &bytesRead, FALSE); } else { // 超时或出错,取消本次请求后直接返回 0 CancelIo(handle_); return 0; } } else { // 其他错误,比如串口被拔掉 return -1; } } return (int)bytesRead; }Read 的流程是:先发一次 ReadFile,数据立刻到达就同步返回;没到的话,Windows 返回 ERROR_IO_PENDING,此时线程等待事件最多 2 秒。等待时间要和 COMMTIMEOUTS 里的总超时配合,不能让 WaitForSingleObject 的等待时间远大于数据到达周期,否则读线程会频繁空转。
Write 的实现逻辑类似,但对超时的容忍度更低,因为写操作更怕长时间占用句柄。
int SerialPort::Write(const unsigned char* buffer, int length) { if (!isOpen_ || buffer == NULL || length <= 0) return 0; unsigned long bytesWritten = 0; BOOL result = WriteFile(handle_, buffer, length, &bytesWritten, &writeOverlapped_); if (!result) { DWORD error = GetLastError(); if (error == ERROR_IO_PENDING) { DWORD wait = WaitForSingleObject(writeOverlapped_.hEvent, 2000); if (wait == WAIT_OBJECT_0) { GetOverlappedResult(handle_, &writeOverlapped_, &bytesWritten, FALSE); } else { CancelIo(handle_); return 0; } } else { return -1; } } return (int)bytesWritten; }Write 里我给的是 2 秒等待。如果对端设备回传 CTS 信号有问题,WriteFile 会一直处于 pending 状态,2 秒后 CancelIo 会强制取消,这样至少不会让线程卡死。实际项目中发一帧的时间如果已经远超 2 秒,那就得考虑增大等待时间或者拆帧发送。
3.3 资源释放与线程安全
Close 方法负责收尾。这里有一个细节:先 CancelIo 清理所有挂起的 I/O 请求,再关闭句柄,顺序反了会导致程序在退出时崩溃。
void SerialPort::Close() { if (handle_ != INVALID_HANDLE_VALUE) { // 先取消所有挂起的 I/O 操作,再关句柄 CancelIo(handle_); CloseHandle(handle_); handle_ = INVALID_HANDLE_VALUE; isOpen_ = false; } }CancelIo 是关闭前必调的一步,它让所有 pending 的 ReadFile/WriteFile 请求立即返回。如果不调,那些阻塞在 WaitForSingleObject 上的线程会继续等待,即使句柄已经关闭,也会造成访问已释放句柄的崩溃。我建议把串口的生命周期做成强一致:打开、配置、使用、清理全部严格按序执行,不要跨线程释放资源。
在实际多线程环境中,读线程和写线程通常是分开的。读线程持续调用 Read,写线程按业务触发 Write。此时要注意两点:第一,两个 OVERLAPPED 结构体必须分开,不能共用一个,否则两个线程的完成事件会相互干扰;第二,关闭串口时不要让别的线程正在阻塞 Read,否则 CancelIo 之后那些线程会返回错误码,如果你把返回 -1 当成致命错误处理,就会触发异常。我的习惯是在 Close 之前先设置一个 isOpen_ 标志,让读线程在下一次循环判断退出,再调 Close。
4. 缓冲区、超时与流量控制:参数怎么设才不会丢数据
串口调不通的案例里,有一大半问题不在代码逻辑,而在参数设置。这些参数包括 COMMTIMEOUTS 结构体的几个字段、收发缓冲区大小,以及流控模式。这一章逐个拆开讲,并给出可复用的推荐值。
4.1 COMMTIMEOUTS 超时参数详解
COMMTIMEOUTS 是 Windows 串口通信的精髓所在。它有 5 个字段,其中和读取相关的有 3 个。很多人在这几个字段上犯迷糊,我先把结构体说明列出来。
| 字段 | 作用 | 推荐值 |
|---|---|---|
| ReadIntervalTimeout | 两个字符到达的最大间隔,单位毫秒,0 表示不使用 | 50 |
| ReadTotalTimeoutMultiplier | 读取每个字符计时的系数,乘以请求字节数 | 10 |
| ReadTotalTimeoutConstant | 每次读取操作允许的固定超时 | 100 |
| WriteTotalTimeoutMultiplier | 写操作的乘法超时 | 10 |
| WriteTotalTimeoutConstant | 写操作的固定超时 | 100 |
举一个例子解释这三个读超时的关系:Read 请求读 64 字节,ReadTotalTimeoutMultiplier 是 10,ReadTotalTimeoutConstant 是 100,那么总超时就是 64 × 10 + 100 = 740 毫秒。ReadIntervalTimeout 独立生效:如果两个字节之间间隔超过 50 毫秒,系统认为一帧结束。
有个常见场景:设备每 100 毫秒发一帧 20 字节的数据。如果把 ReadIntervalTimeout 设得太大,Read 会把两帧当作一次返回,导致数据堆积;设得太小,帧内字节间隔稍大就会被截断。我一般先按设备的帧间隔设置 ReadIntervalTimeout,取帧间隔的三分之一到二分之一。
// 常见设备的推荐配置 COMMTIMEOUTS timeouts = {0}; timeouts.ReadIntervalTimeout = 50; // 帧内字节间隔阈值 timeouts.ReadTotalTimeoutMultiplier = 10; // 每字节超时系数 timeouts.ReadTotalTimeoutConstant = 100; // 固定超时 SetCommTimeouts(hCom, &timeouts);注意,ReadTotalTimeoutMultiplier 和 ReadTotalTimeoutConstant 有一个特殊的 0 值陷阱。两者都为 0 时,表示不使用总超时,Read 会一直等下去,直到读到请求的字节数。这在同步模式下会让线程无限阻塞,千万要避免。
4.2 缓冲区大小与流控选择
缓冲区由 SetupComm 设置。默认情况下系统分配的大小可能只有 1KB 左右,如果你接收的报文长度接近这个值,读取速度跟不上时就会丢数据。SetupComm 的第二个和第三个参数分别设置接收和发送缓冲区,单位是字节。
// 按最大帧长 1KB、双缓冲冗余设置 4KB SetupComm(hCom, 4096, 4096);配置建议:接收缓冲区大小至少是最大单帧长度的 4 倍。如果对端设备会连续发送多帧,缓冲区还要按 burst 的总长度来估算。比如设备一次连续输出 50 帧、每帧 64 字节,那你至少需要 4KB 缓冲,才不至于在上层处理不及时的时候丢数据。
流控的选择比较微妙。软件流控 XON/XOFF 在依赖串口线传输的场合还算可靠,但要考虑一个问题:XOFF 字符可能正好出现在二进制数据流里面,造成误触发。所以发自定义协议二进制帧时,我强烈建议不要用软件流控,除非你的帧做了字节填充。
硬件流控 RTS/CTS 相对可靠,但它要求对端设备真的实现了 CTS 信号控制。有的设备手册写着支持,实际只是固定拉高 CTS,此时你用 RTS_CONTROL_HANDSHAKE 反而会卡住发送。我第一次做设备对接时就在这上面翻车,后来全部改成 RTS_CONTROL_DISABLE,反而稳定了。
// 关闭所有流控,最保守的配置 dcb.fOutxCtsFlow = FALSE; dcb.fOutxDsrFlow = FALSE; dcb.fOutX = FALSE; dcb.fInX = FALSE; dcb.fRtsControl = RTS_CONTROL_DISABLE; dcb.fDtrControl = DTR_CONTROL_DISABLE;如果你确认对端硬件实现了流控,再按下面方式开启 CTS 硬件流控:
// 开启 CTS 流控:发送前等待对端 CTS 有效 dcb.fOutxCtsFlow = TRUE; dcb.fRtsControl = RTS_CONTROL_HANDSHAKE;开启之后,WriteFile 会一直等到 CTS 信号有效才把数据发出去。如果线缆没接 CTS 信号,写操作就会呈现出卡住状态,这正好对应第五章的其中一个坑。
还有一个容易忽略的点:Write 字段的超时设置。默认情况下很多人会把 WriteTotalTimeoutMultiplier 和 WriteTotalTimeoutConstant 也设成和 Read 一样。写超时的意义在于,当对端没有及时接收、串口缓冲区满时,WriteFile 不会一直阻塞。但它也有副作用:如果设备每帧写入时间本来就接近超时上限,数据会被意外截断。我一般把 Write 的固定超时设得比 Read 大一倍,因为写操作通常要等待硬件就绪,不能太苛刻。
关于缓冲区溢出,Windows 的串口接收缓冲是内核级的,应用层 Read 速度跟不上时,数据会先存放在内核缓冲。SetupComm 设置的大小决定内核缓冲容量,如果你的应用解析慢,数据就会在内核缓冲溢出并被丢弃。此时即使你在应用层用大缓冲区也救不回来。解决思路是提高读取线程优先级或者增大内核缓冲,这也是我反复提醒把读取和组帧分开线程的原因。
5. 串口通信常见问题与避坑记录:五个翻车现场
这一章全是实打实的踩坑记录。我把拆项目过程中遇到的、以及很多从业者反复栽跟头的五个问题记录下来,每个都按现象、原因、解决三步写成,方便你直接对照排查。
5.1 打开 COM 口失败:文件被占用或设备路径不对
现象:CreateFile 返回 INVALID_HANDLE_VALUE,GetLastError 返回 5(拒绝访问)或 2(找不到文件)。
原因:最常见的是串口被其他软件占用。Windows 的串口驱动不支持多个进程同时打开同一个 COM 口,无论你的进程是否真的在读写,只要有一个进程打开了它,其他进程的 CreateFile 就会返回 ACCESS_DENIED。这经常发生在自己调试自己程序时:开着串口调试助手,然后再启动上位机,必然打不开。其次是 COM10 以上的端口名没有加设备路径前缀,直接传 "COM10" 会找不到设备。
解决:先检查占用,用设备管理器确认端口号是否被调试工具抢占。代码层面必须支持对 COM10 及以上端口自动加上 "\." 前缀。
// 端口名规范化:COM10 及以上需要加设备前缀 std::string devicePath = portName; if (portName.length() > 4 && portName[3] >= '0' && portName[3] <= '9') { devicePath = "\\\\.\\" + portName; }写串口应用时我一般做一个端口枚举功能,把所有可用 COM 口列出来让用户选择,而不是写死 "COM3"。
5.2 读数据总是少了末尾几字节:超时参数没设对
现象:Read 返回的字节数比实际发送的少,尤其在高波特率下明显,比如设备发了 30 字节,Read 只返回 20 字节。
原因:ReadIntervalTimeout 设得太大或者总超时设得太小。如果 Read 请求 64 字节,而设备只发 30 字节,ReadFile 会等满总超时后才返回已经收到的 30 字节。此时如果你用请求多少字节就必须等到多少字节的逻辑,就会误判为丢数据。本质上是把 ReadFile 当 socket recv 用了,串口是个流式通道,读请求的字节数是上限,不是下限。
解决:把 COMMTIMEOUTS 调成读完一帧立即返回。做法是把 ReadIntervalTimeout 设为帧间隔阈值,ReadTotalTimeoutMultiplier 和 ReadTotalTimeoutConstant 设一个合理的总上限。另外,在 Read 返回后根据实际字节数去组帧,不要固定等待完整长度。
// 适合不定长帧的读取方式 int len = serial.Read(buffer, sizeof(buffer)); if (len > 0) { // 按实际长度处理一帧,而不是等满固定长度 ProcessFrame(buffer, len); }串口数据是按流到达的,你的代码必须做好收流组帧,而不是等长接收。
5.3 收到乱码:波特率或数据位不匹配
现象:收到的数据字符错乱,看起来像火星文,偶尔又有一两个字符正常。
原因:两端波特率不一致是最常见的原因。波特率不同意味着每一位数据的采样时间不对,产生位错和帧错,数据全乱。其次,数据位设成 7 位而发送方按 8 位编码,也会导致解析错位,错位后有些字符看起来正常,有些乱码。校验位不匹配则呈现出随机性,数据刚好奇偶匹配时正常,不匹配时丢。
解决:先用串口调试助手,两端都选同一组参数,确认链路无误再接代码。如果调试助手能通而代码不能通,检查 DCB 里字段是否被错误的默认值覆盖。
提示:排查乱码时,先把流控全部关闭,再把波特率核对一遍,不要盲目调大缓冲区。
5.4 WriteFile 卡住不返回:CTS 硬件流控没接
现象:程序执行到 WriteFile 后就不往下走了,界面无响应,甚至把整个线程卡死。
原因:开启了硬件流控,RTS/CTS 握手线没有接,对端一直没有拉高 CTS,发送被阻塞。同步模式下 WriteFile 会一直等待,如果没设写超时,线程就永远卡住。
解决:要么不启用流控,要么确认线缆完整。写代码时还要给 Write 加一个上限等待时间,并配合 CancelIo 防止永久阻塞。我一般在代码里加一个超时计数器,写超时超过 3 秒就把串口复位重连,这样至少能让系统自愈。
5.5 程序退出时崩溃:关闭句柄的顺序错误
现象:程序退出时偶发崩溃,报错位置不定,Debug 下能看到与 handle 相关。
原因:CloseHandle 被调用时,仍有挂起的 ReadFile 或 WriteFile 请求没有清理。或者一个 OVERLAPPED 事件被多个线程同时等待,句柄关闭后事件访问变成野指针。关闭顺序不对会直接制造一个野指针现场。
解决:关闭串口前先 CancelIo,取消所有挂起的 I/O 请求,然后等每个线程退出后再 CloseHandle,最后关闭事件对象。顺序不能反。
void SerialPort::Close() { if (handle_ != INVALID_HANDLE_VALUE) { CancelIo(handle_); // 等待读写线程退出后再执行到这里 CloseHandle(handle_); // 句柄关闭放在最后 handle_ = INVALID_HANDLE_VALUE; isOpen_ = false; } }6. 最后一步:给串口数据加个环形缓冲区,解决粘包和半包问题
前面提到过,串口是按流到达的,ReadFile 返回的数据可能是一帧、半帧,也可能是两帧连在一起。要处理这种不定长数据,一个实用技巧是引入环形缓冲区。它的作用是把收到的原始字节放进一个固定大小的循环队列,上层解析器再按帧头帧尾切分,完全不受单次 Read 返回长度的影响。这个思路在 Modbus、私有协议解析中非常通用。
环形缓冲区实现上只需要一个数组和两个下标。写指针跟着数据进,读指针跟着数据出,指针到达末尾时回到数组开头。这样一个固定大小的缓冲区能反复利用,避免频繁的 memmove 搬移,也不会因为数据到达快慢而崩溃。
// 环形缓冲区:固定大小,按字节存取 class RingBuffer { public: explicit RingBuffer(int size) : buffer_(new unsigned char[size]), capacity_(size), head_(0), tail_(0), count_(0) {} // 写入 len 字节,返回实际写入数 int Push(const unsigned char* data, int len) { for (int i = 0; i < len && count_ < capacity_; ++i) { buffer_[tail_] = data[i]; tail_ = (tail_ + 1) % capacity_; ++count_; } return count_ > 0 ? len : 0; } // 按长度取出数据,返回实际取出字节数 int Pop(unsigned char* out, int len) { int taken = 0; while (taken < len && count_ > 0) { out[taken++] = buffer_[head_]; head_ = (head_ + 1) % capacity_; --count_; } return taken; } private: std::unique_ptr<unsigned char[]> buffer_; int capacity_; int head_; int tail_; int count_; };代码里的 head_ 指向下一次读取位置,tail_ 指向下一次写入位置,count_ 记录当前有效字节数。写入时先判断是否已满,满了就丢弃,避免覆盖还没被解析的数据;读出时按需取出,不会越界。取模操作让指针在到达末尾时自动回到开头,这是环形队列的标准做法。
把环形缓冲接入前面的 SerialPort 类时,我会单独开一个接收线程,循环里只做两件事:Read 拿数据,Push 进缓冲。解析逻辑完全放到另一个上下文里去处理,这样读取线程不会被业务解析拖慢而导致错过下一个数据包。
// 接收线程:把 Read 的数据推进环形缓冲 RingBuffer rxBuffer(4096); unsigned char tmp[512]; while (running) { int len = serial.Read(tmp, sizeof(tmp)); if (len > 0) { rxBuffer.Push(tmp, len); // 组帧解析:在这里找帧头帧尾 } }这样一个循环不断从串口拿数据,再往缓冲里塞,上层解析逻辑随时可以从缓冲里按帧拉数据。好处是读取线程只需要做一件事,不会因为业务解析耗时太长而错过下一个数据包。我从那以后每次写串口代码,都会强制走一遍缓冲接收、独立解析的思路,不管是 Modbus 协议还是私有帧格式,都没有再为粘包翻过车。希望帮到你。
本文还有配套的精品资源,点击获取