YAOTU INSIGHTS

Modbus实战详解:RTU帧格式、CRC校验与STM32从站调试

Modbus实战详解:RTU帧格式、CRC校验与STM32从站调试
做嵌入式这些年通信协议里打交道最多的Modbus绝对排第一。不管是用STM32做从站、用Linux工控机做采集还是现场拿电脑连PLC排查问题Modbus RTU和Modbus TCP都是绕不开的基本功。最近给一套农田灌溉项目做调试又栽在RS485的接地和终端电阻上折腾了大半天才把问题定位清楚。趁热打铁把Modbus协议的核心机制、调试工具用法和实战中踩过的坑整理成这篇笔记。这篇笔记适合谁看刚接触Modbus的嵌入式新手以及被“主机连从机不正常”这类问题折磨过的工程师。我会先把协议帧格式、CRC校验、功能码这些底层细节讲透再拆解Modbus Poll、Modbus Slave、串口助手这些工具的用法最后用STM32 RS485做一次完整的从站移植和联调把常见故障的排查思路一并整理出来。1. Modbus协议把一条字节流拆开了看1.1 一帧RTU报文里到底装了什么Modbus协议本质上是一个主从结构的主从请求应答协议一台主机Master管理多台从机Slave从机地址范围1到247。平时调试时拿电脑接USB转485电脑就是主机下位机就是从机。主机发请求帧从机回响应帧一来一回谁也不会先开口所以物理链路上不会冲突。RTU模式下一帧完整的报文长这样字段长度说明从机地址1字节0x01~0xF70x00是广播地址功能码1字节03读保持寄存器、06写单寄存器等数据区N字节寄存器地址、数量、数据值、报文内容CRC162字节对前面所有字节做CRC16-Modbus计算低字节在前举个实际例子主机要读从站地址1的保持寄存器从地址0x0000开始读2个寄存器这条请求帧是01 03 00 00 00 02 C4 0B其中01是从机地址03是读保持寄存器功能码00 00是起始寄存器地址00 02是寄存器数量C4 0B是CRC16校验。从站正常响应会回这么一帧01 03 04 00 11 00 22 3D 8A01是地址03是功能码04是后面数据字节数00 11是第一个寄存器的值十进制的1700 22是第二个寄存器值十进制的34最后的3D 8A是CRC。整个通信过程就这么朴素没有任何多余的握手和加密这也是它能在工控领域用了40多年还不退场的原因——简单、透明、容易排查。1.2 CRC16校验手算到代码一次性讲明白RTU报文里最难手算的就是CRC16。Modbus的CRC16叫CRC16-Modbus或者叫CRC16-IBM多项式是0x8005初始值是0xFFFF输入输出都不做异或反转。网上教程一堆但真正落地到单片机代码我建议直接用查表法速度和代码量都最优。按位计算的版本适合理解和排错uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }很多人看不懂代码里为什么出现0xA001这里解释一下。多项式0x8005是正向写法但RTU协议在传输时是低位先移出的所以代码要反过来用0x8005的反射多项式也就是0xA001。手算时也是一样的规则CRC初始0xFFFF对每个字节先异或然后右移8次最低位是1就异或0xA001最后得到的CRC值发送时要先低字节后高字节。调试的时候有个小技巧主机发的请求帧拿串口助手抓到后把这帧的地址码、功能码和数据区喂给上面的函数算出来的CRC应该和帧尾两个字节逆序相等。如果不等先别碰硬件多半是代码里字节序或者初始值搞错了。我见过不少新手把0xFFFF写成0x0000结果整帧CRC全错。1.3 功能码与寄存器模型一次会话说清楚Modbus对数据的访问靠四种对象线圈Coil可读可写对应开关量输出、离散输入Discrete Input只读对应开关量输入、保持寄存器Holding Register可读可写16位、输入寄存器Input Register只读16位。前两个是位对象后两个是16位字对象。实际项目里90%的场景用两个功能码就够了功能码名称操作对象用途0x01读线圈线圈读取DO状态0x02读离散输入离散输入读取DI状态0x03读保持寄存器保持寄存器读参数、测量值0x04读输入寄存器输入寄存器读只读测量值0x05写单线圈线圈控制DO0x06写单寄存器保持寄存器写单个参数0x0F写多线圈线圈批量控制DO0x10写多寄存器保持寄存器批量写参数主机读到异常时从站会回一个异常帧功能码最高位置1即在原功能码上加0x80后面跟一个异常码。比如读保持寄存器功能码是0x03数据地址越界时从站回0x83异常码0x02。1.4 字节序的坑大端规则和32位数据的排列Modbus规定16位寄存器是“大端”传输高字节在前、低字节在后。比如寄存器值是0x1122帧里就是11 22。这个规则很好记但32位数据就很考人了。一个32位无符号整数比如0x11223344要放进两个保持寄存器里。Modbus协议本身没规定先传高16位还是先传低16位不同厂家设备实现不一。主流做法是“高字在前”也就是寄存器地址小的放0x1122地址大的放0x3344帧里看到的就是11 22 33 44读起来很顺眼。但有些国产仪表特别是从单片机小作坊产品继承下来的代码会传成33 44 11 22。遇到这种情况不要和厂家吵先拿Modbus Poll读一下把帧数据或者寄存器原始值记录成16进制搞清楚设备实际用的是哪种排列再在你的解析代码里统一处理。我习惯在项目代码里写一个“读32位无符号数”的函数参数里带上字节序模式方便现场快速切换。浮点数同理IEEE754的4个字节往两个寄存器里放和整数一样的道理只是解析时要用联合体或者memcpy转成float。2. 调试工具链从串口助手到专业主从站软件2.1 Modbus Poll、Slave和免费替代方案调试Modbus通信最趁手的工具是Modbus Poll和Modbus Slave这两款软件。Poll模拟主站Slave模拟从站听起来简单配合起来能覆盖绝大多数调试场景。Modbus Poll可以按地址批量读取寄存器把数据用十进制、十六进制、浮点数显示还能手动写寄存器基本等于一个可视化极强的上位机。Slave更简单把一个虚拟设备挂在电脑串口上按你设定的寄存器表响应主站请求。这两款软件都有评估版可以免费用一段时间。网上流传的注册机、激活码就不要再碰了轻则带木马重则被甲方发现用盗版软件做交付得不偿失。如果只是偶尔调试、不想折腾授权可以考虑开源替代品QModMaster开源主站模拟工具支持RTU和TCP界面朴素但功能够用。MbToolsJava编写的开源Modbus测试工具跨平台。直接看你用的协议栈比如libmodbus自带测试程序编译完就能当主站用。工具不必多把Modbus Poll和Slave用熟基本就能打天下了。我目前主力环境是Windows上的Modbus Poll Linux下用libmodbus写脚本做自动化压测两者互补。2.2 串口助手为什么不能丢专业工具吹得再天花乱坠调试时我总会在旁边开着串口助手盯着最原始的字节流。原因很简单Modbus Poll帮我把所有东西都“翻译”成了整齐的寄存器数值问题被藏起来了。比如从站地址设错了、CRC算错了、字节序颠倒了Poll界面上只会显示超时或者一个奇怪的值但串口助手里能看到真实的字节能立刻判断出是物理层没信号、数据发了但没回还是数据回了但内容不对。调试时我习惯把串口助手的显示模式设成HEX打开时间戳。抓到一帧请求马上和手上的协议文档比对帧里的地址、功能码、寄存器地址。要是只看Poll的界面根本定位不到这一层。2.3 485转USB模块怎么选怎么接电脑上没有串口调试RS485就得用USB转485模块。这个模块是踩坑重灾区我建议直接上CH340或FT232方案的原厂模块价格30到60元之间不要买那种几块钱还送杜邦线的杂牌模块。杂牌模块最常见的两个问题是芯片供电不稳导致电平漂移或者收发切换的延时控制做得差丢第一个字节。接线时记住RS485用A和B两个信号端模块上印的A接设备的A也叫D、485AB接设备的BD-、485B。A和B接反是最常见的低级错误现象是主机发请求后没有任何响应。另外RS485是差分信号但工程上为了安全很多设备还会要求信号地和地线连接特别是两端设备使用不同电源时共地能避免共模电压把芯片打坏。我做过一个项目两个设备各自用不同的开关电源供电没共地通信时好时坏量下来A和B之间的共模电压有7V多直接超了MAX485的耐受范围。2.4 Modbus TCP场景下的抓包姿势Modbus不走串口而是走以太网时调试方法完全不同。Modbus TCP的PUD协议数据单元和RTU几乎一样区别是删掉了CRC、地址码外面套了一个MBAP报文头占7个字节事务处理标识符2字节、协议标识符2字节、长度2字节、单元标识符1字节。其中协议标识符固定是0x0000长度表示后面字节数。这个场景我强烈推荐用Wireshark抓包。打开Wireshark过滤条件填modbus或modbus.tcp就能直接看到完整的Modbus TCP请求和响应。Wireshark还会帮你解析出功能码、寄存器地址、数据值甚至能展开成树形结构。有一次现场工程师非说设备没响应我抓包看到请求帧已经到了但设备回的TCP ACK是正常的只是应用层响应没出来瞬间把问题从“网络不通”切到“设备应用层故障”效率完全不一样。3. 实战STM32从站从移植到联调3.1 硬件准备与接线检查这次实战用STM32F103C8T6最小系统板板载一个USART1通过MAX485芯片转成RS485。硬件接线如下STM32的PA9TX接MAX485的DIPA10RX接ROPB0接RE和DE做方向控制。注意很多MAX485模块把RE和DE已经短接好了只用一根IO控制方向即可。模块的A、B端子接USB转485模块的A、B再接电脑。上电之前先做三件事量一下485模块的VCC和GND是否正常确认模块的A和B有没有焊反把USB转485模块的驱动装好在设备管理器里确认串口号。这三件事看着基础但现场一半以上的“通信不通”都出在这里。我吃过一次亏模块的A和B在PCB上丝印标反了量了半天才发现接反了。3.2 Freemodbus移植流程有个现成的开源协议栈叫Freemodbus专门为嵌入式MCU设计源码结构清晰已经被移植到各种平台。它在STM32上移植的核心工作是三件事串口收发、定时器、回调函数。第一串口接收用中断方式每收到一个字节就往缓冲区里塞。Freemodbus会自己判断帧是否收完判断逻辑是“3.5个字符周期内没有新字节”。所以在9600波特率下3.5个字符大约是4ms需要一个定时器来做超时检测。我习惯把定时器溢出周期设为1ms在中断里做计数连续4ms没有新字节就认为一帧结束。第二方向切换。Freemodbus提供了xMBPortSerialPutByte等底层接口我们在发送第一个字节前把RE/DE置为发送模式发完最后一个字节转为接收模式。这个时序很关键转早了最后一个字节还没发完A/B线上的电平被拉回高阻从站收不到完整的响应。第三串口波特率。Freemodbus默认通过一个宏配置波特率例如#define MB_ASCII_TIMER_3_5_CHARS 4RTU模式下配置MB_RTU_TIMER_3_5_CHARS即可。实际代码里要使能串口接收中断和定时器中断优先级建议定时器高于串口。3.3 寄存器回调从站真正干活的地方Freemodbus把底层协议栈全部封装好用户只需要在回调函数里维护自己的寄存器数组。最核心的是保持寄存器回调eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT iRegIndex usAddress - REG_HOLDING_START; if (usAddress REG_HOLDING_START || usAddress usNRegs REG_HOLDING_START REG_HOLDING_NUM) { return MB_ENOREG; } if (eMode MB_REG_READ) { while (usNRegs 0) { *pucRegBuffer (UCHAR)(usRegHolding[iRegIndex] 8); *pucRegBuffer (UCHAR)(usRegHolding[iRegIndex] 0xFF); iRegIndex; usNRegs--; } } else { while (usNRegs 0) { usRegHolding[iRegIndex] (*pucRegBuffer 8) | *(pucRegBuffer 1); pucRegBuffer 2; iRegIndex; usNRegs--; } } return MB_ENOERR; }这个函数的本质是把你定义的寄存器数组usRegHolding和Modbus地址空间做映射。比如你定义#define REG_HOLDING_START 0x0000、#define REG_HOLDING_NUM 100那么Modbus地址0x0000对应数组第0个元素地址0x0063对应第99个元素。有个细节必须注意寄存器地址是1-based还是0-based。Modbus协议里数据地址从0开始但很多触摸屏和上位机组态软件里用的“寄存器号”从1开始。这就导致同样的地址在Modbus Poll里填0在某些上位机里要填1。Freemodbus直接使用Modbus协议地址也就是从0开始和Poll填一致但对接组态软件时请务必查一下对方的地址偏移否则会隔三差五读错地址。3.4 用Modbus Poll联调一次完整的读写测试从站程序编译下载后打开Modbus Poll配置连接Connection选Serial串口号选USB转485对应的COM口。波特率设为9600数据位8校验位None停止位1。从站地址填1功能码选03读保持寄存器。起始地址填0数量填10然后点Connect。如果一切正常界面上的寄存器表格会开始滚动刷新每行显示一个寄存器当前的值。你需要观察几个点数据是否在按你设定的刷新周期稳定更新而不是忽有忽无。寄存器数值和你在代码里初始化填入的值是否一致。比如你初始化usRegHolding[0] 0x1234界面上就应该看到十进制4660十六进制0x1234。试着把某个寄存器的值改成任意数观察从站程序里的变量是否跟着变。在Poll里切换到写模式下双击寄存器即可输入值写入后回读确认。我第一次联调时Poll界面一直显示超时后来才发现USB转485模块的驱动默认把COM口配置成了偶校验而STM32程序里配置的是无校验。波特率、数据位、校验位、停止位这四个参数有任何一项不对Modbus就完全不通。所以联调时第一件事就是核对串口参数这一步能干掉一半的问题。4. 高频故障与排查手册4.1 从站完全没反应现象是Modbus Poll一直转圈、超时串口助手里能看到主机发的请求帧但从站没有任何响应。这种“发得出收不到”的问题按顺序排查排查项检查方法常见根因A/B接线万用表量A、B间电压静态时应为2V以上A/B反接方向控制示波器或逻辑分析仪看RE/DE引脚方向切换时序不对从站地址核对Poll里填的地址和固件配置地址不匹配串口参数逐一核对波特率、校验位参数不一致供电地线量模块GND和设备GND间电压共模电压过高这里特别想说一下方向控制。RS485是半双工同一时间只能收或者发。从站收到请求后需要先把RE/DE切到发送模式发完再切回来。很多从站程序是在中断里收到完一帧后去处理处理完后才切方向一旦处理耗时过长比如里面有个延时函数主机端就超时了。我见过最离谱的一个程序在寄存器回调里塞了一个HAL_Delay(100)结果主机一写寄存器从站响应就要100ms把主站的超时时间逼到了200ms以上。所以回调函数里绝对不能有阻塞延时。4.2 返回异常码03、02地址越界和功能码不支持主站能收到响应帧但帧里的功能码最高位为1这种问题多半和协议无关是应用层地址有歧义。异常码0x02表示非法数据地址意思是主站读的寄存器地址超出了从站支持的地址范围。比如从站只实现了地址0到99的保持寄存器主站读地址0x0064100从站就会回0x83 0x02。排查时直接看从站代码里注册表的起始地址和数量计算实际覆盖范围。异常码0x03表示非法数据值通常出现在写操作写入的值超出了允许范围。比如寄存器定义的是百分比0到100你硬写200可能被从站主动拒绝。异常码0x01表示非法功能是它根本不支持这个功能码。新手最容易在这种地方绕圈明明协议栈支持0x10写多寄存器但你的回调函数没实现从站就会直接用异常拒绝。先把协议栈的宏开起来比如MB_FUNC_WRITE_MULTIPLE_REG_ENABLED设为1。4.3 CRC不过波特率误差与字节间隙CRC校验不过并不能简单理解成“协议栈算错了”。多数情况下是数据在物理传输过程中发生了位错误或者帧的边界判断错了。Modbus RTU对帧间时间有严格要求一帧内字节间距不能超过1.5个字符时间整个帧必须在3.5个字符时间内完成接收。如果从站端的串口接收中断处理太慢或者中断被更高优先级打断导致两个字节之间间隔超过1.5个字符时间从站就会认为这一帧无效、直接丢弃。这种现象有个典型表现用Modbus Poll定时轮询还能用用但轮询频率加快后超时率直线上升。波特率误差是另一个隐性杀手。STM32用8MHz外部晶振跑9600波特率时USART的分频误差很小。但如果用的是内部RC振荡器温度一变化频率漂移会直接导致位定时偏差累积尤其是长帧数据最后一个字节的采样点可能已经偏到错误的位置。我建议所有Modbus设备优先用外部晶振实在要用内部的波特率尽量不超过19200并实测长时间的误码率。4.4 浮点数和32位数据错乱能通、能读但数据看起来不对劲比如读浮点数读出来是几亿几十亿或者两个16位整数串了十有八九是字节序问题。Modbus寄存器里一个16位数据排成高字节在前。但如果你把两个寄存器拼成一个32位就会有高低字顺序问题。IEEE754浮点数也一样四字节在内存里可能是小端放到Modbus寄存器里又要求大端转换时想当然就会翻车。我写过一个人畜无害的解析函数typedef union { float f; uint32_t u32; uint8_t bytes[4]; } float_u32_t; float regs_to_float(const uint16_t *regs, int big_word_first) { float_u32_t val; if (big_word_first) { val.bytes[0] (regs[0] 8) 0xFF; val.bytes[1] regs[0] 0xFF; val.bytes[2] (regs[1] 8) 0xFF; val.bytes[3] regs[1] 0xFF; } else { val.bytes[0] (regs[1] 8) 0xFF; val.bytes[1] regs[1] 0xFF; val.bytes[2] (regs[0] 8) 0xFF; val.bytes[3] regs[0] 0xFF; } return val.f; }遇到浮点数显示不对先用0x03读两个寄存器把原始16进制抄下来手工拼一下看和实际值差多少。如果对不上就换一个字节序排列再试通常两次就能试出来。现场解决不了的问题很多都是这种低级但顽固的字节序问题。4.5 一个真实案例主机从机单独测都正常连起来就是不通这是热词里热度很高的一句话“485 modbus 主机 从机 分别测试都正常 主机连接从机就不正常”。我见过太多次这种情况也专门排查出一个比较典型的根因。单独测主机时电脑通过USB转485直连从站电平标准一样信号质量好。单独测从机时用电脑模拟主站同理。但真正的主机比如PLC或另一个单片机连从机时问题就来了。最常见的是这两个设备之间的参考地不一致。RS485虽然用的差分信号但收发器芯片的共模输入范围有限当两端设备的GND之间电位差过大A和B线上的共模电压就会超出芯片耐受范围导致接收端无法正确识别差分信号通信时好时坏。解决方法是把两端设备的GND连起来或者至少用三线制A、B、GND连接。注意这里连GND不是说把设备外壳连一起而是把两个电路板的信号地连通。很多工业设备会有专门的485屏蔽地端子接上屏蔽层单端接地就行。线缆长度超过几十米时还要检查终端电阻。标准做法是在总线两端各接一个120欧电阻匹配信号阻抗防止反射。如果只有两个设备短距离直连不接终端电阻问题不大。但如果中间扯了几百米线或者带了很多节点还不接终端电阻波形反射会把信号搞成一团糟表现同样是通信不稳定。5. 一些让通信更稳的经验5.1 时序轮询周期与超时重试Modbus主从通信是典型的“一问一答”主机必须管理好每个从站的帧间隔和超时。从站处理一帧请求需要时间所以主机不能在发送完请求帧后立即等响应而是要留一个合理的响应超时时间。我一般按波特率估算9600波特率下一字节大约1ms一个请求帧加响应帧约20字节考虑从站处理时间超时设100ms比较稳妥。115200波特率下再把超时降到20ms。轮询周期也要和从站的实时性匹配。如果主站每秒轮询一次就从站收一次从站程序里做阻塞延时没问题。但如果要从站实时响应中断或快速控制轮询周期就得缩到几十毫秒。注意不要让多个从站的轮询时间互相重叠建议用循环扫描而非并发请求否则RS485总线上会发生冲突。重试机制一定要有。我通常的做法是连续3次请求无响应就把该从站标记为离线报警提示然后继续轮询下一个从站而不是卡死在那里反复重试。现场总线偶尔丢一帧属于正常现象设计上要允许它丢但要在应用层兜底。5.2 广播和故障定位的妙用Modbus的地址0是广播地址主机可以向所有从站同时发写命令。这个特性在批量设置参数、同步校时等场景非常好用。但广播只支持写操作从站不回任何响应帧所以主机无法确认每个从站是否收到。我一般只在配置场景用广播正常轮询还是点对点。排查现场问题时我还会用“从站回环测试”的思路。在从站代码里预留一个测试寄存器比如地址0x00A0写一个特殊值就自动回复一串特征数据类似协议里的自检命令。这样现场工程师不用带PC也能验证通信链路是否可用。5.3 从站程序里的几个注意点写从站固件时有几个细节常年坑人中断优先级串口接收中断和定时器超时中断建议定时器优先级更高这样帧超时判断不会因为串口正在处理数据而延迟。如果两个中断互相打断容易把帧边界判错。关中断时间如果项目里有用到临界区保护确保关中断时间远小于半个字节时间否则串口接收会丢字节。寄存器更新可以由上位机添加。比如上位机先写一个“参数锁”寄存器值为0x5A才能改参数避免误操作。这个逻辑在工业现场非常实用。协议栈移植时要注意缓冲区大小。默认Freemodbus的收发缓冲区一般够用但如果寄存器数量很大比如一次读几百个寄存器缓冲区就要相应放大。缓冲区不够时从站会直接回异常码0x04从站设备故障而不是悄悄截断数据这个现象很容易误判成硬件问题。5.4 遇到疑难杂症用逻辑分析仪如果上述常规手段都排查过问题还在我强烈建议此时抓一下物理波形。方法很简单把逻辑分析仪的两个通道分别夹在485模块的A和B端设置触发沿为下降沿抓主机发出的一整帧请求和从站的响应。把波形放大观察每一位的时间宽度是否符合9600波特率下104微秒的位时间。从波形能直接看出太多问题位宽是否均匀、是否有毛刺、响应帧是否和请求帧有重叠、方向上切换是否及时。有一次我从波形里看到从站响应帧的第一位被拉出一个很窄的毛刺量下来是方向切换时收发器芯片的建立时间不够后来在代码里加了5微秒延时才彻底解决。这种事要靠示波器或逻辑分析仪才能发现用Modbus Poll只能看到一遍遍的超时。写在最后Modbus协议能火这么多年核心就俩字简单。它不追求性能极致而是把可靠性建立在清晰的帧格式和明确的时序约束上。对嵌入式工程师来说看懂一帧字节流、会用主流调试工具、掌握一套排查思路比背下一堆功能码更有用。我个人这几年调试Modbus的体会是大部分问题都不在协议本身而在物理层和应用层的细微之处——接线、共地、时序、字节序、阻塞延时。真的卡住的时候不要反复盯着代码乱猜把数据帧打开把波形抓出来问题信息永远比代码更诚实。希望这篇笔记能帮你少走几次弯路。