STM32与维控屏MODBUS RTU通信实战:从协议原理到联调排障 简介本资源是一套面向嵌入式开发工程师与工业自动化学习者的STM32 MODBUS通信实战代码包聚焦STM32微控制器通过MODBUS RTU协议与维控HMI屏实现双向数据交互解决LED控制、实时变量显示及IO状态监控等典型工业场景需求。压缩包含307个文件总大小4.87MB主体为58个头文件h与55个源文件c构成完整的FreeModbus移植框架、USART驱动、GPIO控制逻辑及维控屏界面映射配置另有大量位图资源bmp、工程配置文件uvproj、sct、编译中间文件o、d及调试输出axf、hex体现完整MDK-ARM开发闭环。目前已有3941人学习下载提供可直接烧录验证的工程模板、清晰的寄存器地址映射说明、CRC校验与超时处理机制实现以及适配维控屏指令格式的报文封装范例大幅降低工业现场通信协议落地门槛。1. 为什么要在STM32和维控屏之间跑MODBUS项目背景与协议选型做工业设备控制这行遇到“单片机 人机界面”的组合几乎是家常便饭。前阵子接手了一个小型产线项目控制器用的是STM32现场需要一个能显示参数、能改设定值的操作面板。客户指定了维控的触摸屏于是整个通信方案的选型就成了第一个要敲定的事。先说结论在这个项目里我选了MODBUS RTU作为STM32和维控屏之间的通信协议从站库自己写串口用UART 485收发器接出去。之所以没选自由协议、也没上来就用MODBUS TCP核心原因有三点——屏端支持度、开发成本和现场抗干扰需求。维控屏的组态软件里MODBUS设备是内置选项新建设备时选“MODBUS RTU”填上波特率、数据位、校验位、从站地址剩下的事情就是配置控件关联寄存器地址。这意味着屏侧的工程量很小不用写任何脚本做协议解析。如果用自由协议虽然也能做但屏端每个控件都得配收发脚本开发量至少多出三倍而且后续维护的人还得读懂你自定义的报文格式这对项目交接很不友好。再看STM32端。MODBUS RTU的报文格式非常固定哪怕不看任何协议库源码用串口中断 定时器也能在半天内撸一个能用的从站。它的帧格式是“地址 功能码 数据 CRC16”总共就那么几种功能码要处理03读保持寄存器、06写单个寄存器、16写多个寄存器搞定这三个90%的HMI通信场景都覆盖了。至于为什么不直接上MODBUS TCP这个更简单。现场设备距离操作台少说十来米用网线走TCP当然稳定但STM32这边得外接以太网模块成本和布线复杂度都上去了。485总线两根线搞定还支持多设备挂接后面想加一台数显表、变频器之类的第三方设备直接并联到总线上就行地址不冲突即可。对中小型设备来说485 MODBUS RTU就是性价比最稳的组合。这个项目的最终形态如下STM32F103C8T6作为主控通过UART1接了一颗MAX3485芯片转成RS485电平维控屏型号是EK系列具体忘了不影响流程通过屏端的485口挂到同一根总线上。屏上显示电机的转速、温度、运行状态操作员在屏上修改目标转速数据通过MODBUS写进STM32的寄存器再由控制逻辑去驱动执行机构。整个通信代码不到三百行却把“人机交互”这件最关键的事给顶住了。下面把从接线到代码、再到联调排障的过程完整复盘一遍给后面要做类似项目的人一个可以照着抄的作业。2. 硬件连接方案485总线上的电平转换、终端电阻与收发切换2.1 电平转换芯片的选型与电路接法STM32的UART输出的是TTL电平0-3.3V而RS485总线是差分信号A、B两线之间的电压差代表逻辑0和1所以中间必须有一颗收发器芯片做电平转换。这个项目用的是MAX34853.3V供电正好和STM32的IO电平匹配。如果是5V供电的老平台可以用SP3485或者MAX485注意看芯片的VCC引脚接的是3.3V还是5V别插错。典型的接法是STM32的UART_TX接到MAX3485的DI驱动器输入STM32的UART_RX接到RO接收器输出一个GPIO比如PA8接到RE和DE这两个脚在MAX3485上是复用的通常直接短接控制收发方向A、B两个引脚接出去到总线上A接A、B接B注意不要接反这里有一个非常关键、也是新手最容易踩坑的点RE/DE引脚的控制时序。在发送数据之前必须先把DE拉高让驱动器使能然后才能往UART数据寄存器里写数据数据发送完之后需要等最后一个字节完全移位输出也就是TXE和TC标志都置位了再把RE拉低切回接收模式。如果切换太快最后一个字节的停止位可能被截断对端收到的帧就是坏的切换太慢则会错过对方紧接着发来的响应帧。我用的是HAL库 串口中断的模式每次发送前调用一个函数把方向脚拉高发送完成后在UART的TC中断里把方向脚拉低。这块后面讲代码的时候会给出具体的写法。2.2 终端电阻、屏蔽层和共地问题485总线在长距离或高速传输时需要接终端电阻阻值一般是120欧姆接在总线最远端的A、B之间用来消除信号反射。如果只是短距离几米、波特率不高9600不接终端电阻多数情况下也能工作但对波形质量会有影响。这个项目里STM32的板子和维控屏之间的距离大约十米波特率9600我把终端电阻接到了维控屏那一端因为屏的485口旁边通常有跳线或端子可以切换终端电阻实物上没有的话就自己焊一个STM32这端没接。另外两个容易忽视的细节第一是共地问题。485虽然用差分传输抗共模干扰能力强但A、B线之外两个设备之间最好还是有一根共同的参考地。如果两边的地电位差太大超出收发器的共模输入范围照样会通信失败。我在项目里把两边电源的地用一根线连了起来实测下来通信稳定度好很多。第二是屏蔽线的处理。走线用的是双绞屏蔽线屏蔽层单端接地接到STM32板子的GND不要两端都接否则会形成地环路反而引入干扰。2.3 调试阶段的临时接线现场调试时不一定能把屏和板子放在一起我用了一颗USB转485的调试工具CH340方案的插在电脑上先把STM32的485口和电脑连起来用串口调试助手发MODBUS报文验证从站逻辑再接入维控屏做联调。这样可以把问题分成两段先保证“板子作为MODBUS从站没问题”再保证“屏能和板子对上话”。顺序反过来往往会陷入“不知道是屏配置错了还是板子代码错了”的泥潭。3. MODBUS协议的核心细节寄存器地址映射、报文帧格式与CRC16计算3.1 两种寄存器、三个功能码足够覆盖90%的HMI场景MODBUS协议逻辑上把数据分成四个区线圈Coil位操作、离散输入Discrete Input位操作、输入寄存器Input Register16位只读、保持寄存器Holding Register16位可读可写。在HMI 单片机这个场景里真正高频用到的是保持寄存器其次是输入寄存器或者直接用保持寄存器寄存器的只读部分来传测量值省事。拿我这个项目举例寄存器规划如下寄存器地址协议地址数据类型功能读写属性0x000016位无符号目标转速设定值读写用06功能码写0x000116位无符号当前转速测量值只读用03功能码读0x000216位无符号设备温度只读0x000316位位映射运行状态字bit0运行bit1故障bit2急停只读功能码这边我只实现了三个0x03读保持寄存器。屏上电后周期性轮询用来刷新显示数据。0x06写单个保持寄存器。操作员在屏上改目标转速时屏发这个命令。0x10十六进制10十进制16写多个保持寄存器。屏上如果有“一键设置多参数”之类的界面会用到这个。有些资料里会写成功能码10注意这里是十六进制0x10别搞混了。3.2 报文帧格式实例读、写、响应对照MODBUS RTU的报文是二进制格式帧与帧之间靠“静默时间”区分——即总线空闲超过3.5个字符时间就认为一帧结束了。以“读从站地址1的保持寄存器从协议地址0x0000开始读2个寄存器”为例请求帧是这样的01 03 00 00 00 02 C4 0B拆开看01从站地址03功能码读保持寄存器00 00起始寄存器地址协议地址高字节在前00 02读取的寄存器数量这里读2个即地址0x0000和0x0001C4 0BCRC16校验低字节在前高字节在后STM32从站收到后如果地址匹配、CRC校验通过、寄存器范围合法就回响应帧01 03 04 01 F4 02 58 7B A301从站地址回显03功能码回显04后续数据字节数2个寄存器 × 每寄存器2字节 4字节01 F4第一个寄存器值500即目标转速500rpm02 58第二个寄存器值600当前转速600rpm7B A3CRC16写单个寄存器功能码0x06把地址0x0000的值改成80001 06 00 00 03 20 8A 4201从站地址06功能码写单个保持寄存器00 00寄存器地址03 20要写入的值8008A 42CRC16从站处理成功后的响应帧是原样回显请求帧也就是上面的报文原封不动发回去。如果校验失败会回一个异常帧功能码最高位置1比如03变83后面跟一个异常码01非法功能、02非法地址、03非法数据值。3.3 CRC16的计算查表法实现与置位细节CRC校验是MODBUS RTU最容易写错的地方。MODBUS协议用的CRC16和普通的CRC16-MODBUS标准完全一致多项式0x8005反转后是0xA001初值0xFFFF结果异或值0x0000输入输出不反转最简单的实现是逐位计算法代码可读性强uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (uint8_t i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }发送时把算出来的CRC低字节放在前、高字节放在后。接收校验时把整帧包括CRC所有字节再算一遍CRC结果是0就说明没出错。用查表法可以提速适合主频低或者导数据量大的场景但96字节的表比较长这里先给逐位法的代码工程上完全够用。提示CRC初始值是0xFFFF不是0x0000也不是0x8005。这个细节错了整帧校验就永远过不了。4. 代码架构设计串口空闲中断、握手超时与寄存器访问的组合实现4.1 接收缓冲区与空闲中断STM32接收MODBUS帧最省心的方案是开启串口空闲中断IDLE。所谓空闲中断是指一帧数据接收完毕后总线上出现了一段空闲时间一个字节的时间硬件会自动置位IDLE标志触发中断。这样就不用自己去维护复杂的定时器超时逻辑了。用HAL库实现很简单// UART初始化后调用 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); // 在UART1的全局中断服务函数里 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 当前接收计数 uint16_t rx_len 0; HAL_UART_AbortReceive(huart1); // 停止当前接收 rx_len sizeof(modbus_rx_buf) - huart1.RxXferCount; modbus_rx_len rx_len; // 重新开启接收 HAL_UART_Receive_DMA(huart1, modbus_rx_buf, sizeof(modbus_rx_buf)); } HAL_UART_IRQHandler(huart1); }注意上面的例子用了DMA接收实际项目里我是直接用DMA 空闲中断的组合。如果不用DMA改成普通中断逐字节接收也行但DMA方式对CPU占用更少处理连续报文时不容易丢字节。空闲中断触发后先调用HAL_UART_AbortReceive停掉当前接收取出计数值再重新启动DMA接收这是HAL库下比较常见的姿势。有一点要提醒MODBUS要求帧间间隔是3.5个字符时间空闲中断触发条件是一个字节时间两者不冲突。因为从站收到完整一帧后总线的空闲时间必然超过一个字节空闲中断一定会触发只是触发时刻可能比“协议上定义的帧结束时刻”稍微早一点少等了2.5个字符时间对接收结果没有影响。4.2 帧校验与解析状态机接收完一帧后进入校验和处理流程我把它拆成了三件事CRC校验、地址匹配、功能码分派。void modbus_process_frame(uint8_t *buf, uint16_t len) { if (len 4) return; // 最短帧地址功能码CRC2字节 // 1. 校验CRC uint16_t crc modbus_crc16(buf, len - 2); if (crc ! (buf[len - 2] | (buf[len - 1] 8))) return; // 2. 地址匹配 if (buf[0] ! MODBUS_SLAVE_ADDR buf[0] ! 0) return; // 3. 分派功能码 switch (buf[1]) { case 0x03: modbus_handle_read_holding(buf, len); break; case 0x06: modbus_handle_write_single(buf, len); break; case 0x10: modbus_handle_write_multiple(buf, len); break; default: modbus_send_exception(buf[0], buf[1], 0x01); } }分派函数内部再根据寄存器地址做读或写操作。保持寄存器的存储可以用一个全局结构体或者数组对齐比如typedef struct { uint16_t target_speed; uint16_t current_speed; uint16_t temperature; uint16_t status_word; } modbus_regs_t; modbus_regs_t modbus_regs;这样读寄存器时就是按地址访问结构体成员写寄存器时先更新结构体再触发对应的应用逻辑比如目标转速改变后置个标志位让主循环去执行PID调节。4.3 响应发送的时序控制响应帧的发送需要严格管理485收发器方向脚。我写了一个底层的发送函数void modbus_send_response(uint8_t *buf, uint16_t len) { RS485_DIR_EN(); // 拉高RE/DE进入发送模式 HAL_UART_Transmit_IT(huart1, buf, len); // 在UART的TC中断里再拉低切换回接收模式 }TCTransmission Complete中断的触发条件是最后一个字节的停止位已经移出此时让RS485切回接收模式是最安全的。注意不要用TXE发送寄存器空中断TXE置位只表示数据从UART数据寄存器搬到了移位寄存器移出还没完成这时候切线照样会截断帧尾。HAL库下我是在HAL_UART_TxCpltCallback里拉低方向脚void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { RS485_DIR_DIS(); // 回到接收模式 } }5. 维控屏组态配置设备新建、寄存器地址映射与数据格式对齐5.1 组态软件里的MODBUS设备配置维控屏的组态软件是Weincloud或者老的维控HMI组态软件不同系列界面略有差异但逻辑都一样。新建工程后在“设备/通讯”里新建一个设备协议选MODBUS RTU然后填以下参数从站地址要和STM32代码里定义的MODBUS_SLAVE_ADDR一致比如1波特率9600数据位8校验位无停止位1这三个参数波特率、校验位、停止位只要有一项对不上屏和板子就完全握手不了而屏端报错又往往是“通讯超时/无响应”排查起来特别容易绕远路。我的经验是先拿USB转485工具把屏端的配置和STM32的串口配置一一比对确认无误后再查硬件线。另外维控屏组态软件里“设备地址”和“寄存器起始地址”的编号方式可能和MODBUS协议地址有偏移。屏上如果写的是4x-01那么在MODBUS协议层对应的保持寄存器地址就是0x0000如果是4x-02对应0x0001。也就是说屏侧地址从1开始协议地址从0开始两者差1。这个偏移是联调时最容易出问题的点配置控件时务必想清楚。5.2 数值类型与字节序对齐保持寄存器是16位的但屏上显示的很多值比如温度、转速可能需要32位甚至浮点数。MODBUS协议规定一个寄存器16位32位数据需要连续占用两个寄存器帧内数据是按大端高字节在前传输的。在维控屏上配置32位数据时组态软件会让你选“32位整数”或“32位浮点数”格式同时可以设置双字顺序有些叫“高字在前”或“低字在前”。这里必须和STM32端组装四个字节的字节序保持一致否则屏上会出现“数值完全不对但通信又一点都不报错”的诡异现象。我有个习惯能用16位表达的参数就尽量不用32位减少字节序带来的复杂度比如这个项目的转速设定值0-3000rpm16位完全够用。只有温度需要带小数点时才用浮点此时在STM32端把float的地址直接强转成uint16_t数组来供MODBUS访问再用维控屏按浮点数格式解析两边都按IEEE754的字节序来实测没问题。5.3 屏端的控件绑定配置好设备后拖一个数值显示控件关联设备的保持寄存器地址选择无符号16位数据长度1然后换一个数值输入控件关联同一个地址选操作方式为“写单个寄存器”。这样屏上显示就是回调03读到的值输入则通过06写入。状态显示用位按钮/指示器配几个位地址比如保持寄存器的bit0、bit1就行。在屏上每个控件设置好之后给控件加上“通讯状态”的指示标签方便现场判断是通信断了还是数据本身异常。我通常在画面上放一个小图标绑定一个“设备状态”的变量通讯正常时显示绿色超时变红——这样排查问题的时候一眼就能确认通信链路是否在跳动。6. 联调排障实录从屏上数据乱码到彻底稳定的完整链路6.1 故障现象一屏上数值完全不动通讯状态报错这个问题的本质是“屏根本没有收到任何有效报文”排查链路按下面几步走第一步先用USB转485把电脑接到总线上用串口助手软件或者MODBUS调试工具软件比如正点原子、格西烽火这类轮询一下从站。如果电脑能读到正确的寄存器数据说明STM32侧代码没问题如果电脑也读不到问题大概率在STM32的从站程序里先把串口中断、DMA、CRC这块挨个检查。第二步把维控屏的485线单独接出来用电脑的串口工具去对接屏配合屏的“脚本监视”或者“变量监视”在屏端执行一个读操作看能不能抓到报文。如果屏发的报文电脑能收到而STM32收不到问题在STM32的接收配置如果STM32回了报文但屏收不到问题在发送方向或485方向切换。第三步检查RS485方向脚的切换时序。这块用示波器或逻辑分析仪看波形最直观如果没有示波器可以用一个笨办法在发送函数里设置一个GPIO翻转的测试点接个LED发送时LED亮一下如果发送后LED一直亮着灭不了说明TC中断没触发比如中断没使能如果LED闪得飞快但屏就是没反应可能就是时序太快或太慢。6.2 故障现象二屏能收到数据但数值和实际值差很多这类问题九成是寄存器地址偏移或字节序不对。比如我在屏上配置的是“4x-01”STM32代码里注册的也是地址0x0000但屏读到的却是0x0001的值——这就是地址偏移问题。如果屏上配置的是32位格式而STM32回的是两个16位数据数值也会完全错乱。排查方式先在STM32端把每个寄存器的值打印出来用另一个串口输出调试信息然后和屏上显示的值逐项比对基本一两轮就能定位到是哪个环节的字节序或地址搞错了。有时候问题不止一层比如32位浮点数不仅字节序要对还要确认屏端“双字顺序”选的是“低字在前”还是“高字在前”需要和STM32端组装两个寄存器的顺序对应上。我习惯在STM32端规定低地址寄存器存高16位大端模式然后在屏端选择“高字在前”这样和MODBUS协议的字节序一致不会有歧义。6.3 故障现象三单独和电脑调试没问题接上维控屏就通信失败这个现象通常说明STM32从站代码没问题问题在“总线上的电气环境”。常见原因有两个一是AB线接反。485的A、B极性不影响TTL电平转换后的逻辑但接反后信号完全反相对方一定收不到。用USB转485调试工具上一般有A/B标识对照着接到STM32的A/B即可。我用过一个USB转485工具丝印上A和B颜色还是反的导致折腾了很久后来用万用表量了一下两个端口在不同发送状态下的电压差才确定极性。二是共模干扰或地电位差。现场电机启停、变频器工作都可能给485总线带来干扰。处理办法把屏蔽层单端接地、加终端电阻、降低波特率9600降到4800响应慢一点但能通就行、用磁环在485线上套两个都能缓解。还有一个隐蔽的问题如果同时接了两台设备其中一台的485芯片损坏或者A/B输出异常会把整个总线的电平拉死。排查时可以逐个断开设备看通信是否能恢复找到“肇事设备”。6.4 故障现象四通信时好时坏重启后又能跑一阵这类问题多半是“帧被截断”或“缓冲溢出”造成的。一个典型的场景是STM32在空闲中断里取走接收长度后还没来得及处理下一帧又到了导致数据覆盖。处理办法是把接收缓冲放在DMA模式下并在主循环/flags里做“忙标志”如果上一帧还没处理完就收到新帧直接丢弃当前帧或者把处理流程改为“处理完成后立即重新开启DMA接收”。另一个场景是发送和接收的时序竞争。如果屏端在一个很短的时间窗口内连续发出两个请求比如读多个地址做批量刷新而STM32还在上一个响应的发送过程中可能会丢掉第二个请求。解决方案是在处理完前一个请求后、重新开DMA接收之前主动清空接收缓冲区防止残留数据干扰后续帧解析。提示调试现场时别太相信“屏幕上的数值一直在跳”就是通信良好的标志。MODBUS RTU本来就是循环轮询的即使偶发丢包屏也会在下一次轮询时重新读到数据人眼根本察觉不到。要测试稳定性必须长时间跑、循环看错误计数和重试次数最好在屏端开启通讯状态诊断变量。7. 进阶扩展多从站并网、BSP拆分与从RTU迈向TCP的过渡思路7.1 多个STM32或第三方设备并网总线上挂多个从站时只要给每个设备分配不同的从站地址1-247屏端建多个MODBUS设备每个设备指向一个从站地址就行。地址分配在代码里是宏定义比如#define MODBUS_SLAVE_ADDR 1 #define MODBUS_SLAVE_ADDR_2 2在生产环境做多从站组网时总线布线尽量用“手拉手菊花链”——从屏的485口出来先到第一个设备再从第一个设备串到第二个设备不要搞成星形拓扑否则阻抗不匹配容易出反射。终端电阻只在总线最远端的那个设备上接其他设备不接。这个细节直接影响高速波特率比如38400以上下的通信稳定性9600波特率下影响小一些但规范养成习惯总没错。7.2 把MODBUS从站代码抽成通用BSP模块这次项目做完后我把MODBUS从站代码从应用逻辑里剥了出来形成独立的modbus_slave.c/modbus_slave.h模块内部通过一个“寄存器访问回调函数”和上层业务解耦。上层只需要实现三个回调typedef uint16_t (*modbus_read_reg_t)(uint16_t addr); typedef void (*modbus_write_reg_t)(uint16_t addr, uint16_t value); typedef void (*modbus_write_multi_t)(uint16_t addr, uint16_t *values, uint16_t count);这样下次换MCU、换平台只需要把串口底层和这几个回调重新实现一遍协议解析层不动。如果项目还要支持MODBUS TCP——通过以太网网关把RTU转成TCP核心代码可以复用只要把串口的收发换成socket收发报文格式完全一致。后面我打算单独写一篇RTU转TCP的落地笔记这次先把RTU这条线讲透。7.3 一个稳定的从站还缺什么看门狗与超时联锁最后补充一个和MODBUS本身关系不大、但对设备安全至关重要的细节。如果屏在运行中突然断电或通信线脱落STM32这边应该有一个“通信超时”的判断逻辑维护一个接收时间戳主循环里每10ms检查一次如果超过设定时间比如3秒没收到任何有效MODBUS报文就认定通信断开置一个通信丢失标志。应用层看到这个标志后可以决定是停机、保持现状还是切本地控制避免设备在无人操作的状态下一直按旧设定值运行造成安全隐患。我一般把超时时间和屏的轮询间隔协调好屏通常几百毫秒轮询一次3秒没收到基本可以确定链路断了这个阈值既不会误报也不会太迟钝。代码量不大但很多半路出家的项目就是把这类“隐性需求”漏掉了导致设备在通信异常时处于不受控状态这是非常危险的事。写在最后这套方案踩过的坑和留下的经验回看整个项目真正花时间的不是写那几百行协议代码而是联调阶段和电气环境“死磕”的过程。RS485看着简单接上就能跑但工程现场的干扰、接地、拓扑结构随便哪一环出问题都能让你怀疑人生。我最后踩下的结论是先把软件分层做好——串口层、协议层、应用层各管各的事然后联调时按“先电脑后屏、先单点后批量、先短距后长距”的顺序推进大部分问题都能在半小时内定位到具体环节。给后面做类似项目的人几个具体的建议串口参数必须统一9600/8/N/1是默认改了波特率一定要两边同步屏端配置和代码里的串口初始化要一致。地址偏移记清楚屏侧4x-01对应协议地址0x0000差1是常有的事配置控件前先做好对照表。方向脚切换用TC中断别用TXE中断。调试时把串口接收到的原始报文用另一个调试串口打印出来直接看十六进制数据比自己瞎猜强得多。通信超时联锁一定要做这是设备安全的下限。如果后面有机会我还会把这次项目的MODBUS RTU主站实现、以及通过4G模块做远程数据上报的部分也整理出来——MODBUS这个协议虽然老但把它用扎实了在工业现场能解决太多实际问题。本文还有配套的精品资源点击获取