资讯动态

CSerialPort串口通信实战:Win32封装、收发链路与避坑

发布时间:2026/10/24 2:35:32 来源:尧图企业网站定制
简介面向Windows平台从事串口通信开发的C程序员与嵌入式上位机开发者这份CSerialPort串口类修正版聚焦轻量化集成与MFC依赖剥离适合需要在MFC或纯Win32项目中快速接入串口收发、调试设备通信的读者。压缩包共45个文件约316KB包含串口类头文件与C源码、Visual Studio解决方案及工程配置、Win32示例程序、使用说明文本和少量图标资源既能直接编译验证也便于按模块抽取到现有工程。此次修正新增_AFX宏以处理MFC下的必要函数调用改写AfxMessageBox等接口并补充Win32演示工程用来验证非MFC环境下的可用性。已有910人学习下载读者可据此了解串口类的目录组织、MFC与Win32两种接入方式以及去除框架依赖时的改造思路为上位机串口模块选型和排错提供参考。1. 设备管理器里明明有 COM7CreateFile 却返回无效句柄插上 USB 转 232 转接头设备管理器里端口号显示得清清楚楚代码里CreateFile返回的句柄却是INVALID_HANDLE_VALUE。这种下午凡是跟硬件打过交道的人都经历过。CSerialPort 这个串口类存在的意义就是把CreateFile、DCB、COMMTIMEOUTS、WaitCommEvent这一长串 Win32 调用收进一个 C 类里让调用方只需要面对OpenPort、WritePort、OnComm三个动作。2017-03-12 这一版修正的正是老版本里几处让人头疼的地方64 位下的句柄强转、COM10 以上端口名的前缀、关闭时线程退不干净。它适合的人群很明确——手上有一个 MFC 或者纯 Win32 工程要跟 PLC、扫码枪、称重仪、自绘电路板对话又不愿意为了一条串口把 Qt 或 boost 这样的重量级依赖拖进项目里。2. 项目定位与技术栈一个串口类如何把 Win32 API 收口先把这个串口类放回它的坐标里。Windows 平台上的串口本质上是一个文件句柄读写走ReadFile/WriteFile配置走SetCommState事件走WaitCommEvent。这些 API 单独拿出来每一个都不难难的是它们必须按固定顺序组合任何一个参数填错表现都是没反应没有异常、没有日志、没有堆栈。CSerialPort 干的事情就是把这套顺序固化下来再用一个回调把数据交回业务层。2.1 源码包里的文件构成与职责划分拿到压缩包解压之后结构通常很朴素没有 CMakeLists、没有 vcpkg.json就是一对.h/.cpp加一个示例工程。这套构成沿用自 MFC 时代好处是复制粘贴就能用坏处是没有包管理依赖全靠人肉确认。我按要不要动给你分一下文件主要职责二次开发时的处理方式SerialPort.h类声明、回调函数指针、接收缓冲区成员、线程句柄常改加接口、换缓冲容器都在这里SerialPort.cpp全部 Win32 调用、侦听线程实现、错误处理核心文件改动前先做回归测试stdafx.h/targetver.h预编译头、SDK 版本宏基本不碰除非要降 SDK示例对话框工程打开/关闭按钮、发送编辑框、接收显示当验证工具用别当生产代码真正需要读透的只有前两个文件。SerialPort.h里通常会看到这样几个成员通信句柄m_hComm、线程句柄m_hThread、退出事件m_hShutdownEvent、重叠结构用的m_ov、以及一个接收缓冲。这几个成员的生命周期管理几乎决定了这个类稳不稳。SerialPort.cpp里则是完整的一条链路OpenPort→ 配置 DCB → 配置超时 → 建退出事件 → 起侦听线程 → 循环WaitCommEvent→ 收到EV_RXCHAR调OnReceive→ 业务回调。读懂这一条链路剩下的都是参数细节。2.2 依赖与编译模型MFC、TCHAR 与 32/64 位这个串口类的依赖非常薄串口相关 API 全在kernel32.dll里理论上不需要任何第三方库。但它大量使用了CString、TRACE、_T()这些 MFC/ATL 设施所以默认是给 MFC 工程用的。如果你的工程是纯 Win32 或者 Qt第一件事就是把CString换成std::wstring或std::stringTRACE换成OutputDebugString或者你自己的日志宏。字符集是第二个坑。VS 新建工程默认 UnicodeCreateFile展开是CreateFileW传char*会直接编译报 C2664。老代码里到处是char szPort[10]这一版修正之后一般统一成了TCHAR但仍然建议你自己过一遍所有字符串拼接的地方。线程这块要注意签名。侦听线程是类的静态成员函数标准写法是// 静态成员因为 CreateThread 不接受成员函数指针 static UINT __stdcall CommThread(LPVOID pParam);32 位下LPVOID和DWORD都是 4 字节随手强转不会出事64 位下指针是 8 字节如果代码里写的是(DWORD)pParam再转回来指针会被截断表现是线程一启动就崩而且崩的位置每次都不一样。2017 这一版把这类强转改成了DWORD_PTR这也是它值得替换老版本的主要原因之一。提示64 位下编译时把警告级别开到/W4指针截断这类问题编译器会直接告诉你比运行期调试省事得多。2.3 和 QSerialPort、boost.Asio 放一起怎么选选型这件事没有绝对答案看你的工程底盘。下面这张表是我自己在几个项目里踩过之后形成的判断方案依赖体积上手成本适合场景主要短板CSerialPort两个文件低已有 MFC/Win32 工程只需 Windows无跨平台异步模型靠自建线程QSerialPortQt Core中已经是 Qt 项目为一条串口引入整个 Qt 不划算boost.Asioheader-only 可裁剪高已有 Asio 事件循环多路 IO学习曲线陡串口文档少直接写 Win32无中高需求极简、只读不写超时和线程要自己兜底如果工程本来就是 MFC 对话框程序CSerialPort 是最省事的把两个文件拖进工程改一下端口号和波特率就能跑。如果工程已经全面 Qt 化硬塞一个 Win32 串口类反而别扭因为消息循环和线程模型对不上。3. 核心链路拆解从 OpenPort 到 OnComm 的完整路径这一章是重点按调用顺序一段一段看。每一段我都会给出典型实现、参数含义以及填错之后会看到什么现象——串口调试之所以玄学多半是因为错误被 API 吞掉了不告诉你错在哪。3.1 打开端口端口名拼接与 CreateFile 的六个参数第一段是OpenPort。这里面有一个流传很久的坑COM1 到 COM9 可以直接写COM3但 COM10 及以上必须写成\\.\COM10否则CreateFile一定失败错误码是 2找不到文件。因为COM10在 Win32 命名规则里会被当成一个普通文件名解析。BOOL CSerialPort::OpenPort(int nPortNo, UINT nBaud, BYTE nParity, BYTE nDataBits, BYTE nStopBits) { // COM10 及以上必须带 \\.\ 前缀否则解析成文件名CreateFile 返回失败 CString szPort; if (nPortNo 9) szPort.Format(_T(\\\\.\\COM%d), nPortNo); else szPort.Format(_T(COM%d), nPortNo); m_hComm CreateFile( szPort, GENERIC_READ | GENERIC_WRITE, // 读写权限都要只读会在后续 WriteFile 报错 0, // 串口不支持共享必须独占填 FILE_SHARE_READ 会失败 NULL, OPEN_EXISTING, // 串口是已存在设备没有 CREATE 语义 FILE_FLAG_OVERLAPPED, // 异步模式否则 WaitCommEvent 会卡住调用线程 NULL); if (m_hComm INVALID_HANDLE_VALUE) return FALSE; // 具体原因用 GetLastError() 取 return TRUE; }六个参数里最容易错的是第三个和第六个。第三个dwShareMode必须是 0因为串口是独占设备写成共享模式在某些驱动上会直接返回ERROR_ACCESS_DENIED。第六个FILE_FLAG_OVERLAPPED决定了这个句柄是同步还是异步——不填的话后面WaitCommEvent会阻塞在调用处UI 直接假死。nPortNo是整数端口号不是字符串这个设计省掉了调用方自己拼COM前缀的麻烦但也意味着端口号必须从配置里读出来转成 int不能直接把COM7传进去。3.2 配置串口DCB 与 COMMTIMEOUTS 怎么填打开之后立刻要配置两样东西设备控制块 DCB 和超时结构 COMMTIMEOUTS。DCB 决定波特率、数据位、校验、停止位、流控超时决定ReadFile什么时候返回。DCB dcb { 0 }; dcb.DCBlength sizeof(DCB); if (!GetCommState(m_hComm, dcb)) // 必须先取当前状态再改不能全零硬填 return FALSE; dcb.BaudRate nBaud; // 115200 / 921600注意驱动是否真的支持 dcb.ByteSize nDataBits; // 常见 8 dcb.Parity nParity; // NOPARITY / EVENPARITY / ODDPARITY dcb.StopBits nStopBits; // ONESTOPBIT 最常见 dcb.fBinary TRUE; // 二进制模式Windows 下必须为 TRUE dcb.fParity (nParity ! NOPARITY);// 校验错时上报 EV_ERR 事件 dcb.fDtrControl DTR_CONTROL_ENABLE;// 有些设备靠 DTR 供电或判断主机在线 dcb.fRtsControl RTS_CONTROL_ENABLE;// 硬件流控时由驱动接管其余情况保持高电平 dcb.fOutxCtsFlow FALSE; // 不开硬件流控时务必关掉否则发送会一直等 CTS if (!SetCommState(m_hComm, dcb)) return FALSE;fDtrControl这一项特别值得说。有些单片机板子把 DTR 当作复位信号或者电源使能DTR 不拉高板子根本不上报数据你会以为是串口类的问题其实是电平的事。反过来某些 USB 转串口芯片在 DTR 拉高时会触发目标板复位接上去一直重启这时候把DTR_CONTROL_ENABLE改成DTR_CONTROL_DISABLE就好。超时结构决定读的节奏COMMTIMEOUTS to { 0 }; to.ReadIntervalTimeout 50; // 两字节之间超过 50ms 就认为这一批读完了 to.ReadTotalTimeoutMultiplier 0; // 按字节数累加的超时异步模式一般填 0 to.ReadTotalTimeoutConstant 100; // 单次读取的兜底超时 to.WriteTotalTimeoutMultiplier 0; to.WriteTotalTimeoutConstant 500; // 写超时防止对端不收导致 WriteFile 挂住 SetCommTimeouts(m_hComm, to);ReadIntervalTimeout是帧边界判断里最实用的一个参数。设成 50ms意思是字节流中断超过 50 毫秒就当作一帧结束很多变长协议靠这一条就能粗分帧不用额外写状态机。设成MAXDWORD则是有多少读多少立刻返回适合高吞吐场景但会让上层拿到被切碎的半包数据。3.3 侦听线程WaitCommEvent 加 OVERLAPPED 的等待组合配置完之后起侦听线程。这里的关键是WaitCommEvent在异步句柄上会立刻返回 FALSE 并置ERROR_IO_PENDING必须配合GetOverlappedResult或等待事件对象才能拿到真正的结果。如果只判断返回值会误以为失败然后退出线程现象就是打开成功但一直接收不到数据。UINT __stdcall CSerialPort::CommThread(LPVOID pParam) { CSerialPort* pThis (CSerialPort*)pParam; // 64 位下必须用 DWORD_PTR 中转别用 DWORD OVERLAPPED ov { 0 }; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); // 只关心这几类事件掩码越小线程被唤醒的次数越少 SetCommMask(pThis-m_hComm, EV_RXCHAR | EV_ERR | EV_CTS | EV_DSR); while (true) { DWORD dwEvtMask 0; if (!WaitCommEvent(pThis-m_hComm, dwEvtMask, ov)) { if (GetLastError() ! ERROR_IO_PENDING) // 真失败才退出 break; } HANDLE hWait[2] { ov.hEvent, pThis-m_hShutdownEvent }; DWORD dwRet WaitForMultipleObjects(2, hWait, FALSE, INFINITE); if (dwRet WAIT_OBJECT_0 1) // 收到关闭信号 break; DWORD dwBytes 0; if (!GetOverlappedResult(pThis-m_hComm, ov, dwBytes, FALSE)) continue; // 被取消或出错重新等下一次 if (dwEvtMask EV_RXCHAR) pThis-OnReceive(); // 有数据去读 if (dwEvtMask EV_ERR) pThis-OnCommError(); // 校验错、帧错、溢出 } CloseHandle(ov.hEvent); return 0; }WaitForMultipleObjects同时等两个对象这是整个类里最漂亮的一笔一个等数据事件一个等关闭事件线程不用轮询关闭时也不用手起一个TerminateThread。TerminateThread在串口场景下是禁忌它不会释放线程栈和 TLS句柄泄漏只是时间问题。SetCommMask的掩码建议只开需要的。全开EV_ALL会让线程被 CTS、DSR 这些电平变化频繁唤醒在长线缆或者干扰大的现场唤醒频率高到能吃满一个核。3.4 收数据ClearCommError 查队列再 ReadFileOnReceive里第一件事不是读而是查队列里到底有多少字节。void CSerialPort::OnReceive() { DWORD dwError 0; COMSTAT cs { 0 }; if (!ClearCommError(m_hComm, dwError, cs)) // 顺便清掉错误标志否则会一直报错 return; if (cs.cbInQue 0) return; // 按实际字节数分配别用固定 256 的栈数组cbInQue 可能远大于它 BYTE* pBuf new BYTE[cs.cbInQue]; DWORD dwRead 0; if (ReadFile(m_hComm, pBuf, cs.cbInQue, dwRead, NULL) dwRead 0) { m_RxBuf.append(pBuf, dwRead); // 先进环形缓冲保证不丢 m_pfnOnReceive(dwRead); // 再通知业务层来取 } delete[] pBuf; }ClearCommError有两个作用取cbInQue接收队列里的字节数以及清除错误标志。如果不清EV_ERR会一直挂着线程被反复唤醒CPU 占用率飙升但一条数据都没有。m_pfnOnReceive是回调函数指针这是这套代码和业务解耦的方式串口类不知道你收到数据要干什么只负责把有 dwRead 字节到了这件事告诉你。回调里千万不能做耗时操作比如直接更新界面、写数据库、发 HTTP 请求。回调跑在侦听线程上一卡住串口就不读了硬件 FIFO 很快溢出丢数据就是这么来的。注意如果一定要在回调里更新 UI用PostMessage投递到主线程别用SendMessage后者是同步等待等于把主线程和串口线程绑在一起任何一边卡住都会互相拖死。3.5 写数据与关闭WriteFile、SetEvent 与线程回收写数据相对简单但有两个细节。一是异步句柄下WriteFile也可能返回ERROR_IO_PENDING必须调GetOverlappedResult等写完二是发送缓冲要设默认值偏小高波特率下容易堵。BOOL CSerialPort::WritePort(const BYTE* pData, DWORD dwLen) { OVERLAPPED ov { 0 }; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); DWORD dwWritten 0; BOOL bOk WriteFile(m_hComm, pData, dwLen, dwWritten, ov); if (!bOk GetLastError() ERROR_IO_PENDING) bOk GetOverlappedResult(m_hComm, ov, dwWritten, TRUE); // TRUE 阻塞等写完 CloseHandle(ov.hEvent); return bOk dwWritten dwLen; }关闭是这类库最容易翻车的地方。顺序必须是先让侦听线程退出再关通信句柄。反过来的话线程还阻塞在WaitCommEvent上主线程已经把句柄关了线程醒来拿一个野句柄去操作崩溃位置随机。void CSerialPort::ClosePort() { if (m_hComm INVALID_HANDLE_VALUE) return; SetCommMask(m_hComm, 0); // 清空事件掩码会让 WaitCommEvent 立即返回 SetEvent(m_hShutdownEvent); // 通知线程退出 if (m_hThread) { WaitForSingleObject(m_hThread, 3000); // 给 3 秒超时说明线程卡住了 CloseHandle(m_hThread); m_hThread NULL; } CloseHandle(m_hComm); m_hComm INVALID_HANDLE_VALUE; }SetCommMask(m_hComm, 0)这一行是老版本里缺的加上它之后关闭成功率明显提升WaitCommEvent会因为掩码变化立刻返回线程走到WaitForMultipleObjects看到退出信号干脆利落地结束。少了这一行线程可能一直等在WaitCommEvent上WaitForSingleObject白等三秒然后超时——用户看到的现象就是点了关闭按钮界面卡三秒。4. 跑起来编译配置与第一个回环测试前几章讲的是读代码该看哪儿这一章讲动手。验证一个串口类能不能用最快的方式不是接真实设备而是造一对虚拟串口自己发自己收把收发链路跑通之后再上硬件能把是类的问题和是设备的问题彻底分开。4.1 工程配置字符集、库依赖与预处理器如果是 MFC 工程直接把两个文件加进项目确认预处理器里有_AFXDLL字符集选 Unicode就可以编译。如果是纯 Win32 或者控制台工程先做三处替换CString换成std::wstring、TRACE换成OutputDebugStringW、_T()保留只要定义了UNICODE宏它就能用。用命令行编译一个最小验证程序大概是这样:: 在 VS 开发者命令提示符里执行编译 64 位版本 cl /nologo /EHsc /W4 /D_UNICODE /DUNICODE /MD SerialPort.cpp demo.cpp ^ /link user32.lib kernel32.lib参数逐个说/EHsc开标准异常处理/W4是警告级别指针截断会在这里暴露/D_UNICODE /DUNICODE两颗宏必须一起定义只定义一个是 Windows 编程里的经典错误会导致TCHAR和实际调用的 API 版本不匹配。/MD是动态链接 CRT如果主工程用的是/MT这里要跟着改混用会在释放内存时崩在堆管理器里。提示编译通过但链接报 LNK2019 找不到符号八成是字符集不一致——你的工程是 Unicode但SerialPort.cpp是按多字节编译的_T(...)展开成char*而CreateFile要的是wchar_t*。4.2 最小可运行调用先写一个只做三件事的 demo打开、发一串字节、收到后打印。不用界面控制台就行。#include SerialPort.h #include cstdio void OnDataArrived(DWORD dwLen) { // 回调跑在串口线程上这里只打印不做耗时操作 printf(recv %lu bytes\n, dwLen); } int main() { CSerialPort port; port.SetReceiveCallback(OnDataArrived); // 端口号 3波特率 9600无校验8 数据位1 停止位 if (!port.OpenPort(3, 9600, NOPARITY, 8, ONESTOPBIT)) { printf(open failed, err%lu\n, GetLastError()); return -1; } const BYTE data[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A }; port.WritePort(data, sizeof(data)); Sleep(2000); // 留两秒收数据 port.ClosePort(); return 0; }OpenPort的五个参数顺序是端口号、波特率、校验、数据位、停止位和 Win32 的BuildCommDCB字符串顺序一致用过mode com3:9600,n,8,1的人应该很熟。SetReceiveCallback是这一版修正里比较实用的一个接口老版本要么重载OnComm虚函数要么改源码里的回调指针前者要求业务类必须继承后者改一处影响全局。Sleep(2000)之后直接ClosePort这是最小 demo 的写法。真实项目里不能这么干得用信号量或者条件变量等数据到齐否则关得太早最后几个字节还在驱动缓冲里没读出来。4.3 没有硬件时怎么自测虚拟串口对加十六进制收发手边没有板子的时候装一个虚拟串口对驱动设备管理器里会多出一对互连的 COM 口比如 COM5 和 COM6一端给你的程序另一端给任意串口助手。这样发出去的每一个字节都能在助手里看到十六进制收进来的也能手动构造。自测顺序建议这样走程序打开 COM5串口助手打开 COM6两边波特率一致程序发01 03 00 00 00 0A助手看是否原样收到——验证发送链路和字节序助手手动发一串变长数据看回调被触发几次、每次dwLen是多少——验证帧边界助手连续发 10KB 数据看程序收到的总字节数是否等于 10240——验证不丢包程序里连点十次打开/关闭看是否有句柄泄漏任务管理器里看句柄数——验证关闭逻辑。第 4 步是最容易暴露问题的。很多人测的时候只发几个字节一切正常一上真实设备跑高频数据就开始丢因为小数据量根本触发不了缓冲区边界。第 5 步同理单次关闭看不出问题反复开关才会暴露线程没退干净。5. 避坑与排查打不开、收不到、关不掉的三类现场这一章按现象归类。串口问题的麻烦之处在于 API 不抛异常失败就是没反应唯一的线索是GetLastError()返回的一个数字。下面五条都是我或者身边人真实踩过的按现象 → 原因 → 解决写遇到同类问题可以直接对号入座。5.1 现象OpenPort 一直返回 FALSE先看错误码这是唯一可靠的分诊依据if (!port.OpenPort(7, 115200, NOPARITY, 8, ONESTOPBIT)) { DWORD dwErr GetLastError(); // 2 ERROR_FILE_NOT_FOUND端口名不对或端口不存在 // 5 ERROR_ACCESS_DENIED被别的进程占用 // 87 ERROR_INVALID_PARAMETERDCB 参数非法常见于波特率不支持 printf(open failed, err%lu\n, dwErr); }错误码 2 通常是端口号写错或者代码里没加\\.\前缀而端口号大于 9。错误码 5 是串口助手、另一个实例、或者某个后台服务还占着这个口关掉就好如果关不掉去设备管理器里看是不是有隐藏进程最粗暴的办法是拔插一次 USB 转接头让驱动重新枚举。错误码 87 比较少见一般是波特率填了一个驱动不支持的值比如921600在廉价 CH340 上就跑不了降到115200立刻正常。还有一类特殊现象OpenPort返回 TRUE但GetLastError()里残留着上次的错误码。这是因为CreateFile成功时不清错误码如果你在成功路径上也打了GetLastError会看到莫名其妙的数字别被它带偏。5.2 现象能打开但收不到数据或者数据只收到一半三种原因按出现频率排。第一种WaitCommEvent的返回值判断写错了。异步句柄下它返回 FALSE 是正常的此时GetLastError()是ERROR_IO_PENDING。如果代码写成if (!WaitCommEvent(...)) break;线程会立刻退出串口打开着但没人读。第二种接收缓冲用了固定长度。老代码里常见BYTE buf[256]; ReadFile(hComm, buf, 256, dwRead, NULL);当cbInQue是 1024 时一次只读 256剩下的留在驱动里如果此时没有新的EV_RXCHAR事件因为队列一直非空事件不会重复触发剩下的 768 字节就永远读不出来了。解决办法就是 3.4 节那样按cbInQue动态分配或者读完之后再查一次队列非空就继续读// 收到一次事件后循环读到队列为空避免漏读 while (true) { COMSTAT cs { 0 }; DWORD dwErr 0; ClearCommError(m_hComm, dwErr, cs); if (cs.cbInQue 0) break; // ... 读取 cs.cbInQue 字节 }第三种回调里做了重活。打印日志、更新控件、写文件任何一个耗时超过几毫秒串口线程就被占住硬件 FIFO通常只有 16 到 64 字节溢出数据在驱动层就丢了你在应用层怎么优化都找不回来。注意EV_RXCHAR的语义是接收队列从空变为非空不是每来一个字节触发一次。队列一直非空期间不会有新事件这也是必须循环读到空的原因。5.3 现象ClosePort 卡住三秒或者退出程序时崩溃卡住和崩溃其实是同一个根因的两种表现线程和句柄的关闭顺序错了。如果先CloseHandle(m_hComm)再等线程线程从WaitCommEvent返回时拿着一个已经失效的句柄GetOverlappedResult返回失败代码继续往下跑下一次循环再调WaitCommEvent就是未定义行为运气好返回错误运气不好直接访问违例。如果先SetEvent通知退出但不等线程结束就返回对象可能已经被析构比如串口对象是栈上的局部变量线程还在里面访问成员变量这是典型的 use-after-free崩溃堆栈看起来毫无规律。正确的顺序在 3.5 节已经给过清掩码 → 置退出事件 → 带超时等线程 → 关句柄。这里补一个诊断技巧如果WaitForSingleObject稳定超时说明线程没走到退出分支八成是卡在WaitCommEvent上检查SetCommMask(m_hComm, 0)有没有加上。还有一种更隐蔽的情况在OnComm回调里调用了ClosePort。这等于让线程等自己结束必然死锁。回调里只能置一个标志位真正的关闭交给主线程或者一个专门的管理线程。5.4 现象Unicode 工程下编译报 C2664或者中文显示成乱码C2664: 无法将参数 1 从 char * 转换为 LPCWSTR这是老代码迁移到 Unicode 工程时必现的错。原因是代码里写死了char数组而工程属性里字符集是 使用 Unicode 字符集。两种改法。想省事就把工程字符集改成使用多字节字符集能编译通过但这是倒退新代码不该这么干。正规做法是全量替换成TCHAR和_T()TCHAR szPort[16]; _stprintf_s(szPort, _T(\\\\.\\COM%d), nPortNo); // 跟着字符集自动切换字符串格式化也要跟着换sprintf换成_stprintf_sstrlen换成_tcslen。这一步做过一次之后后面加新功能就不会再被字符集绊住。5.5 现象高波特率下丢字节示波器上看数据是连续的波特率上到 460800 或 921600 之后开始丢数据低波特率完全正常这种问题最容易被误判成线太长或者干扰大。先排除软件原因。第一接收循环要读到队列为空见 5.2第二侦听线程优先级可以适当提到THREAD_PRIORITY_ABOVE_NORMAL注意是适当提到 TimeCritical 会影响整个系统的响应第三发送缓冲和接收缓冲要显式设置// 默认缓冲偏小高波特率下容易堵按需放大到 4KB 甚至 64KB SetupComm(m_hComm, 4096, 4096);如果软件层面都排除了再怀疑硬件。USB 转串口芯片的 FIFO 深度差异很大FT232 系列在 921600 下很稳几块钱的 CH340 跑 921600 本身就吃力这不是代码能解决的只能降速或者换芯片。线材也有影响TTL 电平在高速率下对线长很敏感超过半米就开始出问题。6. 二次开发把 CSerialPort 改成能进生产的样子这个类本身是能用的水平离能放心交付还差两件事接口耦合和协议分帧。直接改源码之前先在它外面包一层抽象接口把打开、关闭、读写定义成纯虚函数CSerialPort作为 Windows 平台的一个实现。这样做的好处是以后要上 Linux 或者换QSerialPort业务层一行都不用改。协议分帧是最花时间的一块。串口是字节流没有消息边界OnReceive拿到的dwLen只是这次读到了多少不是一帧有多长。常见做法是三层驱动层用ReadIntervalTimeout做粗切分缓冲层维护一个可增长的环形队列协议层用状态机按帧头、长度、校验逐字节解析。状态机里一定要有超时重置逻辑——收到半个帧之后设备掉线状态机会永远卡在等剩余字节下一次数据来了直接被当成上一帧的续包越解越乱。验证方法上我现在固定做三件事。第一写一个假的串口实现继承同一套抽象接口按脚本吐预设字节流包括粘包、半包、错帧、超长帧四种恶意输入跑单元测试。第二用虚拟串口对做端到端回环连续跑两小时统计收发字节数是否守恒。第三开关五百次看句柄数是否回到基线。这三步跑完再上真实设备能省掉大量在现场靠猜的时间。有个小技巧值得单独说给每次收发打上时间戳和方向标记落到一个环形日志里出问题时把最近 N 条 dump 出来。串口现场往往没有调试器日志是唯一的黑匣子。血泪经验是我早期图省事直接在OnComm回调里解析协议、写数据库、再回一条应答本地测试一切正常上了现场每天丢几十帧。查了两天才反应过来是回调阻塞导致的硬件层丢包。从那以后我每次接新的串口设备都强制先跑一遍虚拟串口回环加十六进制对照确认收发链路在不接业务的情况下字节完全守恒再往上叠协议。这份源码包里示例工程就能直接用来做这件事拿到手先跑通第 4 章的回环测试比一上来就改协议稳妥得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑