简介:CSerialPort串口类最新修正版2017-03-12面向使用C++进行串口通信开发的程序员,尤其适合需要在MFC与Win32工程间复用串口封装、减少框架依赖的读者。压缩包共45个文件,约316KB,以13个h头文件和9个cpp源文件为核心,并配有sln解决方案、vcxproj工程、Win32示例程序以及txt、md说明文档,便于直接编译验证与对照阅读。此次修正通过_AFX宏处理MFC必要函数,改写AfxMessageBox并进一步剥离MFC依赖,同时加入Win32程序验证非MFC环境下的可用性,可帮助读者理解串口类的接口设计、消息处理与跨工程移植思路。已有910人学习,适合作为串口通信学习、代码复用和MFC解耦改造的参考。
1. CSerialPort串口类落到工程里:先别急着编译,先确认这三件事
很多人拿到 CSerialPort 串口类压缩包,第一反应是拖进工程点编译,结果不是消息映射对不上,就是关闭窗口时卡死。这个资源是 Windows 下基于 Win32 异步串口 API 封装的串口类,标题里的 2017-03-12 修正版更像一次工程修补,核心价值不在“能打开串口”,而在把 CreateFile、DCB 配置、读线程、事件通知和 Close 清理收进一个可复用的类里。它适合做设备通信上位机、工控采集、仪器联调的 C++/MFC 开发者,也适合需要快速把串口收发接进现有对话框程序的人;不适合想跨平台、或者只发几条指令就完事的场景。先把它当成一个有历史包袱的工程件,验收接口和关闭路径,再谈业务。
2. 拆开 CSerialPort 串口类:Win32 异步串口封装与选型边界
2.1 类里到底封装了什么:CreateFile、事件线程和窗口消息
CSerialPort 这类串口类的骨架并不神秘,常见做法是用CreateFile打开COMx,用SetCommState配波特率和校验位,用SetCommTimeouts配读写超时,再开一个读线程等待WaitCommEvent。收到EV_RXCHAR后,线程把数据读进内部缓冲,然后向宿主窗口发送自定义消息,比如WM_COMM_RXCHAR、WM_COMM_ERR、WM_COMM_TXEMPTY。业务层不直接碰HANDLE,只在消息处理函数里取数据。这个模型的好处是 UI 线程不会被ReadFile卡住,坏处是类的生命周期和窗口句柄绑得很紧,窗口先销毁、串口后关闭,就很容易翻车。
拿到资源后,先看头文件里暴露了哪些接口,不要靠猜。下面这张表是这类串口类最常见的接口面,验收时逐项对照,缺哪个就在封装层补哪个,别直接改业务代码去适配。
| 接口/消息 | 常见用途 | 验收点 |
|---|---|---|
Open/InitPort | 打开端口并启动读线程 | 重复打开是否返回失败,失败后句柄是否清空 |
Close | 停线程、关句柄、清事件 | 连续调用是否崩溃,窗口销毁前是否已调用 |
Configure | 修改波特率、校验位 | 运行中改参数是否先停读写 |
Read | 从内部缓冲取字节 | 无数据时返回 0 还是阻塞,超时是否可控 |
Write | 发送字节流 | 返回值是否表示实际写入长度 |
SetReadTimeouts | 配读间隔和总超时 | 参数单位是否为毫秒,是否影响线程退出 |
WM_COMM_RXCHAR | 通知有数据到达 | 消息映射是否挂上,wParam/lParam含义是否核对 |
WM_COMM_ERR | 通知串口错误 | 是否处理帧错误、溢出、设备拔出 |
提示:修正版的“修正”不能替代自测。重点看
Close里线程退出顺序、事件句柄清理、重复打开保护,这三处比新增几个接口更影响稳定性。
从选型上看,单串口、单进程、MFC 对话框程序用这套类很顺手,代码量小,调试路径短。多串口高吞吐、无窗口后台服务、跨平台需求,就不建议硬套。无窗口服务没有消息泵,依赖窗口消息通知的类会变得别扭;高吞吐场景下,如果把解析和 UI 刷新都塞进OnCommRxChar,丢包是迟早的事。我一般会把这类串口类定位成“传输层”,协议分帧、CRC 校验、业务状态机放在独立模块,串口类只负责打开、收字节、发字节、报错。
2.2 直接复用还是二次封装:选型边界与线程模型
直接复用 CSerialPort 串口类的前提,是工程里已经有一个稳定的 Windows 消息循环,并且串口数量少、协议不复杂。二次封装的理由通常有三个:业务层不想依赖 MFC 消息宏;需要把串口收发放到独立线程;需要统一管理多个端口。二次封装不等于重写类,常见做法是保留CSerialPort做底层,外面套一个SerialChannel,把消息回调转成std::function或队列,这样业务层只处理完整帧。
线程模型要提前定清楚。CSerialPort 自带读线程,但它的通知仍然回到窗口线程。若业务解析耗时超过几毫秒,就不要在OnCommRxChar里做浮点运算、查数据库、刷新表格。更稳的做法是:OnCommRxChar只把字节追加到环形缓冲,另一个工作线程从缓冲取帧、校验、入业务队列。这样即使 UI 短暂卡顿,串口读线程也不至于因为内部缓冲满而丢数据。参数上,内部缓冲别用默认的 512 字节,常见做法是 4096 或 8192 起步,高波特率再往上加。
还有一个边界容易被忽略:CSerialPort的读线程通常会等EV_RXCHAR或EV_ERR,如果事件掩码只开了EV_RXCHAR,设备拔出、帧错误、缓冲溢出就不会走WM_COMM_ERR,现场表现就是“数据突然没了,但程序没报错”。所以打开端口时事件掩码至少包含EV_RXCHAR | EV_ERR,需要流控时再加EV_CTS | EV_DSR。这不是玄学,是事件驱动串口的基本盘。
2.3 拿到资源先做接口盘点:四步验收与最小编译
第一步不是写业务,而是把资源里的头文件接口盘出来。Windows 下用findstr就够,重点是看Open、Close、Read、Write、超时接口的签名,避免照着旧例子抄错参数顺序。
REM 在资源目录下执行,先看头文件暴露了哪些关键接口 findstr /n "Open Close InitPort Configure Read Write SetReadTimeouts SetWriteTimeouts" CSerialPort.h这段命令只做一件事:把接口名和行号打出来。/n是显示行号,方便直接跳到头文件对应位置。重点核对Open的参数顺序,常见是端口号、波特率、校验位、数据位、停止位、事件掩码、缓冲大小;如果资源里是InitPort,参数含义通常接近,但不要混用。Read要看清返回值是字节数还是布尔值,Write要看清有没有pdwWritten输出参数,后面写发送代码时按真实签名传参。
第二步是建一个最小对话框工程,只放一个“打开”按钮和一个“关闭”按钮,不接任何业务协议。代码骨架如下,目的不是完成功能,而是验证类能编译、能链接、能打开再关闭。
#include "CSerialPort.h" CSerialPort m_serial; // 成员变量,生命周期跟随对话框 BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 先关后开,避免上一次调试残留句柄 m_serial.Close(); // 参数按资源头文件核对:端口号、波特率、校验、数据位、停止位、事件掩码、缓冲 if (!m_serial.Open(1, 115200, 'N', 8, 1, EV_RXCHAR | EV_ERR, 4096)) { AfxMessageBox(_T("COM1 打开失败,检查端口号是否被占用")); return TRUE; } // 读超时:读间隔 50ms,总超时常量 200ms,避免线程退出时无限等 m_serial.SetReadTimeouts(50, 20, 200); return TRUE; }这段代码的关键在三个参数:端口号从 1 开始对应COM1,波特率必须和设备手册一致,事件掩码里带上EV_ERR才能在异常时收到通知。SetReadTimeouts(50, 20, 200)里 50 是读间隔,20 是总超时乘数,200 是总超时常量,单位通常是毫秒;总超时大致按“乘数 × 请求字节数 + 常量”估算,具体以资源实现为准。若类里没有SetReadTimeouts,只暴露SetCommTimeouts的封装,就按同样思路配,别让读线程卡在无限等待上。
第三步做空口回环:把 TX 和 RX 短接,发固定字节,看能不能原样收到。第四步做拔插测试:打开串口后拔掉 USB 转串口,再插回,观察WM_COMM_ERR是否触发、Close是否还能正常执行。四步都过了,再把这个串口类接进业务。
3. 把串口类接进 MFC 消息链:从 Open 到 OnCommRxChar 的完整闭环
3.1 初始化顺序:先 Close 再 Open,消息映射别漏
CSerialPort 串口类的消息通知依赖窗口句柄,消息映射没挂上,串口打开成功也收不到数据。典型写法是在对话框头文件里声明消息处理函数,在BEGIN_MESSAGE_MAP里挂WM_COMM_RXCHAR和WM_COMM_ERR,然后在OnInitDialog里打开端口。顺序上,先Close再Open,不是多此一举,而是调试期间程序异常退出后,端口有时还挂在旧进程上,先关一次能把类内部状态清干净。
// CMyDlg.h protected: CSerialPort m_serial; afx_msg LRESULT OnCommRxChar(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnCommErr(WPARAM wParam, LPARAM lParam); DECLARE_MESSAGE_MAP() // CMyDlg.cpp BEGIN_MESSAGE_MAP(CMyDlg, CDialogEx) ON_MESSAGE(WM_COMM_RXCHAR, OnCommRxChar) ON_MESSAGE(WM_COMM_ERR, OnCommErr) END_MESSAGE_MAP() BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_serial.Close(); // 清残留状态 if (!m_serial.Open(1, 115200, 'N', 8, 1, EV_RXCHAR | EV_ERR, 8192)) { AfxMessageBox(_T("串口打开失败")); return TRUE; } m_serial.SetReadTimeouts(30, 10, 150); return TRUE; }消息映射宏里的OnCommRxChar不要写成&CMyDlg::OnCommRxChar,MFC 的ON_MESSAGE要的是成员函数名。Open的第六个参数是事件掩码,EV_RXCHAR | EV_ERR表示有数据或错误都通知;第七个参数是内部缓冲大小,8192 适合中等吞吐。若资源头文件的Open参数个数不同,按头文件补齐,别把EV_ERR省掉。窗口销毁时,在OnDestroy里先m_serial.Close(),再调基类,否则读线程可能还在向已经销毁的窗口发消息。
3.2 接收回调只做搬运:OnCommRxChar 与环形缓冲
OnCommRxChar是事故高发区。有人在这里直接拼字符串、刷编辑框、解析协议,低波特率看不出问题,一旦上到 921600 或者设备连续发帧,UI 线程被拖慢,串口内部缓冲很快见底。更稳的写法是回调里只做一次Read,把字节塞进环形缓冲,立刻返回。协议解析放到定时器或工作线程里做,UI 刷新再降频。
LRESULT CMyDlg::OnCommRxChar(WPARAM wParam, LPARAM lParam) { BYTE buf[1024]; // 经典双参数 Read 返回读到的字节数;若头文件是三参数版本,补一个 DWORD nRead 输出参数 int n = m_serial.Read(buf, sizeof(buf)); if (n > 0) { m_rxRing.Push(buf, n); // 只搬运,不解析,不刷 UI } return 0; }Read的返回值要按资源头文件判断:返回int表示字节数时,n > 0才追加;返回BOOL时,还要看输出参数里的实际长度。缓冲区给 1024 字节是折中,太小会增加回调次数,太大会占用栈空间。m_rxRing建议用固定容量环形缓冲,比如 64KB,写入时如果剩余空间不足,记录一次溢出计数,不要静默覆盖。回调里不要调用AfxMessageBox,也不要在调试版里每个字节打一次TRACE,这两种做法都会让接收路径变慢。
注意:
WM_COMM_RXCHAR的wParam/lParam在不同实现里含义可能不同,有的传端口号,有的传事件码。单串口场景可以忽略,多串口场景必须对照头文件,别拿一个参数当端口 ID 到处用。
3.3 发送与超时:Write 返回值、TXEMPTY 和流控
发送路径比接收简单,但返回值必须看。Write返回成功不代表所有字节都写进驱动,有些实现只表示“已提交”。如果资源里的Write带pdwWritten输出参数,就按实际写入长度判断;没有输出参数,就按返回值判断,并在上层加发送队列,避免连续大包把驱动缓冲打满。发送大文件或长帧时,等WM_COMM_TXEMPTY再发下一包,比死循环Sleep稳。
bool CMyDlg::SendFrame(const BYTE* payload, int len) { std::vector<BYTE> frame; frame.reserve(len + 4); frame.push_back(0xAA); // 帧头 frame.push_back((BYTE)len); // 长度,单字节协议示例 frame.insert(frame.end(), payload, payload + len); frame.push_back(0x55); // 帧尾,实际项目建议换 CRC DWORD written = 0; // 若资源头文件的 Write 只有两个参数,去掉 &written,并按返回值判断 if (!m_serial.Write(frame.data(), (DWORD)frame.size(), &written)) return false; return written == frame.size(); }这段发送代码里,frame.reserve避免多次扩容,帧头帧尾只是示例,真实协议换成长度加 CRC 更可靠。Write的第三个参数是实际写入字节数,类型通常是DWORD*;若头文件没有这个参数,就改成if (!m_serial.Write(frame.data(), (DWORD)frame.size())) return false;。发送前要确认端口处于打开状态,发送后如果业务要求可靠,等WM_COMM_TXEMPTY再发下一帧,或者维护一个发送队列,由后台线程逐帧写。
流控是另一个容易漏的点。硬件流控没开,设备发得快、上位机收得慢,表现就是随机丢字节。若 CSerialPort 类没有直接暴露流控接口,常见做法是在Open后拿到串口句柄,用GetCommState/SetCommState改 DCB。下面代码里的GetSafeHandle名称要按资源实际接口调整,没有就自己在类里加一个只读访问器,注意只在打开后调用。
HANDLE h = m_serial.GetSafeHandle(); // 名称按资源头文件调整 if (h != INVALID_HANDLE_VALUE) { DCB dcb = { 0 }; dcb.DCBlength = sizeof(dcb); if (GetCommState(h, &dcb)) { dcb.fOutxCtsFlow = TRUE; // 打开 CTS 输出流控 dcb.fRtsControl = RTS_CONTROL_HANDSHAKE; // RTS 自动握手 dcb.fOutX = FALSE; // 关闭软件 XON/XOFF dcb.fInX = FALSE; SetCommState(h, &dcb); } }fOutxCtsFlow打开后,对端拉低 CTS,本端才会继续发;fRtsControl用硬件握手,不要同时开软件流控,否则两边都以为自己在控流,现场会很乱。若设备手册明确要求无流控,就把这两个开关关掉,别照抄。改完 DCB 后,读超时可能被驱动重置,建议再调用一次SetReadTimeouts,把超时参数重新落下去。
4. 串口参数与时序调优:波特率、超时、缓冲区别拍脑袋填
4.1 参数表:波特率、校验、数据位、停止位、流控
串口参数没有“通用最优”,只有和设备手册一致。波特率不一致,收到的是乱码;数据位少一位,高位被截断;校验位设错,每个字节都像被干扰;停止位不对,部分设备直接不响应。下面这张表是现场最常核对的参数,调试时逐项对,不要只改波特率。
| 参数 | 常见取值 | 设错后的表现 | 核对方法 |
|---|---|---|---|
| 波特率 | 9600、19200、115200、921600 | 全乱码或完全无数据 | 设备手册、示波器测位宽 |
| 数据位 | 8,少数 7 | 字符高位丢失,HEX 对不上 | 看协议帧是否固定 8 位 |
| 校验位 | N/E/O | 偶发帧错误,WM_COMM_ERR频繁 | 设备配置页与代码同时核对 |
| 停止位 | 1,少数 2 | 接收不稳定,边界错位 | 低速设备尤其要确认 |
| 流控 | 无、RTS/CTS、XON/XOFF | 高速连续发时随机丢字节 | 看设备是否要求硬件握手 |
| 事件掩码 | `EV_RXCHAR | EV_ERR` | 出错无通知,程序像“黑匣子” |
表格里的“完全无数据”和“全乱码”要分开看:完全无数据先查端口号、线序、打开是否成功;全乱码先查波特率和校验位。WM_COMM_ERR频繁触发时,不要只清错误标志,先把ClearCommError拿到的错误码打出来,常见有CE_FRAME、CE_OVERRUN、CE_RXOVER。帧错误多半是波特率或校验位不对,溢出错误多半是上层读得太慢或缓冲太小。
4.2 读超时与缓冲:SetReadTimeouts 的三个参数
读超时决定读线程等多久返回。设成无限等待,关闭串口时线程可能退不出来;设得太短,读线程频繁空转,CPU 占用上升。常见配置是读间隔 20 到 50 毫秒,总超时常量 100 到 300 毫秒。SetReadTimeouts(50, 20, 200)的含义是:收到第一个字节后最多再等 50 毫秒看后续字节,总超时按乘数和常量估算,避免一个请求永远不返回。
// 打开成功后立刻设置,参数单位按资源实现通常是毫秒 m_serial.SetReadTimeouts(30, 10, 150); // 如果类没有封装 SetReadTimeouts,可在拿到句柄后直接配 COMMTIMEOUTS COMMTIMEOUTS to = { 0 }; to.ReadIntervalTimeout = 30; to.ReadTotalTimeoutMultiplier = 10; to.ReadTotalTimeoutConstant = 150; to.WriteTotalTimeoutMultiplier = 10; to.WriteTotalTimeoutConstant = 200; SetCommTimeouts(h, &to);ReadIntervalTimeout是字节间超时,适合不定长协议;ReadTotalTimeoutMultiplier和ReadTotalTimeoutConstant一起决定总超时。写超时也别设 0,否则驱动缓冲满时Write可能长时间不返回。内部缓冲从 4096 起步,高波特率连续采集可以上 8192 或 16384,但别无限加,缓冲太大只会把丢包问题推迟暴露,最后还是要靠协议流控和快速搬运解决。
4.3 字节流分帧:长度字段加 CRC 的状态机
串口是字节流,没有消息边界。收到AA 05 01 02不能假设一帧结束,下一次可能补齐剩余字节。常见协议是帧头、长度、载荷、CRC。解析状态机放在工作线程里,按字节喂入,凑够一帧再交给业务。下面代码是精简版,重点看状态切换和长度上限保护。
enum RxState { WAIT_HEAD, WAIT_LEN, WAIT_BODY, WAIT_CRC }; RxState st = WAIT_HEAD; BYTE len = 0, body[255], crc = 0; int pos = 0; void FeedByte(BYTE b) { switch (st) { case WAIT_HEAD: if (b == 0xAA) st = WAIT_LEN; break; case WAIT_LEN: len = b; if (len == 0 || len > 200) { st = WAIT_HEAD; break; } // 长度上限保护 pos = 0; st = WAIT_BODY; break; case WAIT_BODY: body[pos++] = b; if (pos >= len) st = WAIT_CRC; break; case WAIT_CRC: crc = b; if (CalcCrc(body, len) == crc) OnFrame(body, len); // 完整帧交给业务 st = WAIT_HEAD; break; } }len == 0和len > 200是保护条件,防止错误字节把缓冲区写穿。CalcCrc要按设备协议实现,别自己换算法。状态机每次只处理一个字节,适合放在环形缓冲消费循环里;如果协议有转义字符,还要在WAIT_BODY里处理转义还原。分帧做好后,OnCommRxChar里就更不应该解析业务,否则状态机会被 UI 卡顿打断,恢复时容易丢字节。
5. 排查串口类常见故障:五条血泪踩坑记录
5.1 先固定证据:日志、端口状态和最小复现
串口问题最怕“时好时坏”。现场一乱,很多人直接改波特率、换线、重装驱动,最后不知道哪一步起了作用。我的习惯是先固定证据:记录打开参数、每次Read返回字节数、WM_COMM_ERR的错误码、Close调用顺序。日志不要每字节打,按秒汇总或者按帧记录,否则日志本身就把接收路径拖慢。
void LogComm(const char* tag, DWORD err, bool opened, DWORD rxBytes, DWORD txBytes) { char line[256]; // 只记录状态和计数,不在高频回调里逐字节打印 sprintf_s(line, "[%s] err=%lu open=%d rx=%lu tx=%lu\n", tag, err, opened ? 1 : 0, rxBytes, txBytes); OutputDebugStringA(line); }这个日志函数的参数分别是场景标签、错误码、打开状态、累计接收字节、累计发送字节。OutputDebugStringA在调试器里看,Release 下可以换成文件日志,但要做限流。定位时先看open是否一直为 1,再看rx是否增长;如果rx不涨但设备确实在发,优先查消息映射和事件掩码;如果rx涨但业务收不到完整帧,查分帧状态机和长度字段。
最小复现是把问题压到十行代码:只打开串口、只读字节、只打印 HEX。业务代码全部注释掉,TX/RX 短接做回环。回环能通,说明类和驱动没问题,故障在业务解析或 UI 线程;回环不通,再查端口号、线序、驱动和权限。这个二分法很土,但比盯着几千行业务代码猜要快。
5.2 五个高频坑:现象、原因、解决
第一个坑是“打开成功但收不到数据”。现象是Open返回真,设备灯在闪,OnCommRxChar一次不进。原因通常是消息映射没挂、窗口句柄传错、事件掩码没开EV_RXCHAR,或者类内部通知的是父窗口而业务在子对话框里等。解决方法是先核对BEGIN_MESSAGE_MAP里有没有ON_MESSAGE(WM_COMM_RXCHAR, OnCommRxChar),再确认Open的事件掩码包含EV_RXCHAR,最后在回调入口加一行计数日志,确认消息到底有没有到。
第二个坑是“关闭窗口时卡死或崩溃”。现象是点右上角关闭,程序停在Close里,或者直接访问违例。原因多是读线程还在跑,窗口已经销毁,线程仍向旧句柄PostMessage;也可能是CloseHandle顺序不对,事件句柄先关,线程还在WaitCommEvent。解决方法是把m_serial.Close()放到OnDestroy的最前面,Close内部先置退出标志,再关事件,再等线程结束,最后关串口句柄;不要在OnCancel里只调EndDialog不关串口。
第三个坑是“高波特率随机丢字节”。现象是低波特率正常,升到 921600 后每几百帧少一两个字节,CRC 偶尔错。原因通常不是线,而是OnCommRxChar里做了耗时操作,或者内部缓冲太小,读线程来不及搬。解决方法是回调里只Read到环形缓冲,解析放工作线程;缓冲从 8192 起步;发送端加帧间隔或等TXEMPTY;硬件流控该开就开。若还丢,用示波器看波形和地线,别只改代码。
第四个坑是“USB 转串口拔插后再也打不开”。现象是第一次能用,拔掉再插,Open一直失败,或者打开成功但收不到。原因是旧句柄没释放、端口号变了、驱动还没枚举完。解决方法是捕获WM_COMM_ERR后主动Close,延时 500 毫秒到 1 秒再重试;重试前重新枚举COM号,不要硬编码COM3;连续失败三次就提示用户检查设备,不要死循环重开把 UI 卡住。
第五个坑是“HEX 显示和字符串显示打架”。现象是设备发明文能看,发 HEX 就乱码,或者中文变成问号。原因是把字节流当字符串处理,编码转换时机不对。解决方法是接收层永远按BYTE缓冲,显示层再决定按 HEX 还是按 GBK/UTF-8 解码;发送时先把字符串转成字节数组,再走Write,不要在CString和char*之间反复转。调试期统一用 HEX 看,能少很多误判。
5.3 把问题压到十行代码:二分定位法
遇到“有时丢包、有时正常”,先做回环,再做固定长度压测。回环测试不接设备,直接把 TX 和 RX 短接,发 1000 帧固定内容,统计收到多少帧、CRC 错多少。回环稳定后接设备,如果问题复现,说明在设备协议或线缆;回环也不稳,说明在程序或驱动。这个顺序能避免一上来就怀疑设备,也能避免把驱动问题拖进业务层。
压测时把发送间隔、帧长、波特率、缓冲大小四个参数记下来,每次只改一个。比如固定 115200、帧长 64、间隔 10 毫秒,先把缓冲从 4096 调到 8192,看丢包是否下降;再调间隔,看是否与发送速度有关。不要一次改五个参数,改完好了也不知道为什么好。串口调试里,能复现比“感觉好了”值钱。
6. 串口类稳定性验收:回环压测与断线重连的最后一公里
回环压测我一般用一个小脚本配合上位机,脚本只负责发帧和校验,不掺业务。下面这段 Python 用pyserial发 10000 帧,每帧带序号,收到后比对序号和 CRC;没有pyserial就换成任意串口助手手动发,但统计丢包会麻烦。
import serial, struct, time ser = serial.Serial("COM3", 115200, timeout=0.2) lost = 0 for seq in range(10000): payload = struct.pack("<I", seq) + b"\x00" * 12 frame = b"\xAA" + bytes([len(payload)]) + payload + bytes([sum(payload) & 0xFF]) ser.write(frame) time.sleep(0.002) # 2ms 间隔,逐步压到 0 观察丢包 echo = ser.read(len(frame)) if len(echo) != len(frame) or echo[2:6] != payload[:4]: lost += 1 print("lost", lost) ser.close()脚本里的COM3、波特率、帧头要和上位机一致;time.sleep(0.002)是发送间隔,先 2 毫秒,稳定后再降到 0。echo[2:6]对应序号位置,长度字段和 CRC 位置按实际协议调。压测通过标准不是“一帧不丢”,而是丢包率可解释、CRC 错误可复现、断开后能重连。断线重连重点看三件事:拔掉设备后WM_COMM_ERR是否触发,Close是否不卡,重新枚举端口后能否在 1 秒内打开。下面这张表可以作为验收记录。
| 验收项 | 方法 | 通过标准 |
|---|---|---|
| 空口回环 | TX/RX 短接,发 1000 帧 | 收到帧数与发送一致 |
| 高波特率压测 | 921600,帧长 64,间隔 0 | 丢包有计数,不静默丢 |
| 拔插恢复 | 运行中拔掉再插回 | 错误有通知,重连成功 |
| 关闭清理 | 连续打开关闭 50 次 | 不崩溃、不残留句柄 |
| 异常长度 | 发长度 0、255 的坏帧 | 状态机不越界,能回到帧头 |
从那以后我每次拿到串口类资源,都先跑一轮回环和拔插,再改业务代码;这份 2017-03-12 的 CSerialPort 串口类包也一样,先核对头文件接口,按第 2 章的验收步骤走一遍,再把它接进你的模拟项目X。希望帮到你。
本文还有配套的精品资源,点击获取