资讯动态

Windows下C++串口通信实战:从Win32 API到重叠I/O完整指南

发布时间:2026/9/9 2:35:22 来源:尧图企业网站定制
简介这是一份面向Windows平台C开发者的串口通信全代码资源围绕RS-232、RS-422、RS-485等常用串行接口标准完整实现全双工数据的接收与发送适合需要编写串口调试工具、学习串口编程或进行工业控制上位机开发的中级开发者。压缩包共51个文件约22.09MB涵盖h/cpp源码、rc资源脚本、vcxproj与dsp工程配置、exe可执行程序、动态库以及pdb调试符号等源码、工程与编译产物一应俱全。已有5259人学习下载。资源内含完整的串口调试助手工程界面图标与资源文件齐全不仅可直接运行exe查看串口收发效果还能参照串口封装库与对话框实现进行二次开发或结合调试日志与升级文档梳理Windows下串口API的调用流程对深入理解RS-232全双工通信机制和上位机开发非常有价值。 串口通信这事儿干嵌入式、工控、上位机开发的朋友基本都绕不过去。Windows上用C操作串口更是几十年的老活儿网上资料多但碎片化严重要么只贴几行代码不解释原理要么啰嗦半天连个能跑的工程都没有。我这两年调试过的串口设备不少从STM32、51单片机到各种传感器模块、VMware里的Linux虚拟机踩过的坑也算有代表性。这篇把Windows下C串口通信从原理到完整代码、从参数配置到实战排障一次性说清楚代码是能直接拷进工程编译的那种新手照做能跑通老手也能来核对下细节。1. 先把底层逻辑捋清楚串口在Windows眼里是什么很多人写串口程序第一反应是找第三方库但其实Windows把串口当作文件来处理直接用Win32 API就能搞定全部操作这样理解起来反而更简单。核心思路就四个APICreateFile负责打开串口ReadFile、WriteFile负责读写数据DCB结构体负责配置参数。说到底就是打开文件、读文件、写文件只不过这个文件有点特殊它是物理串口设备。1.1 串口通信的链路模型一条完整的串口通信链路从上到下大概是这样的上位机应用层 → 虚拟串口驱动或Win32串口API → UART控制器或USB转串口芯片 → 电平转换RS232/RS485/TTL → 线缆 → 对方设备的UART → 对方应用层。Windows C开发者打交道的是最上面两层。我们调CreateFile时传的设备路径是\\.\COM3这种Windows内核的串口驱动会负责把数据传递给底层的UART控制器。如果用的是USB转串口线中间还隔了一层USB驱动比如常见的CH340、CP2102、FT232其实也能被系统抽象成标准COM口上层代码不需要区分。理解了这层抽象你就明白为什么我们不用关心电平高低、起始位怎么触发这些硬件细节Windows串口API已经把物理层的活儿全包了。我们要做的只是打开串口、配好参数、收发数据。1.2 为什么用Win32 API而不是第三方库网上不少教程推荐用boost.asio的串口封装或者CSerialPort这类轮子。我的观点很直接如果你是老手用第三方库提升效率没问题如果你是新手或者要排查疑难杂症必须懂Win32 API。理由有三点。第一Win32 API是系统原生接口不依赖任何运行库编译出来的程序干干净净放到任何Windows机器上都能跑。第二串口通信出问题的时候你查官方文档、翻微软社区、搜Stack Overflow答案几乎全部基于Win32 API术语不懂这些你连问题都描述不清楚。第三CreateFile、ReadFile这些API在很多场景下通用你学会了串口后续做管道通信、文件读写也会顺手很多。2. 写代码之前的三个决策同步、异步还是线程轮询串口收发不像普通文件读写数据什么时候到是外部设备决定的你根本不知道下一秒会不会有数据。所以设计程序时必须先想清楚I/O模型不然后面必踩坑。2.1 同步I/O的致命问题同步模式最简单ReadFile一调用就阻塞线程直到收到数据才返回。但问题很明显如果你在主线程里同步读界面直接卡死如果另开一个线程同步读跟外部设备交互时主线程发命令、读线程等应答这是一个可行的方案但前提是通信协议简单、不会同时收发大流量数据。同步模式适合的场景一次性读一个固定长度的应答、设备只在收到指令后回复、不需要高频持续接收。比如发一条AT指令回来一个OK这种同步模式完全够用。2.2 重叠I/OOverlapped I/O才是正解真正建议用的是重叠I/O也就是异步模式。CreateFile时传入FILE_FLAG_OVERLAPPED标志ReadFile立刻返回不阻塞线程Windows内核在后台收数据收完了通过事件对象告诉你。这个模式有两个好处一是线程不阻塞你可以用同一个线程处理多个串口或者同时处理界面消息二是可以配合事件机制实现真正的数据到了才处理不需要循环轮询省CPU。难点是需要维护OVERLAPPED结构体和事件句柄代码比同步模式复杂一点但这点复杂度换来的是实打实的可靠性。2.3 我推荐的工程结构实际工程中我用的是一种组合方案主线程负责界面和业务逻辑单独开一个接收线程跑重叠I/O。接收线程只做两件事——等待事件、读数据。读到的数据丢进队列或者直接回调给上层。发送用同步WriteFile就行因为发送操作本身可控你不会连续发几万个字节不带停的同步写简单且不会出错。这个结构的核心代码如下// 接收线程循环 while (m_bRunning) { DWORD waitResult WaitForSingleObject(m_overlapped.hEvent, 200); if (waitResult WAIT_OBJECT_0) { DWORD bytesRead 0; GetOverlappedResult(m_hComm, m_overlapped, bytesRead, FALSE); if (bytesRead 0) { // 处理接收到的数据 ProcessRecvData(m_recvBuf, bytesRead); // 立刻发起下一次读取 ReadFile(m_hComm, m_recvBuf, sizeof(m_recvBuf), NULL, m_overlapped); } } }3. 完整代码一个可直接编译的串口通信C类这里直接给出一个我自己在用的封装没有第三方依赖编译环境是Visual Studio 2019及以上Windows 10/11都测过老系统理论也能跑。3.1 SerialPort.h接口声明#pragma once #include windows.h #include string #include functional class SerialPort { public: SerialPort() default; ~SerialPort() { Close(); } // 打开串口baudrate 为波特率例如 9600 bool Open(const std::string portName, DWORD baudRate 9600); void Close(); // 发送数据 bool Write(const char* data, DWORD len); // 接收回调函数收到数据后自动触发 void SetReceiveCallback(std::functionvoid(const char*, DWORD) callback) { m_callback std::move(callback); } private: HANDLE m_hComm INVALID_HANDLE_VALUE; HANDLE m_thread NULL; bool m_bRunning false; OVERLAPPED m_overlapped{}; char m_recvBuf[4096]{}; std::functionvoid(const char*, DWORD) m_callback; void RecvThread(); static DWORD WINAPI RecvThreadProc(LPVOID param) { auto* p static_castSerialPort*(param); p-RecvThread(); return 0; } };头文件里我预留了接收回调这样业务层不用关心接收线程细节收到数据就直接触发回调函数代码干净不少。m_recvBuf给4096字节一般串口设备数据量不会超过这个数如果你要接收大包数据可以调大但注意别设太小导致丢包。3.2 SerialPort.cpp核心实现#include SerialPort.h #include cstdio bool SerialPort::Open(const std::string portName, DWORD baudRate) { std::string path \\\\.\\ portName; // 1. 打开串口 m_hComm CreateFileA( path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, // 独占模式串口不支持共享 NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 关键重叠I/O NULL ); if (m_hComm INVALID_HANDLE_VALUE) { printf(打开串口失败错误码: %d\n, GetLastError()); return false; } // 2. 配置串口参数 DCB DCB dcb{}; dcb.DCBlength sizeof(DCB); if (!GetCommState(m_hComm, dcb)) { printf(获取串口参数失败\n); CloseHandle(m_hComm); m_hComm INVALID_HANDLE_VALUE; return false; } dcb.BaudRate baudRate; dcb.ByteSize 8; dcb.StopBits ONESTOPBIT; dcb.Parity NOPARITY; dcb.fBinary TRUE; dcb.fParity FALSE; dcb.fOutxCtsFlow FALSE; // 关闭硬件流控 dcb.fOutxDsrFlow FALSE; dcb.fDtrControl DTR_CONTROL_DISABLE; dcb.fDsrSensitivity FALSE; dcb.fTXContinueOnXoff TRUE; dcb.fOutX FALSE; // 关闭软件流控 dcb.fInX FALSE; dcb.fRtsControl RTS_CONTROL_DISABLE; if (!SetCommState(m_hComm, dcb)) { printf(设置串口参数失败错误码: %d\n, GetLastError()); CloseHandle(m_hComm); m_hComm INVALID_HANDLE_VALUE; return false; } // 3. 设置超时避免ReadFile卡死 COMMTIMEOUTS timeouts{}; timeouts.ReadIntervalTimeout 20; timeouts.ReadTotalTimeoutMultiplier 0; timeouts.ReadTotalTimeoutConstant 100; timeouts.WriteTotalTimeoutMultiplier 0; timeouts.WriteTotalTimeoutConstant 500; SetCommTimeouts(m_hComm, timeouts); // 4. 清空缓冲区避免残留旧数据 PurgeComm(m_hComm, PURGE_RXCLEAR | PURGE_TXCLEAR); // 5. 初始化重叠结构体启动接收线程 m_overlapped.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); m_bRunning true; m_thread CreateThread(NULL, 0, RecvThreadProc, this, 0, NULL); return true; } void SerialPort::Close() { m_bRunning false; if (m_hComm ! INVALID_HANDLE_VALUE) { SetCommMask(m_hComm, 0); SetEvent(m_overlapped.hEvent); // 唤醒阻塞中的线程 if (m_thread) { WaitForSingleObject(m_thread, 1000); CloseHandle(m_thread); m_thread NULL; } CloseHandle(m_overlapped.hEvent); CloseHandle(m_hComm); m_hComm INVALID_HANDLE_VALUE; } } bool SerialPort::Write(const char* data, DWORD len) { if (m_hComm INVALID_HANDLE_VALUE || data NULL || len 0) { return false; } DWORD bytesWritten 0; OVERLAPPED writeOverlapped{}; writeOverlapped.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); BOOL ret WriteFile(m_hComm, data, len, NULL, writeOverlapped); if (!ret GetLastError() ERROR_IO_PENDING) { WaitForSingleObject(writeOverlapped.hEvent, 1000); } GetOverlappedResult(m_hComm, writeOverlapped, bytesWritten, FALSE); CloseHandle(writeOverlapped.hEvent); return (bytesWritten len); } void SerialPort::RecvThread() { while (m_bRunning) { DWORD waitResult WaitForSingleObject(m_overlapped.hEvent, 200); if (waitResult WAIT_OBJECT_0) { DWORD bytesRead 0; BOOL ret GetOverlappedResult(m_hComm, m_overlapped, bytesRead, FALSE); if (ret bytesRead 0) { if (m_callback) { m_callback(m_recvBuf, bytesRead); } } // 立即发起下一次异步读取 memset(m_recvBuf, 0, sizeof(m_recvBuf)); ReadFile(m_hComm, m_recvBuf, sizeof(m_recvBuf), NULL, m_overlapped); } } }3.3 主函数测试用例#include SerialPort.h #include cstdio int main() { SerialPort port; if (!port.Open(COM3, 9600)) { printf(串口打开失败\n); return 1; } port.SetReceiveCallback([](const char* data, DWORD len) { printf(收到数据(%d bytes): , len); for (DWORD i 0; i len; i) { printf(%02X , (unsigned char)data[i]); } printf(\n); }); const char cmd[] {0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A}; port.Write(cmd, sizeof(cmd)); printf(已发送命令等待设备回复...\n); // 跑10秒后退出 Sleep(10000); port.Close(); printf(测试结束\n); return 0; }这个测试用例模拟的是常见的Modbus RTU读寄存器请求设备收到后正常会回复一帧数据。你根据自己的设备协议改发送内容就行。4. 这些细节决定你能不能稳定通信代码贴完了但很多人把代码跑起来依然收不到数据或者收一堆乱码问题就出在下面这些细节上。4.1 DCB参数设置的物理含义dcb.BaudRate 9600表示通信速率每秒9600比特也就是一比特持续时间约104微秒。波特率是通信双方事先约定好的A设备设9600发数据B设备设9600收数据才解析得了。如果你是9600发4800收对方看到的每一比特都会错位收到的就是乱码。ByteSize是数据位通常设置8位因为ASCII码是7位、Modbus RTU是8位设成8通用性最好。StopBits是停止位ONESTOPBIT即1位停止位绝大多数设备支持。Parity是校验位NOPARITY表示无校验。这三个参数加波特率就是串口通信最核心的四元组必须与对端设备完全一致。我把fOutxCtsFlow、fOutxDsrFlow、fOutX、fInX全关了意味着彻底关闭硬件流控和软件流控。流控的作用是防止数据溢出但实际使用中反而经常因为流控配置不当导致数据发不出去。比如CTS脚电平不对开了硬件流控后WriteFile可能一直等待。如果你确认对接设备没有使用RTS/CTS、DTR/DSR这类引脚做流控就全关掉这是我的经验之谈。4.2 超时参数与PurgComm的坑COMMTIMEOUTS结构体里最重要的两个参数是ReadIntervalTimeout和ReadTotalTimeoutConstant。我之前处理过一个设备它一帧数据是几十个字节但字节与字节之间的间隔偶尔会超过20毫秒。如果ReadIntervalTimeout设成20ReadFile可能在收到不完整的一帧时就返回了。后来把这个值调大到50毫秒问题解决。PurgeComm很多人会忽略但它非常关键。串口刚打开时缓冲区里可能有上一次会话残留的数据不清理的话第一次读就会读到一堆历史垃圾。清理函数调用很简单PurgeComm(m_hComm, PURGE_RXCLEAR | PURGE_TXCLEAR);4.3 RS232、RS485与TTL电平的区分这里必须强调一个新手最容易犯的错误。串口通信的物理层有好几种电平标准TTL电平是0V和3.3V/5V常用于单片机板子上的排针RS232电平是正负电压常用于工业设备和老式电脑的DB9接口RS485是差分信号用于长距离、多设备组网。如果你的设备是TTL电平排针直接插到电脑DB9串口上大概率烧毁引脚甚至设备。正确做法是通过USB转TTL模块连接比如常见的CP2102、CH340模块。而如果是RS232接口的设备接电脑串口用直连线接USB转串口线则需要注意交叉连接。我之前就见过一个案例某工程师买了个USB转RS232线插上设备后一直收不到数据排查半天发现是线序问题。后来换了根正确线序的线问题秒解决。这类硬件问题不是代码能解决的查的时候要扩大排查范围。5. 宿主机Windows与VMware Linux串口联调很多人搜串口通信是想让Windows宿主机与VMware里的Linux虚拟机通信这个场景我也验证过。核心思路是让VMware把串口设备桥接给虚拟机或者使用命名管道方式做数据转发。5.1 VMware串口映射的两种方法第一种方法直接把物理串口映射给虚拟机操作路径是虚拟机设置 → 硬件 → 添加 → 串行端口 → 选择使用物理串口。这样Linux虚拟机里看到的/dev/ttyS0就对应Windows宿主机的COM3。适用于宿主机有真实物理串口或者USB转串口线能被VMware直接穿透的情况。第二种方法用命名管道Named Pipe虚拟机设置里选择使用命名管道命名管道地址填\\.\pipe\com_1。宿主机程序通过CreateFile(\\\\.\\pipe\\com_1, ...)方式打开这个管道就可以跟Linux虚拟机的串口收发数据而Linux里对应配置通常用socat来转发。这个方法的好处是不需要有物理串口原理和串口几乎一样。5.2 联调时容易踩的坑命名管道方式中管道本身不检查波特率。如果你在宿主机里按9600发送数据Linux里socat按115200接收照样能通信但这不代表双方数据一致。正确做法是两边都配成相同的波特率参数注意如果用的裸管道两边波特率其实都被忽略数据以流的方式传送但上层协议可能依赖波特率做时序控制因此仍建议显式配一致。还有一个坑是设备占用冲突VMware把物理串口映射给虚拟机后宿主机程序就再也不能打开同一个COM口了。如果两边同时抢同一个串口会报错ERROR_ACCESS_DENIED错误码5。调试这种问题一定要先确认哪个程序占用了串口任务管理器里看进程或者用工具查句柄别盲目改代码。另外虚拟机里Linux串口通信还有个常见症状收数据时老丢第一个字节。这通常跟VMware串口驱动的FIFO缓冲区以及Linux侧termios设置有关。Linux下把/dev/ttyS0参数重新配置一下尤其关闭ICANON、ECHO这些默认行为几乎都能解决stty -F /dev/ttyS0 9600 raw -echo -echoe -echok6. 常见问题排查实录照着这张表排查能省半天时间串口调试最终拼的是排查问题的思路。我把实战中遇到的典型问题整理成速查表按现象、原因、解决方案列清楚你直接对号入座。6.1 串口打不开现象可能原因解决方案CreateFile返回错误码2串口号不存在打开设备管理器确认实际COM号返回错误码5串口被占用关闭VMware映射、其他串口工具或调换程序启动顺序返回错误码13权限不足以管理员身份运行程序这里面有个很多人不知道的点系统设备管理器显示的是COM10以上时代码里路径一定写成\\.\COM10不要省略\\.\前缀。COM1到COM9可以省略但统一带上前缀更稳妥。6.2 9600能通信4800收不到数据这个问题在热搜词里也出现了我来重点讲。如果你的设备在9600波特率下正常改成4800后收不到数据第一反应是检查配置是否真的生效了。有个隐蔽的坑是某些USB转串口芯片尤其CH340山寨版在低速下的时钟精度不够。仿真测试发现CH340在4800速率下如果主板USB控制器本身时基不稳可以产生数个百分点的波特率偏差。对端严格的UART在数据位起始沿判定时就会出错表现就是收不到数据。排查步骤用示波器或者逻辑分析仪量一下TXD引脚的电平宽度确认实际波特率跟理论值差多少。换一根短线、降低系统功耗负载再测试。把超时参数调长排除丢帧导致的数据不完整。如果硬件没法换程序上可以在4800模式下把超时和缓冲区加大再不行就用配套的驱动工具调整芯片的波特率修正系数。6.3 收到乱码乱码的排查顺序基本固定先确认两边波特率完全一样连小数位都不能差。再看数据位、停止位、校验位是否一致很多设备默认7位数据位、偶校验如果你用8位无校验去读肯定乱码。接着检查有没有打开流控软件流控开启后很多设备会把0x11、0x13当控制字符吃掉。最后才是代码问题。如果你处理数据时把有符号char当成无符号解析也可能导致打印出来的东西看着像乱码。我的建议是串口数据一律用unsigned char处理避免符号扩展。6.4 第一帧数据永远丢数据如果程序跑起来后永远丢第一个字节后面都正常基本可以断定是缓冲区没有清空或者接收时序有问题。PurgeComm要放在打开串口和配置完参数之后、启动接收线程之前。还有种情况是设备一上电就发一包状态数据你的程序还没来得及读取缓冲区就被新的数据覆盖了。解决方式是在收到设备主动上报的数据后先做一次空读ReadFile读出来丢弃再发请求指令。这种场景我遇到不止一次第一次跟某传感器模块联调时对方开机就发AA 55两个字节我当时没管结果每次发查询命令后读到的一帧数据里总是多了这两个字节。后来加了一次空读一切正常。7. 别忘了Windows串口还有这几个隐藏细节这里再补充三个日常开发中用得上、但多数教程不会提的小细节。第一个是ClearCommError函数。它可以实时查询串口发送缓冲区和接收缓冲区中有多少字节同时顺带清除通信错误。在调试阶段打印一下这个信息能快速判断数据到底有没有进到串口驱动层。正常流程是DWORD errors 0; COMSTAT comStat{}; ClearCommError(m_hComm, errors, comStat); // comStat.cbInQue 就是接收缓冲区中的字节数第二个是SetCommMask和WaitCommEvent配合注册事件。标准的做法是用WaitCommEvent监听EV_RXCHAR收到字符事件一旦检测到数据到达就触发处理。跟我前面写的重叠I/O方案相比这个方案适合那些不想一直挂ReadFile、想进一步降低CPU占用的场景。不过为了代码简单我的封装类里直接用重叠ReadFile就够了。第三个是Sleep时间别太短。串口通信不是USB通信没有即发即收的机制。两个字节之间的间隔至少是一个字符时间9600波特率下约1.04毫秒如果设备处理逻辑复杂一点要几毫秒甚至几十毫秒才回复也是正常的。你发完命令立即等数据等不到别急着以为是代码问题先看设备端的处理逻辑和硬件行为。8. 最后的实践经验分享串口通信说白了就四个字协议、时序。协议靠两边约定时序靠硬件和超时来保障。代码写清楚只是第一步真正麻烦的永远是对端设备的行为。我调试串口时养成一个习惯先把收到的原始数据用十六进制打印出来看而不是直接转字符串。很多设备回的是二进制帧直接转字符串会漏掉不可见字符排查问题时容易被误导。另外就是串口工具的配合使用。哪怕自己写了完整的串口调试程序我也经常开着第三方串口助手看数据流。这样能快速判断数据到底是设备没发出来还是我们自己的程序处理错了。先用串口助手确认链路通再查自己的代码逻辑这是效率最高的排查路径。最后再说一个小技巧如果你在虚拟机环境下做串口开发镜像文件里的串口配置和真实环境经常有细微差别。遇到虚拟机下程序能跑但实际设备上不工作的情况优先检查超时参数和事件对象这俩是最容易在环境迁移时被忽略的。代码可以直接用但跟硬件打交道的活儿关键是耐心和排查思路。希望这份总结能让你少走点弯路。本文还有配套的精品资源点击获取

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

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

免费获取报价