写这篇笔记的时候,我刚从现场回来。一台温控表通过MODBUS RTU和PLC通信,现象是主机发指令从站完全没反应。朋友把波特率、站号、接线查了个遍,最后发现是USB转485模块的A/B线接反了——这种“低级错误”其实特别典型。很多朋友学MODBUS协议,把报文格式背得滚瓜烂熟,一到现场还是抓瞎,因为协议看懂和调试调通之间,隔着一条很宽的沟。这篇是嵌入式调试笔记第7篇,我把MODBUS协议从报文层面到物理层面完整拆一遍,再结合串口调试助手的实战操作,聊点文档里不会写的坑。适合正在用STM32、单片机做设备通信的嵌入式工程师,也适合刚接触工业总线、被一堆十六进制报文搞懵的初学者。把这篇吃透,至少能解决现场80%的MODBUS通信问题。
1. 为什么搞嵌入式的都躲不开MODBUS——先聊聊这个协议的地位
1.1 从Modicon到现场总线:半个世纪的老协议为什么还活着
MODBUS是1979年Modicon公司(后来并入施耐德电气)推出的通信协议,最初就是为了PLC之间、PLC和HMI之间通信设计的。到今天四十多年过去,工业现场还到处是它的身影:温控表、变频器、智能电表、流量计、气体探测器,甚至很多农业大棚里的传感器,都默认带MODBUS接口。
为什么这么老的东西还没被淘汰?两个原因:一是开放免费,协议规范公开,任何厂家都可以实现,不需要授权费;二是简单可靠,报文结构极其朴素,一个从站地址加一个功能码就能干很多事。你不需要像CAN那样处理复杂的仲裁机制,也不需要像EtherCAT那样搞分布式时钟,只需要一个串口加两根线,就能把一堆设备串起来。
对于嵌入式开发来说,MODBUS还有一层特殊意义:很多MCU项目的第一步就是“跑通串口”,而MODBUS就是建立在串口之上的最典型的应用层协议。你写驱动程序、调试DMA空闲中断、验证硬件收发通路,最终都要落到类似“01 03 00 00 00 01 D8 44”这样一帧报文上。把MODBUS吃透,串口通信能力也会跟着上一个台阶。
1.2 主从架构和三种传输模式:RTU、ASCII、TCP怎么选
MODBUS只有两种角色:主机(Master)和从机(Slave)。一台总线上只能有一个主机,从机最多挂247个,通信永远是主机发起请求、从机返回应答,从机绝对不可以主动往外发数据。这一点和很多人的直觉不一样——比如有个传感器报警了,它不能自己上报,必须等主机轮询到它,它才把报警状态带回来。
传输模式有三种,实际工程里最常打交道的是RTU模式:
| 模式 | 载体 | 数据表示 | 校验 | 典型场景 |
|---|---|---|---|---|
| RTU | RS232/RS485 | 二进制,8位一帧 | CRC16 | 工业现场绝大多数设备 |
| ASCII | RS232/RS485 | ASCII字符(每字节拆成两个字符) | LRC | 老设备、需要人眼直接读报文的场景 |
| TCP | 以太网 | 二进制,带MBAP报文头 | 无额外校验(依赖TCP) | 上位机、物联网网关 |
RTU效率最高,同样一条命令RTU只需要8个字节,ASCII要写17个字符,所以现在新设备基本都走RTU。TCP模式把串口那层换成了以太网,端口号固定502,报文里除了从站地址、功能码、数据区,前面还多了7个字节的MBAP头,用于事务处理标识和长度标识。调试网络设备时用Wireshark抓包最方便,但Wireshark对MODBUS TCP有解析器,能看到完整的报文结构——这个后面实战部分再细说。
2. RTU报文拆开看:地址码、功能码和数据区到底怎么读
2.1 一帧RTU报文的固定骨架
RTU模式下一帧完整的报文由四段组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 目标设备地址,范围1~247,0为广播地址 |
| 功能码 | 1字节 | 告诉从站要干什么,读还是写,读写什么 |
| 数据区 | N字节 | 根据功能码不同,长度和含义也不同 |
| CRC16 | 2字节 | 校验前面的所有字节,低字节在前发送 |
帧与帧之间还要求至少3.5个字符时间的静默间隔,如果两个帧之间的间隔太短,从站会把两帧误认为一帧。这也是很多自定义协议转MODBUS后通信错乱的根源——主站轮询太快,中间留的静默时间不够。
2.2 逐字节实战拆解:一条读请求报文的一生
拿最常见的“读保持寄存器”举例。主机要向1号从站读起始地址0x0000的1个寄存器,请求帧如下:
01 03 00 00 00 01 D8 44逐个字节看:
01:从站地址,目标设备是1号机。03:功能码,读保持寄存器。00 00:要读的寄存器起始地址,这里指0x0000。00 01:要读的寄存器数量,这里是1个。D8 44:CRC校验,对前6个字节进行计算,低字节D8在前,高字节44在后。
如果从站正常应答,会返回:
01 03 02 01 2B F9 CB01:自己的地址,确认是在回1号机的命令。03:回显功能码,表示正常执行了读保持寄存器。02:数据区字节数,后面跟2个字节,也就是1个寄存器。01 2B:寄存器值,0x012B,换算成十进制是299。如果设备定义这个寄存器放的是温度且分辨率为0.1℃,那就是29.9℃。F9 CB:CRC校验。
注意从站地址、功能码是回显的,但数据区不是回显,是两个完全不同的东西。主机发的是“起始地址+数量”,从机回的是“字节数+实际值”。这个区别初学者特别容易搞混。
3. 功能码全景:从01到16,每个数字背后的真实用途
3.1 位操作和寄存器操作的功能码分组
MODBUS的功能码看起来很多,实际上逻辑很简单,就是对设备的两种存储单位做读写操作:一种是“位”,只有0和1,对应开关量;另一种是“寄存器”,16位一个字,对应模拟量或数值。
| 功能码 | 作用 | 操作对象 | 读写方向 |
|---|---|---|---|
| 01 | 读线圈状态 | 输出位 | 读 |
| 02 | 读离散输入状态 | 输入位 | 读 |
| 03 | 读保持寄存器 | 输出寄存器 | 读 |
| 04 | 读输入寄存器 | 输入寄存器 | 读 |
| 05 | 写单个线圈 | 输出位 | 写 |
| 06 | 写单个寄存器 | 输出寄存器 | 写 |
| 15 | 写多个线圈 | 输出位 | 写 |
| 16 | 写多个寄存器 | 输出寄存器 | 写 |
这里有个重要概念:线圈和离散输入的区别在于,线圈是“输出型”的位,主机可以写,比如控制继电器吸合;离散输入是“输入型”的位,主机只能读,比如读一个行程开关的状态。寄存器同理,保持寄存器是可读可写的,输入寄存器是只读的。很多传感器设备内部有采集值,用04功能码读输入寄存器;而配置参数放在保持寄存器里,用03读或用06写。
3.2 实际设备中哪些功能码用得多
在我调试过的温控表、变频器、电表里,出现频率最高的是03、04、06、16这4个。
- 03:读保持寄存器,读设备配置参数、运行参数。
- 04:读输入寄存器,读传感器实时采集值。
- 06:写单个寄存器,修改单个参数。
- 16:写多个寄存器,批量下发参数或启动命令。
01和05用来控制继电器输出也常见,比如远程合闸、远程复位,底层就是把线圈写1或写0。15号功能码一般用于PLC批量控制多路DO。
还有一个容易忽略的点:功能码对不在范围或者不支持操作时,从站会把返回帧的功能码最高位置1来表示异常。比如你发了03,设备返回83,说明读保持寄存器这个操作异常了。紧接着的异常码会告诉你原因,这块放到实战部分详细讲。
4. 寄存器地址映射:工程师最容易绕晕的“偏移量”陷阱
4.1 PLC地址和协议地址的换算关系
MODBUS协议层定义的寄存器地址是从0x0000开始的纯地址,但绝大多数设备手册不会直接写0x0000,而是写“40001”这种PLC风格的地址。这两个之间差了整整40001个编号,第一次看到的工程师十有八九会懵。
| 数据类型 | PLC地址前缀 | 起始编号 | 协议地址范围 |
|---|---|---|---|
| 线圈 | 0 | 000001 | 0x0000~0xFFFF |
| 离散输入 | 1 | 100001 | 0x0000~0xFFFF |
| 输入寄存器 | 3 | 300001 | 0x0000~0xFFFF |
| 保持寄存器 | 4 | 400001 | 0x0000~0xFFFF |
映射规则很简单:PLC地址减去前缀基数,再减1,就是协议地址。比如温控表手册写“温度值存放在40003”,那就是40003 - 40001 = 2,协议地址是0x0002。你发请求时起始地址填0x0002,而不是0x0003,更不是40003。这是个极其常见的坑,我在现场见过有人直接把40003拆成0x4003发出去,结果设备一直返回非法数据地址。
4.2 数据类型才是真正的拦路虎
地址对上了,数据值的解读又是一关。MODBUS寄存器固定16位,但设备厂商并不统一数据类型:
- 16位无符号整数:最常见,比如温度、湿度、压力。
- 16位有符号整数:比如温度会出现负值,用无符号解读会得到一个65535附近的大数。
- 32位整数或浮点数:需要占2个寄存器,就有个大小端问题。有的设备高字在前,有的低字在前,甚至字节序也有讲究。
- 定点数:比如分辨率0.1,寄存器存的是299,真实值是29.9。
调试时如果读回来的值和现场明显对不上,先别急着怀疑通信,看看是不是数据类型搞错了。教你一个土办法:把寄存器值和手册里的量程对比一下,如果读回来是0xFFFF这种满量程值,很有可能是有符号负数被你按无符号读了。
5. 串口助手抓报文的完整实战:从发送到应答逐字节核对
5.1 硬件连接和串口参数设置
实战部分用最常见的工具组合:一台装着串口调试助手的电脑、一个USB转RS485模块、一台支持MODBUS RTU的设备。我用的是sscom串口调试助手,理由很简单——免费免安装、稳定、支持十六进制收发和定时发送,对MODBUS调试完全够用。
接线只有三条线要关心:USB转485模块的A接设备485端的A(或者标D+),B接B(或者标D-),另外把模块的GND和设备电源的GND共地。很多人第一反应是485只需要A/B两根线,凭什么还要共地?因为RS485是差分信号没错,但收发器的工作电压参考点还是依赖地电位,两个设备地电位差太大,会导致共模电压超范围,轻则通信不稳定,重则烧芯片。所以别偷懒,把GND接上。
串口参数在设备手册里查,绝大多数设备出厂默认是9600、8数据位、1停止位、无校验,也就是常说的8N1。sscom里这几个参数对应波特率、数据位、停止位、校验位,设置完点“打开串口”。
5.2 一个完整读请求的发送和响应分析
假设设备手册说明:1号从站,保持寄存器40001存放温度,分辨率0.1℃。我们要读当前温度。
按4.1节的换算规则:40001 → 协议地址0x0000,读1个寄存器。
在sscom的发送区输入:
01 03 00 00 00 01 D8 44勾选“十六进制发送”和“十六进制显示”,点发送。正常情况下接收区会出现:
01 03 02 01 2B F9 CB逐字节核对一遍:
| 字节 | 值 | 含义 |
|---|---|---|
| 1 | 01 | 1号从站的应答 |
| 2 | 03 | 功能码回显,读保持寄存器 |
| 3 | 02 | 数据区2个字节 |
| 4-5 | 01 2B | 寄存器值0x012B,即299 |
| 6-7 | F9 CB | CRC校验 |
299按分辨率0.1℃折算就是29.9℃。如果现场温度计显示30℃左右,说明通信和数据解析都对。
5.3 异常响应码的含义与处理
如果请求有问题,从站不会直接忽略,而是回一个异常帧。比如请求了不存在的寄存器地址,返回:
01 83 02第三个字节02就是MODBUS的异常码,表示“非法数据地址”。完整的异常码表如下:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 01 | 非法功能码 | 从站不支持该功能码 |
| 02 | 非法数据地址 | 寄存器地址越界 |
| 03 | 非法数据值 | 写入的值超出允许范围 |
| 04 | 从站设备故障 | 设备内部异常 |
| 06 | 从站忙 | 设备正在处理其他任务 |
调试时遇到异常帧,我的习惯是先记下功能码和异常码的组合,比如“03功能码返回83+02”,基本可以瞬间定位是地址算错了还是功能码不支持,不用瞎猜。
6. CRC校验“算不对”的坑:手动验算与代码实现双重验证
6.1 CRC16-MODBUS算法的计算过程
CRC是MODBUS RTU保证数据完整性的核心。协议采用的是CRC16-MODBUS标准:多项式0x8005,初始值0xFFFF,结果不额外异或。实现时通常用反射形式,即对每个字节和寄存器做异或后,逐位判断最低位,若为1则右移一位再异或0xA001。
很多人第一次手写CRC校验程序,算出来的结果和串口助手给的不一致,几乎都是这三个原因之一:初始值没用0xFFFF、多项式方向搞反、发送时高低字节顺序颠倒。
下面把之前那帧请求01 03 00 00 00 01的CRC手动演算一遍,方便理解算法本质:
- 初始化CRC寄存器为0xFFFF。
- 取第一个字节0x01,与低8位异或,得0xFFFE。
- 连续右移8次,每次检测最低位,如果为1就右移后异或0xA001,得到0x807E。
- 取第二个字节0x03,与0x807E异或,得0x807D。再右移8次,得到0x2140。
- 依次处理完0x00、0x00、0x01,最终得到0x44D8。
- 发送时低字节在前,所以CRC在报文里写作D8 44。
如果你在验证工具里输入相同的数据得到0x44D8,说明算法理解对了。这个手算过程比较繁琐,但建议至少亲手走一遍,因为很多奇奇怪怪的“通信偶发错误”其实就是CRC实现里某个细节写错了。
6.2 代码实现与常见错误
最常用的实现是查表法,速度比逐位运算快得多,适合在MCU中断里处理。下面是一段完整的C语言实现:
#include <stdint.h> // 逐位运算版本,代码直观,适合理解和调试 uint16_t modbus_crc_bitwise(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= *buf++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; } // 查表法版本,更高 static const uint16_t crc_table[256] = { // 用modbus_crc_bitwise预生成256项的查表,这里省略具体数据 }; uint16_t modbus_crc_table(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc = (crc >> 8) ^ crc_table[(crc ^ *buf++) & 0xFF]; } return crc; }发送时记得低字节在前:
uint8_t frame[8]; uint16_t crc = modbus_crc_bitwise(frame, 6); // 前6字节参与校验 frame[6] = crc & 0xFF; // 低字节 frame[7] = (crc >> 8) & 0xFF; // 高字节写校验代码时最容易踩的坑是把CRC算进参与校验的字节里,这样怎么算都不对。参与校验的只有从站地址、功能码和数据区,CRC两个字节本身不算。还有就是有的从站对CRC时序很敏感,如果帧间间隔控制不好,即使CRC正确也会被从站丢弃。
7. 物理层的隐藏杀手:RS485接线、终端电阻与电气异常排查
7.1 RS485的接线规范和常见错误
报文、CRC、寄存器地址全都没问题,设备还是偶尔掉线,这时候就得往物理层找原因。RS485用两条差分线A和B传输,靠A、B之间的电压差来区分逻辑0和逻辑1。接线错误是现场最高频的物理层故障。
A/B接反的表现很典型:主机发请求,从站完全不应答,或者收发都正常但数据全是乱码。因为A/B反过来以后,差分信号的极性完全颠倒,设备收到的每一位都和实际相反。排查方法也简单,把A/B对调一下再试。
另一个常见问题是终端电阻。RS485在高速率或长线缆传输时,信号会在线路末端反射,导致波形畸变。规范做法是在总线最远端的两个设备上各并联一个120Ω终端电阻。但注意,不是所有场合都必须要,短距离(几米)低速(9600)通常不接也没事。如果接了电阻,却不小心接在中间节点上,反而会增加总线负载,降低信号质量。
7.2 电气异常藏在报文背后的表现
用万用表可以快速判断RS485链路是否健康。总线空闲时,A相对B的电压差应该在2V以上,一般实测2~5V正常;通信时电压会跳变,用万用表的直流档能看到读数波动。如果A/B对地电压接近0V,或者A/B之间压差几乎为0,基本可以判定收发器或线路有问题。
还有一些表现容易被误判为软件问题:
- 发送后有时能收到应答、有时收不到:大概率是接线接触不良或端子松动。
- 波特率越高越不稳定:可能是线缆过长、终端电阻缺失或接地不良。
- 多台从站地址都设为1:总线上地址冲突,所有地址为1的设备会同时应答,数据立刻乱套。
- 从站偶尔回异常码06(从站忙):主站轮询太快,从站处理不过来。解决办法是把轮询周期拉长到200ms以上,或者对每个请求增加重试机制。
调试RS485时,条件允许最好用示波器看波形。正常的RS485空闲电平是固定的偏置电压,通信时能看到清晰的差分方波。如果波形边沿有严重的振铃,多半是终端电阻没匹配好;如果波形幅值明显偏低,多半是总线挂载设备太多,驱动能力不够。
8. 调试现场的问题定位思路:从“没反应”到“数据不对”的排查路径
8.1 三类典型故障现象与排查优先级
面对一个MODBUS通信故障,我习惯先把现象归成三类,然后按“物理层→链路层→应用层”的顺序逐层排查,不要一上来就怀疑协议配置。
| 故障现象 | 优先排查方向 | 具体手段 |
|---|---|---|
| 完全不通信,主机发从站无任何响应 | 物理层 | 检查A/B是否接反、电源是否正常、GND是否共地、串口是否打开 |
| 有响应但数据乱码或CRC错误 | 参数与链路 | 核对波特率/数据位/停止位/校验位、检查帧间隔 |
| 通信正常但读值不对 | 应用层 | 核对寄存器地址换算、数据类型、大小端、分辨率 |
第一类“完全没反应”里,我最常遇到的其实是串口助手没选对COM口,或者USB转485模块的驱动没装好。这类问题技术含量为零,但对新手杀伤力极大。所以排查的第一步永远是:打开设备管理器,确认USB转串口枚举出来的COM口号,再和串口助手里选择的一致。
8.2 我的一套“报文速查表”工作法
调试经验多了以后,我逐渐养成了一个习惯:在调试任何MODBUS设备之前,先根据设备手册做一张“报文速查表”,把常用操作对应的请求帧提前算好写在上头。比如:
| 操作 | 请求帧 | 说明 |
|---|---|---|
| 读40001温度 | 01 03 00 00 00 01 D8 44 | 1号从站,起始地址0x0000,读1个寄存器 |
| 读40003湿度 | 01 03 00 02 00 01 ... | 起始地址0x0002 |
| 写40010开关 | 01 06 00 09 00 01 ... | 起始地址0x0009,写值为1 |
| 读全部输入寄存器 | 01 04 00 00 00 10 ... | 读16个输入寄存器 |
这张表的价值不只是省去临时算CRC的麻烦,更重要的是它能帮你快速排除“请求本身写错”的可能性。每次遇到通信故障,先用速查表里最简单的“读温度”这一条发出去,如果这个都通,说明物理层和链路层没问题,问题大概率在应用层解析;如果连这条都不通,那就老老实实回到物理层查线。这种“最小请求”的思路,能把调试范围快速缩小,避免在一堆可能因素里瞎转。
遇到那种手册都不全的杂牌设备,还有个土办法:发送广播地址0的请求,或者逐个试探从站地址1到247,看哪个地址会有响应。这招在设备地址被人改过、不知道当前地址的时候特别管用。你不需要把所有地址都试完,一般从1开始试到20以内就能命中大部分设备。
最后再说一个很多人不注意的细节:串口助手的“定时发送”功能虽然好用,但轮询周期不要设太短。我见过有人设成10ms连发,结果把从站直接“问死”了,从站忙异常码满天飞。标准的做法是每帧发送之间留足时间,至少50到100ms起步,现场调试完再根据最坏情况慢慢调短。协议这玩意儿,跑通容易,跑稳才是真功夫。