
如果你也跟我一样在某个深夜对着串口调试助手发呆看着串口回过来的全是0xFF像一杯水泼在玻璃上又弹回来那你应该能理解我写这篇笔记的心情。这周我在调一款工业采集模块上位机一直报通信异常我拿SSCOM直接发报文不论发什么从站都回0xFF。折腾了大半晚上最后的结论既不是模块坏了也不是线路虚焊而是我自己把功能码弄错了。于是我把MODBUS协议从头到尾重新啃了一遍从帧格式到寄存器映射从CRC16计算到RS485收发切换边查边记边改总算把从站跑通了。这篇就是这次调试的完整复盘也是我嵌入式调试笔记的第7篇。如果你是做单片机、嵌入式Linux、工业仪表或者PLC配套设备的工程师只要你需要在串口上跟设备打交道这篇笔记应该能帮你少走不少弯路。1. 为什么我在第7篇笔记里才认真啃MODBUS1.1 一个把简单协议搞复杂的现场那天的情况是这样的主站是一块组态屏从站是我手上的一块STM32采集板。组态屏这边配置好了“读保持寄存器起始地址0长度2”但屏幕上的数据一直是0。我第一反应是程序里寄存器地址没对上于是打开串口助手跨过组态屏直接给采集板发报文。我随手发的报文是01 05 00 00 FF 00意思是向1号从站的0号线圈写一个ON。为什么选05因为我脑子里默认“写功能码就是05读功能码就是03”。结果采集板回了一个0xFF。这里有个非常典型的现象很多从站程序处理不了非法请求时会直接回一个0xFF或者干脆静默。回0xFF的基本逻辑是“我收到了点东西但它不是我能理解的完整帧”。所以排查方向不应该纠结这个0xFF本身而是要问为什么我发过去的帧在从站看来是非法的回0xFF的三个高频原因地址域不匹配报文开头的地址不等于从站地址CRC16校验失败硬件收到了但软件解析时把帧丢弃功能码不受支持从站代码里根本没做05的处理。我那次属于第三种。而更深层的教训是MODBUS里“读寄存器”和“写线圈”是不同功能码不能靠“读用03写用05/06”这种模糊记忆吃饭。你在串口助手里面敲下去的每一个字节都要按照协议来少一个字节、错一个CRC、地址差一个数设备都不会给你想要的结果。1.2 MODBUS到底能干什么不能干什么在展开帧格式之前先把协议本身的定位说清楚。MODBUS是Modicon在1979年提出的应用层协议它跑在各种物理链路上最常见的是RS485上的MODBUS RTU还有MODBUS TCP、MODBUS ASCII。现在工业现场仪表、电量模块、温控器、变频器基本都带MODBUS接口所以它几乎是嵌入式工程师绕不过去的“普通话”。MODBUS是单主多从的请求-响应机制。总线上只能有一个主站主站发请求从站收到后回复同一时刻只允许一个从站占用总线说话。如果多个从站同时往总线上发数据485就冲突了主站收到的就是一堆乱码。这个机制简单可靠但代价是实时性一般适合采集类、控制类的非实时场景。我习惯用交通规则来类比UART只是给你铺了一条路字节可以在上面跑MODBUS规定了靠右行驶、红绿灯长什么样、路口怎么让行。没有MODBUS两个设备就算波特率完全一致也不知道对方发的这一串字节到底是什么意思。而有了MODBUS一帧数据发出去双方都能按统一规则解析出“这是谁、在干什么、数据是什么、有没有出错”。2. MODBUS RTU帧格式逐字节拆开看2.1 RTU帧的基本组成MODBUS RTU一帧由四部分组成从站地址、功能码、数据、CRC16校验。表格里先把每一段的职责写清楚后面所有内容都围绕这张表展开。字段长度说明从站地址1字节0x01~0xF70x00为广播地址从站收到广播后执行动作但不回复功能码1字节0x01~0x10等标识读还是写、读什么类型的数据数据N字节寄存器地址、数量、数据值等内容随功能码变化CRC162字节对整个帧从地址开始到数据结束做校验发送时低字节在前一个标准读保持寄存器请求例子01 03 00 00 00 02 C4 0B011号从站03读保持寄存器00 00从寄存器地址0x0000开始00 02读2个寄存器C4 0BCRC16发送时低字节C4在前高字节0B在后。从站正常响应01 03 04 00 0A 00 14 BA C1011号从站03功能码回显04数据字节数2个寄存器乘以2字节等于4字节00 0A、00 14两个寄存器的16位值十进制分别是10和20BA C1CRC16。帧与帧之间靠时间间隔区分。RTU标准要求帧内字节间隔不能超过1.5个字符时间帧与帧之间的静默时间至少3.5个字符时间。以9600bps、1起始位8数据位无校验1停止位为例一个字节是10位约1.04ms3.5个字符就是约3.6ms。我在从站程序里并没有严格卡1.5字符而是用串口空闲中断或一个5ms左右的定时器来判断帧结束实测下来在9600波特率下很稳。2.2 常用功能码与报文格式MODBUS常用功能码其实就那八个把表格记住大部分设备手册都能直接看懂。功能码名称操作对象典型用途01读线圈0x区位读DO输出状态02读离散输入1x区位读DI输入状态03读保持寄存器4x区字读参数、读统计数据04读输入寄存器3x区字读测量值、传感器值05写单个线圈0x区位控制一个DO06写单个寄存器4x区字修改一个参数0F写多个线圈0x区位批量控制DO10写多个寄存器4x区字批量修改参数逐个看报文格式会更清楚。读保持寄存器03的请求帧格式是“地址03起始寄存器地址2字节寄存器数量2字节CRC”。比如刚才例子里的01 03 00 00 00 02 C4 0B就是读1号从站从0x0000开始的2个寄存器。写单个寄存器06的请求帧格式是“地址06寄存器地址2字节数据2字节CRC”。比如01 06 00 00 00 0A表示把1号从站的0x0000寄存器写成10。从站正常应答时会把整个请求帧原样回显。写单个线圈05的请求帧格式是“地址05线圈地址2字节数据2字节CRC”。这里数据只能是FF 00表示ON或者00 00表示OFF。如果写成01 00很多从站会直接把它当非法数据值处理。从站无法正确处理请求时会返回异常响应帧。格式是“地址功能码|0x80异常码CRC”。也就是说功能码最高位置1比如0x03变成0x83。异常码常见的有01非法功能码02非法数据地址03非法数据值04从站设备故障。调试时看到异常码就能直接定位是请求本身问题还是从站内部问题比回0xFF友好得多。3. 存储区模型90%的联调失败都栽在地址映射上3.1 四个存储区先分清谁是谁MODBUS把数据分成四个存储区线圈Coil0x区、离散输入Discrete Input1x区、输入寄存器Input Register3x区、保持寄存器Holding Register4x区。刚开始学的时候这四个区的名字很容易看晕但只要抓住“数据类型”和“读写方向”两个维度就够了。存储区图号数据粒度读写权限常用功能码线圈0x位可读可写01、05、0F离散输入1x位只读02输入寄存器3x16位字只读04保持寄存器4x16位字可读可写03、06、10区分这四类的关键不是“地址大小”而是“数据方向”。比如传感器测量值通常放在输入寄存器里因为它是设备自己生成的上位机只能读而设置参数、控制字放在保持寄存器里因为上位机要改。很多工程师第一次写从站会把所有数据全塞到保持寄存器里其实也能跑只是不符合设备的行业约定跟组态屏对接时容易出问题。这里有一个点要特别说清楚“位”和“字”的区别。线圈和离散输入的一个地址对应一个bit读一次只返回0或1寄存器的一个地址对应一个16位整数。如果你想把一个32位float从保持寄存器里读出来协议层不会帮你拼它只保证你能把两个连续的16位寄存器读回来怎么拼成float是你业务逻辑的事。3.2 协议地址与设备编号之间的“差1”陷阱MODBUS协议帧里的寄存器地址是0x0000~0xFFFF从0开始编号。但很多PLC触摸屏、组态软件习惯用“40001”这种五位数来表示保持寄存器。40001对应的协议地址就是0x000040002对应0x0001。写“40001”和写“0x0000”说的是同一个寄存器。反过来协议地址0x0002如果配到组态软件上就要写成40003。这个1的差值让无数人翻过车。我见过有人直接照抄手册上的“输入寄存器30003”然后帧里写0x0003结果差了一个地址读回来的数据驴唇不对马嘴。麻烦的是有些设备手册直接给“寄存器地址0x0000~0x003F”这时就不用加1按帧里的原始地址填。所以拿到设备手册第一件事是确认它的地址表是“协议地址”还是“PLC图号”。如果它同时给出功能码和地址范围一般指协议地址如果写的是“40001/40002”这种五位编号那就是图号。拼接32位数据时还要注意字节序。一个32位浮点数放在两个寄存器里比如0x0000和0x0001。不同厂商的映射可能不同可能是高位字在低地址也可能是低位字在低地址就算寄存器顺序没问题还有大小端的问题。MODBUS本身规定寄存器是16位大端传输但32位float怎么映射到两个16位寄存器协议不管。调试时如果读出来的数值是一个很奇怪的小数先怀疑word顺序再怀疑byte顺序不要一上来怀疑CRC。4. 一次真实的主从联调排错全程从“回FF”到稳定读写4.1 现象与初步排查这次排错的全过程应该是本篇笔记里最有复用价值的部分。现象前面说了主站组态屏问数据返回0或者异常我用串口助手直发报文从站回0xFF。排查按下面步骤来。第一步确认物理层。RS485是差分信号A和B接线如果接反了设备根本收不到完整帧表现也是回0xFF或完全没响应。我拿万用表量A-B之间的静态电平正常空闲状态下A相对B是正电压大于200mV如果量出来是负的把线对调。短距离桌面调试时终端电阻可以暂时不接也能通信但线拉长之后会有反射表现为偶发丢帧这时候总线两端各加一个120欧电阻就好。第二步看波形。把逻辑分析仪挂在485芯片的RO引脚接收输出上直接抓UART波形。这一步要确认主站发的帧到底进没进MCU、波特率对不对、有没有拆帧。如果波形里能看到完整报文说明物理层和UART接收基本没问题问题在软件解析。第三步查软件解析。很多芯片的波特率是通过时钟算出来的如果系统时钟和预期不一致实际波特率会偏移尤其用内部RC振荡器跑9600的时候。排查方法是对着一帧已知报文测量帧总时长用帧长度乘以位时间反推实际波特率。比如9600波特率、10位一字节、8字节帧理论时长约8.3ms如果实测明显偏出就要回头查时钟配置。4.2 顺着时间线定位根因我这次把上述三步走完之后物理层没问题波形也正常最后用调试串口把从站收到的字节一个个打印出来发现CRC校验一直失败。于是我开始怀疑CRC计算抄错了。我拿请求帧01 03 00 00 00 02去对照标准CRC工具算出来是C4 0B程序里算出来也是C4 0B没问题。然后问题变得有意思了CRC没问题功能码明明是03为什么从站还认为非法我在软件里加了一行打印把接收缓冲区的字节都打出来才发现从站收到的并不是01 03 00 00 00 02 C4 0B而是被拆成了两段01 03 00和00 00 02 C4 0B。第一段长度不够程序把它当干扰丢弃第二段被当新帧解析从站地址是0x00这是广播地址从站按规则不能回复程序就超时返回0xFF。拆帧的原因在接收中断。我那时候每次只读一个字节用一个很短的定时器做超时判断。发现第一个字节后定时器设定的是3ms而实际两个字符之间在9600波特率下间隔大约1ms理论上是够的但我定时器里顺带做了其他事导致超时频繁触发一帧还没收完就判定“帧结束”。调整方法有两个。一是把定时器超时改成“从收到当前字节起重新计时”不要在第一个字节就固定倒计时二是直接用UART空闲中断判断一帧结束。STM32的空闲中断是硬件行为比软件定时器可靠得多。我最后改成空闲中断一帧数据完整进DMA缓冲区后再统一解析这个问题就彻底消失了。4.3 稳定之后的验证与轮询参数修好拆帧问题后从站能正常回01 03 04 00 0A 00 14 BA C1了但我没有马上收工。我把组态屏恢复连接让它作为主站开始轮询观察了一个多小时。轮询间隔我设置的是500ms每次读2个保持寄存器一帧8字节数据量很小。这里有一个经验工业总线上的轮询周期不要追求极限一般建议至少留出2到3倍的报文时长余量。9600波特率下8字节约8.3ms理论上1ms轮询都不至于超时但你要考虑从站负载、主站调度抖动、485切换时间通常留几百毫秒比较稳。另外还要注意地址规划。如果总线上接多个从站每个从站地址必须唯一。地址冲突会让两个从站同时回帧485冲突后主站收到的数据就是乱的。我的从站程序里做了地址配置寄存器上电时从EEPROM读地址改设备号不用重新烧固件。5. 从站程序里那些磨人的细节CRC、超时与状态机5.1 CRC16校验写对一次一劳永逸MODBUS RTU的CRC16基于多项式0xA001。标准计算过程是CRC寄存器初始化为0xFFFF对帧内每个字节先与CRC低字节异或然后右移8次每次如果移出的最低位是1就与0xA001异或。贴一份我一直在用的逐位算法static uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; while (len--) { crc ^ *buf; for (i 0; i 8; i) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }发送时有个坑CRC低字节在前高字节在后这跟很多协议“高位在前”的习惯正好相反。我第一次写的时候算出来的值跟工具对不上找了半天才发现是字节序搞反了。标准工具显示C4 0B帧里要发的顺序是先C4再0B。把这两个测试向量固化到单元测试里以后改代码跑一遍就知道CRC有没有被改坏请求帧01 03 00 00 00 02的CRC是C4 0B响应帧01 03 04 00 0A 00 14的CRC是BA C1。从站解析流程也要统一先收完整帧再做CRC校验CRC不过的帧直接丢弃不回复任何东西也绝不处理数据。这是MODBUS从站的基本修养。有些设备CRC失败还回异常帧容易让主站误以为设备在线但出错干扰整个链路的排障。5.2 从站状态机与RS485收发切换从站程序建议按状态机来写不要一进串口中断就处理业务。我的做法是空闲状态等待第一个字节接收状态积累帧数据用DMA接收或空闲中断判断帧结束校验状态解析帧头、做CRC16校验执行状态按功能码分发读取或更新寄存器应答状态组响应帧走RS485发出去。其中RS485方向的切换是最容易被忽略的细节。用MAX3485这类芯片时DE和RE通常接在一起一个GPIO控制收发方向。发送完最后一个字节后不能立刻把方向拉回接收因为UART移位寄存器可能还没完全送完立刻切换会把停止位截掉。我一般是在发送完成中断里延时1个字节时间再拉低DE或者干脆在发送函数里等待TC标志位置位后再切方向实测更稳。还有一个细节是广播地址。MODBUS规定地址0x00是广播地址从站收到广播帧后要执行但不回复。有些设备调试时会忽略广播不回这个规则主站发广播配置帧从站回了结果总线上多个从站同时回直接冲突。处理广播帧时要注意解析归解析回帧归回帧功能码需要广播时就只执行不回复。5.3 超时重试与异常处理主从通信总有丢帧的时候。从站侧容易忽略的一个参数是“等待下一字节的超时”。如果用DMA加空闲中断帧结束判断依赖UART空闲时间一般不需要额外处理如果用手动定时器一定要确保超时时间大于帧内字节间隔并且每次收到新字节都要重新计时。主站侧的超时重试更讲究。我一般把超时时间设置为从站最坏响应时间的2倍以上。如果从站最坏要50ms才能准备好数据主站超时设80ms还很容易误判。重试次数通常设3次超过3次才报通信故障不要频繁重试把总线占满。我实际遇到过一种情况主站把超时设成10ms从站其实5ms就能回但主站操作系统的调度抖动导致偶尔没等到就重发从站收到重复帧处理完又回一次看起来就是偶发重复报文。这类问题往往不是从站的问题而是主站超时参数不合理。联调时如果发现“偶尔重复、偶尔缺失”先查两边的时序参数不要急着改协议代码。6. 调试装备与实战心法6.1 串口助手之外我还会用这些工具串口调试助手SSCOM这类是基础装备但只适合手动发送和接收原始字节。手动发MODBUS帧有几个麻烦CRC要自己算、地址要自己调、长报文拼起来容易错。所以我在联调阶段通常先上MODBUS专用工具。Modbus Poll可以模拟主站Modbus Slave可以模拟从站。它们能自动组帧、自动算CRC、自动展示寄存器值。用Modbus Poll去读自己写的从站能把“协议问题”和“业务问题”分开页面上数据不对那就是从站寄存器映射或数值转换的问题页面超时那就是从站没有正确回帧。这个二分法能省掉大量排查时间。逻辑分析仪是抓波形的主力挂在UART的RX引脚就能看到主站到底发了什么、从站回了什么。很多逻辑分析仪软件支持UART协议解析把波特率设对它直接给你解析成十六进制字节排查拆帧问题非常好用。比示波器便宜对波段调试完全够用。我还习惯在关键排障时做一个“监听探针”用一个独立的串口模块并联在485总线上把主站发出来的原始报文录下来存成文件。调试到一半遇到无法复现的偶发问题回头翻监听日志往往能找到主站偶尔多发的某个异常报文。这种记录习惯比现场猜管用得多。6.2 防呆经验积累第一拿到任何设备的MODBUS手册先画一张地址映射表把功能码、寄存器地址、数据类型、缩放系数都列出来。这个过程逼着你把“图号”和“协议地址”的差值提前搞清楚而不是联调时踩坑。第二从站的CRC函数要单独拎出来做测试不要藏在业务代码里靠上机联调验证。用测试向量跑一遍确认CRC是对的联调时就不用反复拿它当怀疑对象。第三收到合法帧但功能码不支持时不要默不作声也不建议随便回0xFF。按标准回异常帧功能码最高位置1带上异常码。这样主站能明确知道是哪一类问题排查效率高很多。第四串口助手要设置成HEX显示和HEX发送别发ASCII。我见过不少新手在ASCII模式下输入“01 03”实际发出去的是字符0、1、空格、0、3的ASCII码从站当然不认。第五和组态屏、触摸屏联调时不要把所有功能码写完再试。先用Modbus Poll把通信调通再让上位机介入。两边同时都有问题的时候你根本不知道先查哪一边。说是MODBUS协议详解其实调试到现在我最大的体会是这类协议真正难的地方不在帧格式而在时序、字节序、地址映射这些“软件之外”的约定。协议文档十页就能看完但把它变成一个能稳定跑几个月的从站需要踩的坑远不止十页。后面如果有机会我想再写一篇串口DMA加空闲中断的架构笔记这是我当时从轮询改成中断之后感觉通信稳定性提升最大的一次改动正好和这次的状态机内容接得上。