VC++串口十六进制收发全攻略:从打开串口到数据帧解析
简介面向VC开发者的串口通信示例工程演示如何通过指定串口完成十六进制数据的发送与接收。项目覆盖Windows平台串口编程核心流程使用CreateFile打开串口、SetCommState配置波特率与校验位、SetCommMask与WaitCommEvent监听事件以及ReadFile/WriteFile收发数据并专门处理十六进制字符串与字节流之间的转换。工程共19个文件以C源码为主包含6个.h头文件、5个.cpp源文件另附资源文件、工程文件.dsp/.dsw和说明文档压缩包仅24KB结构紧凑适合初学者快速搭建串口调试程序或嵌入到设备通信项目中。已有1338人学习下载可作为理解串口API应用和进制转换技巧的参考实现。1. 为什么VC串口通信绕不开十六进制先搞懂你要面对的是什么写一个VC串口收发程序看起来是件小事——打开串口、写字节、读字节三五个API调用就完事。但真正上手做十六进制收发时很多人第一版程序只在调试助手里点了半天“发送”用在线工具拼了拼接发命令等接到实际设备——比如一个温控模块、一个读卡器或者一台老式仪器——才发现收发数据怎么都对不上。串口通信真正的麻烦不在于API调用本身而在于字节流怎么拼、怎么切、怎么在超时和缓冲之间找到平衡。这篇文章把我在VC下做十六进制串口收发的完整套路拆开讲覆盖从打开串口、配置参数到收发数据的每个环节以及那些不跑一遍根本发现不了的坑。适合刚开始做串口工具的开发者也适合急需排查收发异常的熟手。2. 串口打开与参数配置CreateFile和DCB的正确写法2.1 打开串口的方式与权限CreateFile的四个参数怎么定VC操作串口的底层接口是Win32 API第一步是用CreateFile打开设备文件。串口在Windows里被抽象成文件但打开方式和普通文件有区别必须指定独占访问不能共享读写必须用OPEN_EXISTING而不是CREATE_ALWAYS路径名是\\.\COM3这种格式而不是COM3。HANDLE hComm CreateFile( L\\\\.\\COM3, // 端口名注意前缀 GENERIC_READ | GENERIC_WRITE, // 可读可写 0, // 独占模式串口不支持共享 NULL, // 安全属性默认即可 OPEN_EXISTING, // 串口必须用OPEN_EXISTING FILE_FLAG_OVERLAPPED, // 重叠I/O异步读写 NULL // 模板文件串口传NULL ); if (hComm INVALID_HANDLE_VALUE) { // 打开失败用GetLastError()查原因 DWORD err GetLastError(); // 常见ERROR_ACCESS_DENIED被占用ERROR_FILE_NOT_FOUND端口不存在 }打开字符串里的\\\\.\\是C转义后的写法实际传给API的是\\.\COM3。这个前缀告诉Windows后面跟的是设备名而不是文件名不加前缀在某些系统上会打开失败。FILE_FLAG_OVERLAPPED这个参数决定了后续读写是同步还是异步我习惯用重叠I/O这样接收线程可以等待事件而不是空转轮询。参数里没有指定收发缓冲区大小那是在打开之后单独设置的。2.2 DCB参数表波特率、数据位、校验位、停止位怎么配合打开串口之后配置通信参数核心是填充DCB结构体。很多人直接声明一个DCB然后往里面填值结果SetCommState失败原因通常是DCBlength没初始化或者没先用GetCommState拿当前值再修改。正确的做法是先Get后Set只改动需要改的字段。DCB dcb {0}; dcb.DCBlength sizeof(DCB); GetCommState(hComm, dcb); // 先拿当前配置 dcb.BaudRate 115200; // 波特率和设备端必须一致 dcb.ByteSize 8; // 数据位8 dcb.Parity NOPARITY; // 无校验 dcb.StopBits ONESTOPBIT; // 1位停止位 if (!SetCommState(hComm, dcb)) { // 配置失败检查波特率是否为枚举值奇偶校验是否匹配 }参数组合必须和设备端完全一致任何一个不匹配都会导致乱码或完全无响应。常用的组合除了115200-8-N-1还有9600-8-E-1偶校验、4800-7-O-1奇校验7数据位老式仪表里比较常见。我这里列一个常见参数对应表方便你对设备手册时核对参数组合适用场景注意事项115200, 8, N, 1绝大多数现代设备、USB转串口默认组合大部分调试助手默认值9600, 8, N, 1低速设备、长距离传输距离超过15米建议降波特率9600, 8, E, 1MODBUS RTU协议默认校验位常见于工业总线4800, 7, O, 1老式称重仪表、PLC7位数据位不常见容易踩坑配置完成后建议再用GetCommState读回来校验一次确认参数真的生效了。有一些USB转串口芯片对部分波特率支持不好比如某些芯片在设置的波特率不是标准值时会把数据发得乱七八糟读回来能帮你发现这类玄学问题。2.3 超时参数ReadFile为什么经常读不到数据先检查这里串口读写和普通文件读写最大的区别在于串口没有明确的“文件结尾”你无法知道设备什么时候停止发数据。所以ReadFile需要配合COMMTIMEOUTS超时设置否则会一直阻塞或者立刻返回0字节。默认的超时参数几乎不可用必须自己设置。COMMTIMEOUTS timeouts {0}; timeouts.ReadIntervalTimeout 50; // 字符间最大间隔50ms timeouts.ReadTotalTimeoutMultiplier 10; // 每读一个字节乘10ms timeouts.ReadTotalTimeoutConstant 100; // 额外固定100ms SetCommTimeouts(hComm, timeouts);这三个参数决定了ReadFile的行为模式。ReadIntervalTimeout是“两个字节到达之间的最大间隔”如果设备分批发数据间隔超过这个值ReadFile就会返回已读到的部分。ReadTotalTimeoutMultiplier和Constant合起来算出总超时上限。如果ReadIntervalTimeout设为MAXDWORD且另两个为0则ReadFile只读缓冲区里已有的数据立刻返回。我一般把间隔超时设在20到50毫秒之间接收线程能及时处理数据又不会因为设备发送稍慢就被超时切断。3. 十六进制发送从文本指令到字节流的完整链路3.1 十六进制字符串解析去掉空格和‘0x’前缀再转字节很多串口工具的界面让用户输入十六进制文本比如“01 03 00 00 00 02 C4 0B”程序要把这串文本转成9个字节再发送。这里最大的坑是格式不统一有的输入带空格有的不带有的用户喜欢写“0x01”有的写“01”还有人中间混了几个英文字母大小写。解析函数要容忍这些差异。// 把十六进制字符串转成字节数组支持空格和0x前缀 int HexStringToBytes(const char* hexStr, BYTE* outBytes, int maxLen) { int byteCount 0; const char* p hexStr; while (*p byteCount maxLen) { // 跳过空白字符 while (*p || *p \t) p; // 跳过0x或0X前缀 if (p[0] 0 (p[1] x || p[1] X)) p 2; // 解析两位数 int hi HexCharToVal(p[0]); int lo HexCharToVal(p[1]); if (hi 0 || lo 0) break; // 非法字符停止解析 outBytes[byteCount] (BYTE)((hi 4) | lo); p 2; } return byteCount; }这里解释两个要点。一是每两个十六进制字符拼一个字节所以要写成(hi 4) | lo这是十六进制转字节的标准公式。二是要跳过0x前缀和空格因为用户手动输入的文本里这两种情况几乎必然出现。HexCharToVal是个小工具函数把0-9、a-f、A-F映射到数值非法字符返回-1。解析出错时不要直接返回0最好记录一下错误位置方便界面上提示用户“第几个字符有误”。3.2 WriteFile发送的完整流程重试、清缓冲和错误码字节拼好之后用WriteFile写进串口。但直接调WriteFile并不够稳妥因为串口驱动有内部的输出缓冲区可能还在发上一条指令新数据就堆积了。在发送前用PurgeComm清空缓冲区能避免操作太快时新旧数据混在一起。重叠I/O模式下WriteFile可能返回ERROR_IO_PENDING表示写入还在进行需要等它完成。BOOL WriteData(HANDLE hComm, const BYTE* data, DWORD len) { // 发前先清空发送缓冲避免上一条指令残留 PurgeComm(hComm, PURGE_TXABORT | PURGE_TXCLEAR); OVERLAPPED ov {0}; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); DWORD written 0; BOOL ok WriteFile(hComm, data, len, written, ov); if (!ok) { DWORD err GetLastError(); if (err ERROR_IO_PENDING) { // 异步写入还没完成等待它结束 ok GetOverlappedResult(hComm, ov, written, TRUE); } } CloseHandle(ov.hEvent); return ok (written len); }PURGE_TXABORT是中止正在进行的写操作PURGE_TXCLEAR是清空输出缓冲区。这两个为什么一前一后组合TXCLEAR只能清还没发出去的数据如果驱动正在发送一个不完整的帧只清缓冲没用发送中途的数据可能已经被发出去了所以要先TXTABORT中止再清除。写完后检查written是否等于len有些情况下WriteFile返回成功但只写了一部分字节这种情况虽然少见但协议上必须当成发送失败处理。3.3 一个完整发送例子以MODBUS RTU读取指令为例把前面两部分合起来看一个完整场景给一个MODBUS RTU温控器发送读取保持寄存器的指令设备地址0x01功能码0x03起始寄存器0x0000读2个寄存器CRC校验C4 0B。BYTE cmd[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B }; if (WriteData(hComm, cmd, sizeof(cmd))) { // 发送成功接下来进入接收流程 // 注意不要立刻ReadFile部分设备处理需要时间 }这里有一个很容易忽略的点有些设备收到指令后要几十毫秒才返回数据发送完立刻读会只读到空缓冲。解决方案有两种要么发送后Sleep一小段再读要么依赖接收事件的超时机制。我选择后一种用WaitCommEvent挂在接收事件上数据到了自然触发没数据就一直等不会浪费CPU也不怕设备响应慢。MODBUS RTU的帧边界靠3.5个字符时间的静默来切分数据间隔超过这个时间就认为一帧结束这正好和前面设的ReadIntervalTimeout配合起来。4. 十六进制接收线程、缓冲区与帧边界判断4.1 用WaitCommEvent做事件驱动接收还是开线程轮询接收串口数据有几种方案用ReadFile死循环加超时轮询、用WaitCommEvent等事件通知、用重叠I/O加事件对象完成回调。我最终常套用的是WaitCommEvent加一个独立接收线程理由有三个不阻塞主界面消息循环、能及时响应数据到达、代码结构清晰容易加协议解析逻辑。DWORD WINAPI ReceiveThread(LPVOID param) { ThreadParam* tp (ThreadParam*)param; HANDLE hComm tp-hComm; OVERLAPPED ov {0}; ov.hEvent CreateEvent(NULL, TRUE, FALSE, NULL); BYTE buffer[1024] {0}; DWORD bytesRead 0; // 串口事件掩码收到字符就触发 DWORD eventMask 0; SetCommMask(hComm, EV_RXCHAR); while (!tp-stopFlag) { // 等待串口事件阻塞等待 if (!WaitCommEvent(hComm, eventMask, ov)) { if (GetLastError() ERROR_IO_PENDING) { // 真正等待直到有事件或超时 DWORD waitResult WaitForSingleObject(ov.hEvent, 200); if (waitResult ! WAIT_OBJECT_0) continue; } } // 有数据到达立刻读取 if (eventMask EV_RXCHAR) { if (ReadFile(hComm, buffer, sizeof(buffer), bytesRead, NULL)) { if (bytesRead 0) { // 把bytesRead个字节交给协议解析层 OnDataReceived(buffer, bytesRead); } } } } return 0; }WaitCommEvent的重叠事件对象必须在每个循环里重用但每次WaitCommEvent调用之前如果上个事件还没处理完要先ResetEvent再继续。上面代码里有个隐藏细节WaitCommEvent失败返回ERROR_IO_PENDING时用WaitForSingleObject等事件对象超时设为200毫秒是为了让循环能定期检查stopFlag实现线程退出否则WaitCommEvent会一直阻塞到天荒地老线程退不干净。4.2 收到的字节怎么格式化显示成十六进制文本收到的是原始字节数组要显示成01 03 04 00 1E 00 1E 43这种带空格的十六进制文本格式化函数要注意两点每个字节必须输出两位不足补零字节之间用空格分隔方便人工读取和复制回发送框。直接printf(%x)是新手最常犯的错误。// 字节数组转十六进制显示字符串 void BytesToHexString(const BYTE* data, int len, char* out, int outLen) { int pos 0; for (int i 0; i len pos outLen - 3; i) { // 用0123456789ABCDEF查表拼字符避免格式化开销 out[pos] g_hexDigits[(data[i] 4) 0x0F]; // 高四位 out[pos] g_hexDigits[data[i] 0x0F]; // 低四位 if (i len - 1) out[pos] ; } out[pos] \0; }这里用查表法而不是sprintf是因为接收循环里可能每几十毫秒就格式化一次查表比格式化快得多。data[i] 4取高四位 0x0F取低四位这是字节拆两个十六进制字符的标准写法。显示字符串的长度是字节数的3倍减1每个字节2个字符加1个空格所以OutLen参数要按这个计算防止缓冲区溢出。界面上的显示框建议用等宽字体几十个字节排下来能对齐人工读数据时不会看花眼。4.3 粘包和半包一个读卡器的帧怎么切才算完整接收数据时最常见的协议问题就是粘包和半包。设备连续发送多条响应时ReadFile可能一次读回好几帧拼在一起设备把一帧分成几段发送时ReadFile一次只读到半个帧。解决思路是建立一个累积缓冲区把每次读到的数据追加进去然后按协议帧格式从缓冲区里解析。struct ReceiveBuffer { BYTE data[4096]; int len; }; // 每收到一批新数据就调用一次尝试从缓冲中切出完整帧 void OnDataReceived(const BYTE* chunk, int chunkLen) { // 1. 追加到累积缓冲区 memcpy(g_rxBuf.data g_rxBuf.len, chunk, chunkLen); g_rxBuf.len chunkLen; // 2. 循环查找完整帧这里以帧头长度字段为例 while (g_rxBuf.len 4) { // 至少能看头长度 // 假的协议帧头0xAA长度在第二个字节 if (g_rxBuf.data[0] ! 0xAA) { // 没找到帧头丢弃一个字节继续找 memmove(g_rxBuf.data, g_rxBuf.data 1, g_rxBuf.len - 1); g_rxBuf.len--; continue; } int frameLen g_rxBuf.data[1] 2; // 长度位是数据长度加上头和尾 if (g_rxBuf.len frameLen) { // 凑够一帧提取出来交给上层处理 ProcessFrame(g_rxBuf.data, frameLen); // 已处理的部分从缓冲移除 memmove(g_rxBuf.data, g_rxBuf.data frameLen, g_rxBuf.len - frameLen); g_rxBuf.len - frameLen; } else { // 半包数据还没到齐退出等下一批 break; } } }这里的核心逻辑分三步追加、按帧头定位、按长度字段判断是否凑够。没找到帧头时逐字节丢弃是考虑到可能数据中间混进了随机噪声丢掉一个字节再重新对齐。凑不够长度时跳出循环继续等下一批数据。用memmove而不是memcpy是因为源和目的区域可能重叠memmove能正确处理。这个累积缓冲的思路比每次ReadFile试图精确读一帧要可靠得多因为ReadFile到底读多少字节取决于驱动、缓冲大小和超时设置你控制不了。5. 串口收发的常见坑与排查现象、原因、解决5.1 打开串口失败被占用、权限不足和COM号超过9现象CreateFile返回INVALID_HANDLE_VALUEGetLastError返回ERROR_ACCESS_DENIED或ERROR_FILE_NOT_FOUND。原因和解决分三种串口被其他程序占用比如还在开着调试助手没关解决就是关掉占用程序或者换一个COM号权限不足程序没以管理员身份运行而串口驱动要求较高权限解决是提升程序权限COM号超过9的时候路径必须写成\\.\COM10而不是COM10否则系统无法识别。注意如果你的设备在设备管理器里显示COM32而程序里只写了COM32 Windows会把前面的COM解析成设备类别而不是端口名必须写\\.\COM32。 这是很多人打开高COM号端口失败的直接原因。5.2 设备回的数据变成乱码先查配置再查接线现象发送指令正确但设备回复的内容是一堆奇怪字符或者完全对不上期望的值。原因排查顺序很重要第一查波特率是否匹配设备手册写9600而程序设了115200那一定乱码第二查数据位和校验位8E1和8N1看着差不多但设备端不支持偶校验时收发就是不稳定第三查物理接线TXD和RXD交叉接反了或者共地没接上。如果程序配置全对但依然乱码用逻辑分析仪或者示波器看波形看起始位和数据位宽度是否符合当前波特率。很多时候USB转串口芯片质量参差不齐在高速率下波形畸变明显把波特率降低一档就好了。5.3 接收线程退出时程序崩溃清理顺序别搞反现象程序关闭时提示访问违规或者串口设备没有正常释放下次打开失败。原因是接收线程还在WaitCommEvent阻塞时主线程直接CloseHandle关了串口句柄线程内还在用这个句柄崩溃不可避免。解决是先用标志位通知线程退出等线程结束再关句柄。// 正确关闭顺序 tp-stopFlag TRUE; // 1. 设置停止标志 SetCommMask(hComm, 0); // 2. 清除事件掩码让WaitCommEvent返回 WaitForSingleObject(hThread, 1000); // 3. 等线程退出 CloseHandle(hComm); // 4. 最后关闭串口句柄 // 5. 关闭线程句柄和事件句柄SetCommMask(hComm, 0)这步很关键它让正在等待的WaitCommEvent因掩码变化而返回线程就能及时退出。顺序不能乱先关串口句柄再等线程是多数崩溃的根源。5.4 PurgeComm和FlushFileBuffers用错导致指令丢失现象程序连续快速发送几条指令设备只响应了其中一部分另一部分像是没收到。原因可能是发送完立刻PurgeComm把还没发完的数据清掉了或者反过来FlushFileBuffers等待缓冲区清空导致阻塞。解决是区分这两个API的使用场景PurgeComm用于取消待处理的操作和清除缓冲区一般在改变通信状态或者发送新指令前用FlushFileBuffers用于等待发送缓冲区清空并且数据全部发出通常用在需要确认指令已发出时。发送关键指令时用FlushFileBuffers确认发出发送前不要PurgeComm清掉缓冲区里的数据。6. 让串口程序更稳数据校验、日志和状态机写串口程序做到“能收发”容易做到“稳定”需要额外三层功夫数据校验、日志记录、状态机处理。校验方面工业协议的CRC16或CRC32不能靠接收时临时算要在发送前就算好拼进帧里接收时也校验一遍校验不一致就丢弃重传。日志方面我习惯把每次收发的原始字节和时间戳写到本地文件程序对不上问题时翻日志比反复抓包快得多。这里有一个具体的做法每次OnDataReceived收到数据同时写一条带毫秒时间戳的日志格式就是[1234567] TX 01 03 00 00 00 02 C4 0B用write追加不占用主线程。收尾再讲一个我一直在用的习惯从写第一行代码开始就把串口数据当成“无边界字节流”而不是“正好一帧一帧的数据”所有帧的切分都靠状态机或累积缓冲区来做而不是依赖ReadFile的一次返回值。这个习惯帮我省了很多排查粘包的时间。串口程序最终稳定与否往往不取决于协议多复杂而在于边界条件和清理顺序有没有做好。希望这篇笔记能帮你少走几段弯路。本文还有配套的精品资源点击获取