简介这是一份面向Windows平台C开发者的RS485串口通信入门与实战参考代码基于MFC类库实现适合工业自动化、物联网设备等场景下需要完成设备间数据传输的开发者学习。资源以Visual Studio 2015为开发环境围绕串口对象创建、参数配置、打开关闭、数据读写等核心环节给出可运行示例帮助读者理解RS485半双工、多点通信的基本用法。压缩包共22个文件约52KB包含4个h头文件、3个cpp源文件以及sln解决方案、vcxproj工程文件、rc资源脚本、ico图标和txt说明文档覆盖从工程配置到界面资源的完整结构导入VS2015后可直接编译运行。目前已有3199人学习下载。通过研究这份Demo读者可以快速掌握MFC串口通信的代码组织方式并在此基础上扩展错误处理、数据校验与通信协议为实际项目提供参考。1. 基于 C MFC 的 RS485 串口通信 Demo从能跑到敢上产线的那点事工控上位机这行有个很典型的现象USB 转 485 模块插上串口助手能收发自己用 C MFC 写的程序一跑就翻车——要么打不开串口要么发出去没回要么跑一晚上第二天发现数据全错位。基于 C MFC 的 RS485 串口通信 Demo 完整版代码解决的正是这个从「工具能通」到「程序能稳」的落差。它面向的是做产测工装、PLC 数据采集、仪表轮询这类上位机的开发者核心不是教你串口是什么而是把 MFC 这套老框架下打开串口、配置 485 半双工、收发线程、超时重试、CRC 校验这一整条链路讲透。RS485 本身是电气层标准真正决定成败的是半双工方向控制、帧边界识别和异常恢复这三点在 Demo 里必须落地成代码否则就是玩具。2. 先把 RS485 和 MFC 串口这两件事拆开看2.1 RS485 是电气标准不是协议很多人一上来就找「RS485 协议」这是个常见误解。RS485 只规定了差分信号、共模范围、驱动能力和多点总线拓扑它不规定波特率、不规定帧格式、更不规定你发什么字节。真正让设备之间能对话的是跑在 485 物理层之上的应用层约定比如常见的 Modbus RTU或者某台仪表自己定义的私有帧。这意味着两件事。第一你的上位机代码里串口参数波特率、数据位、停止位、校验位必须和从站设备严格一致差一个都不行。第二帧的起始、长度、结束、校验方式全部由设备手册决定不能想当然。我见过有人拿 Modbus RTU 的 3.5 字符间隔去套一台私有协议的称重仪表结果永远收不全血泪经验就是先读手册再写代码。RS485 是半双工总线同一时刻只能有一个节点在发送。上位机作为主站通常要主动控制收发方向。这里就引出第一个关键硬件点你的 USB 转 485 模块是自动收发还是手动收发。自动收发的模块多数廉价模块靠硬件检测发送数据自动切换方向省事但时序不可控手动收发的模块需要你操作 RTS 或 DTR 引脚来切换。MFC 里能不能控制 RTS取决于串口驱动和模块这一点在选型阶段就要确认否则代码写完发现方向切不了只能换模块。2.2 MFC 串口的三条技术路线在 MFC 里做串口常见有三条路选错路线后面全是坑。第一条是 MSComm 控件。这是 VB 时代的 ActiveX 控件MFC 里插入后能用但它是基于消息的事件模型老旧高波特率下丢事件、卡 UI 是常态而且部署时要注册 ocx现在很多环境已经不带它了。除非维护老项目新写不建议。第二条是 Windows API 直接操作。用 CreateFile 打开\\.\COMxSetCommState 配参数SetCommTimeouts 设超时ReadFile/WriteFile 收发配合 OVERLAPPED 做异步。这条路最底层、最可控也是我一般会推荐的方案因为 485 半双工对时序敏感只有自己掌控读写节奏才踏实。第三条是第三方库比如常见的跨平台串口库。它们封装了 API跨平台方便但在 Windows 下控制 RTS 切换、精细设置超时有时反而绕。做纯 Windows 工控上位机直接用 API 更直接。下面这张表是我选型时的判断依据路线方向控制能力高波特率稳定性部署复杂度适用场景MSComm 控件弱差需注册 ocx维护老项目Windows API强好无依赖新项目首选第三方库中好带 dll跨平台需求2.3 打开串口的最小可用代码先给出用 API 打开并配置串口的核心片段这是整个 Demo 的地基。// 打开串口注意 COM 口大于 9 时必须用 \\.\ 前缀 HANDLE OpenSerialPort(const CString portName, DWORD baudRate) { CString fullName; fullName.Format(_T(\\\\.\\%s), portName); // COM10 以上必须这样写 HANDLE hComm CreateFile( fullName, GENERIC_READ | GENERIC_WRITE, 0, // 串口必须独占共享模式填 0 NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // 异步模式 NULL); if (hComm INVALID_HANDLE_VALUE) return INVALID_HANDLE_VALUE; DCB dcb { 0 }; dcb.DCBlength sizeof(DCB); if (!GetCommState(hComm, dcb)) { CloseHandle(hComm); return INVALID_HANDLE_VALUE; } dcb.BaudRate baudRate; dcb.ByteSize 8; dcb.Parity NOPARITY; dcb.StopBits ONESTOPBIT; dcb.fBinary TRUE; dcb.fRtsControl RTS_CONTROL_ENABLE; // 手动收发模块靠这里控制方向 if (!SetCommState(hComm, dcb)) { CloseHandle(hComm); return INVALID_HANDLE_VALUE; } // 设置超时读超时是 485 轮询的关键 COMMTIMEOUTS timeouts { 0 }; timeouts.ReadIntervalTimeout 50; // 字节间隔超时 timeouts.ReadTotalTimeoutMultiplier 0; timeouts.ReadTotalTimeoutConstant 200; // 整帧读超时 timeouts.WriteTotalTimeoutConstant 200; SetCommTimeouts(hComm, timeouts); return hComm; }逻辑说明CreateFile 用FILE_FLAG_OVERLAPPED打开异步句柄是为了后面用 WaitCommEvent 或重叠 IO 做非阻塞收发避免 UI 线程被 ReadFile 卡死。DCB 里fRtsControl设成RTS_CONTROL_ENABLE是给手动收发模块用的发送前置位、发送后清位就能切方向。超时参数是重点ReadIntervalTimeout决定两个字节之间隔多久算一帧结束这个值设太小会把一帧拆成两段设太大响应变慢200 毫秒整帧超时对多数仪表够用。参数说明波特率常见 9600、19200、115200必须和设备一致数据位多数 8校验位看设备Modbus RTU 常用偶校验或无视校验停止位多为 1。这几个参数错一个收到的就是乱码或干脆没反应。3. 半双工收发与帧解析Demo 真正难的地方3.1 方向切换的时序控制485 半双工最容易被忽略的就是方向切换。自动收发模块你管不了手动模块你必须自己切。典型流程是拉高 RTS 进入发送态WriteFile 把数据推出去等数据真正从移位寄存器发完再拉低 RTS 回到接收态。问题在于「等数据发完」这一步。WriteFile 返回只代表数据进了驱动缓冲区不代表已经上了总线。如果立刻切回接收最后几个字节可能被自己截断。稳妥做法是发送后调用 FlushFileBuffers 或者按波特率估算发送耗时再延时。// 发送一帧并切回接收态 BOOL SendFrame(HANDLE hComm, const BYTE* data, DWORD len) { // 进入发送态 EscapeCommFunction(hComm, SETRTS); DWORD written 0; if (!WriteFile(hComm, data, len, written, NULL) || written ! len) { EscapeCommFunction(hComm, CLRRTS); return FALSE; } // 等待数据真正发出115200 下每字节约 87us留足余量 FlushFileBuffers(hComm); DWORD waitMs (DWORD)(len * 10000 / 115200) 2; Sleep(waitMs); // 切回接收态 EscapeCommFunction(hComm, CLRRTS); return TRUE; }逻辑说明EscapeCommFunction 的 SETRTS/CLRRTS 直接操作 RTS 引脚这是手动收发模块切方向的入口。FlushFileBuffers 保证写缓冲清空Sleep 再按波特率给一个保守延时。参数上len * 10000 / 115200是把字节数换算成毫秒的粗略公式加 2 毫秒余量防止边界情况。注意如果你的模块是自动收发的这段 RTS 操作可能无效甚至干扰需要先确认模块类型。判断方法很简单发数据时用示波器看 RTS 引脚有没有变化或者直接问模块供应商。3.2 帧边界识别超时法还是长度法485 上收到的是一串连续字节流怎么知道一帧从哪开始到哪结束是解析的核心。常见两种方法。超时法靠字节间隔判断。Modbus RTU 规定帧间隔大于 3.5 个字符时间就算新帧。实现上就是设ReadIntervalTimeout读到间隔超时就认为一帧结束。优点是通用缺点是波特率低时 3.5 字符时间较长响应慢而且不同设备对间隔要求不一样。长度法如果协议帧长固定或者帧头里带长度字段直接按长度读。优点是精确快速缺点是不通用换设备要改代码。我一般会两者结合先按帧头匹配再按长度字段读最后用超时兜底。下面是一个带帧头识别和 CRC 校验的接收循环。// 接收线程按帧头 0xAA 0x55 识别长度字段在第三字节 void ReceiveThread(HANDLE hComm, CListCtrl* pList) { BYTE buf[512]; DWORD read 0; BYTE frame[512]; int frameLen 0; bool inFrame false; while (g_bRunning) { if (!ReadFile(hComm, buf, sizeof(buf), read, NULL) || read 0) { Sleep(10); continue; } for (DWORD i 0; i read; i) { BYTE b buf[i]; if (!inFrame) { // 找帧头 if (b 0xAA) { frame[0] b; frameLen 1; inFrame true; } } else { frame[frameLen] b; // 收到第二字节确认帧头 if (frameLen 2 frame[1] ! 0x55) { inFrame false; frameLen 0; continue; } // 第三字节是长度收满整帧 if (frameLen 3) { int expect frame[2] 4; // 头2 长度1 数据 CRC1 if (frameLen expect) { if (CheckCRC(frame, expect)) { DispatchFrame(frame, expect, pList); } inFrame false; frameLen 0; } } } } } }逻辑说明状态机先找帧头 0xAA再确认 0x55然后读长度字段算出整帧长度收满后做 CRC 校验通过才分发。这样即使中间丢字节或混入噪声也能靠帧头重新同步。参数上frame[2] 4里的 4 是帧头两字节加长度一字节加 CRC 一字节具体要按你的协议调整。3.3 CRC 校验不能省工控现场电磁干扰大485 线又常和动力线走一起收到错帧是家常便饭。CRC 校验是最后一道防线省了它错误数据直接进数据库后面排查能让你怀疑人生。Modbus 用的是 CRC16多项式 0xA001实现如下。// Modbus CRC16低字节在前 WORD CalcCRC16(const BYTE* data, int len) { WORD crc 0xFFFF; for (int i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }逻辑说明标准 Modbus CRC16 算法初始值 0xFFFF逐字节异或后右移最低位为 1 时异或多项式 0xA001。返回值的低字节在前、高字节在后拼帧时注意顺序。参数上如果你的设备用的是 CRC8 或校验和替换这个函数即可接口保持一致。4. 避坑与排查那些让 Demo 跑不起来的细节4.1 串口打不开报「拒绝访问」现象CreateFile 返回 INVALID_HANDLE_VALUEGetLastError 是 5拒绝访问。原因串口被其他程序占用最常见的是串口助手没关或者上一次程序异常退出没释放句柄。串口是独占资源同一时刻只能一个进程打开。解决先确认没有其他程序占用任务管理器里看有没有残留进程。程序里加异常处理退出时确保 CloseHandle。调试阶段可以在打开失败时提示具体 COM 号别让用户猜。4.2 发出去有回但全是乱码现象能收到数据但字节全不对或者偶尔对偶尔错。原因九成是串口参数不匹配。波特率差一点、校验位设错、停止位不对都会导致采样错位。还有一种情况是 485 模块的 A/B 线接反差分信号极性反了收到的就是反相数据。解决先用串口助手用同样参数验证设备确认参数无误。再检查 A/B 接线多数模块标了 A B-接反了换过来。如果参数和接线都对量一下总线终端电阻长距离时两端各 120 欧姆不能少。4.3 高波特率下丢数据现象115200 下跑一段时间偶尔丢几帧或者 UI 卡顿。原因在 UI 线程里同步 ReadFile或者接收缓冲区太小。同步读会阻塞消息循环缓冲区小则高流量时溢出。解决接收放到独立线程用异步 IO。同时调大驱动接收缓冲区用 SetupComm 设置。UI 更新用 PostMessage 回主线程别在接收线程里直接操作控件。// 调大驱动缓冲区减少高波特率下的溢出 SetupComm(hComm, 4096, 4096); // 收 4K发 4K4.4 长时间运行后帧错位现象程序跑几小时后解析开始错位重启就好。原因接收缓冲区里残留了半帧数据或者噪声字节混入后状态机没复位。超时法在低波特率下如果间隔设得太小一帧被拆开状态机就乱了。解决状态机加超时复位超过一定时间没收到完整帧就清空重新找帧头。接收前先 PurgeComm 清空缓冲区。下面这行在每次轮询前调用能清掉残留。PurgeComm(hComm, PURGE_RXCLEAR | PURGE_TXCLEAR);4.5 多设备轮询时响应串台现象总线上挂多个从站回复的帧对不上号。原因485 是总线所有从站都能收到主站请求如果两个从站地址配成一样或者某个从站异常一直发数据就会串台。解决确保每个从站地址唯一轮询时严格一问一答发完请求等回复超时再发下一个。别用广播地址去读数据。总线上加终端电阻线长超过 100 米降波特率。5. 让 Demo 更接近产线的两个进阶技巧5.1 用状态机管理轮询任务Demo 能收发只是起点产线上要的是稳定轮询几十台设备。我一般会用一个轮询状态机把「发请求、等回复、超时重试、记录结果」串起来。核心是一个任务队列每个任务带从站地址、功能码、期望长度、重试次数。状态机在 IDLE、SENDING、WAITING、TIMEOUT 之间流转超时自动重试重试 N 次失败就标记该设备离线继续下一个不让一台坏设备拖垮整个轮询。// 轮询任务结构 struct PollTask { BYTE slaveAddr; BYTE funcCode; WORD regAddr; int retry; DWORD lastSendTick; }; // 状态机主循环片段 void PollLoop() { while (g_bRunning) { switch (g_state) { case IDLE: if (NextTask(m_current)) { SendFrame(hComm, BuildRequest(m_current), reqLen); m_current.lastSendTick GetTickCount(); g_state WAITING; } break; case WAITING: if (GotReply()) { ParseAndStore(); g_state IDLE; } else if (GetTickCount() - m_current.lastSendTick 500) { if (--m_current.retry 0) { SendFrame(hComm, BuildRequest(m_current), reqLen); m_current.lastSendTick GetTickCount(); } else { MarkOffline(m_current.slaveAddr); g_state IDLE; } } break; } Sleep(5); } }逻辑说明IDLE 取下一个任务发送WAITING 等回复500 毫秒没回就重试重试耗尽标记离线。参数上500 毫秒超时要按设备响应时间调慢设备要放宽重试次数一般 2 到 3 次太多会拖慢轮询周期。5.2 日志与回放排查现场问题的后悔药现场出问题最怕没日志。我习惯在收发两端都记原始字节带时间戳写到环形缓冲或文件。格式就是「时间 方向 十六进制字节」。这样设备厂家扯皮时直接把日志甩过去。更进一步把日志做成可回放把记录的接收字节按时间喂给解析器就能在办公室复现现场问题不用跑现场。这个习惯救过我很多次尤其是那种一天只错一次的玄学故障。日志别记太细导致磁盘爆用环形缓冲保留最近几万条就够。关键字段时间戳精确到毫秒、方向、原始字节、解析结果。回放时跳过发送只重放接收流验证解析逻辑。最后说个我自己的习惯任何 485 项目我都会先写一个最小收发 Demo用串口助手对着调通一帧再往上加轮询和业务。跳过这一步直接写完整程序翻车概率极高。希望帮到你。本文还有配套的精品资源点击获取