eefocus_4229102 发表于 2026-9-16 16:52:13

抓包看到一串十六进制一脸懵?手把手教你拆解 Modbus RTU 报文

上一篇我们从物理层讨论了 RS485 总线的选型与布线。但线缆铺好、终端电阻焊对,只解决了"电信号能不能传过去"的问题;总线上跑的那些字节"代表什么意思、该怎么解析",则要靠协议层来约定。

RS485 本身只规定了电气特性,并没有帧格式。工程中最常"骑"在 RS485 上的协议就是 Modbus RTU。它简单、开放、久经考验,几乎所有 PLC、仪表、变频器、网关都支持。这一篇我们把一帧 Modbus RTU 报文彻底拆开,看清它的构成,再顺着几个典型场景看请求与响应是如何一来一回配合的。

Modbus RTU 的基本框架

Modbus 由 Modicon 公司于 1979 年提出,是一套主从(Master/Slave)架构的请求—响应协议,几个核心特征决定了帧的样子:

单主多从:总线上同一时刻只有一个主站发起通信,从站(地址 1~247)只能被动应答,绝不能主动上报。
一问一答:主站发一个请求帧,目标从站回一个响应帧,请求与响应成对出现。
半双工串行:所有字节在一条 RS485 总线上一位一位地顺序传输,帧与帧之间靠"静默时间"分隔。

Modbus 在串口上有两种编码模式,RTU 是绝对主流:


模式数据表示每字节开销校验特点
**RTU**直接二进制8 bitCRC-16紧凑、高效,本文主角
ASCII每字节转 2 个 HEX 字符~20 bitLRC 纵向冗余可读但冗余大,现已少见


RTU 的优势在于数据原样以二进制发送,同样的信息量占用字节更少,且用 CRC 校验比 ASCII 的 LRC 检错能力更强。

一帧 RTU 报文长什么样

无论功能码如何变化,任何一帧 Modbus RTU 报文都遵循同一套布局:地址域 + PDU(功能码+数据域)+ 校验域。

https://www.shiott.cn/file-server/images/article/fig_frame_structure.png

标准规定一帧最长 256 字节。字段含义如下:


字段长度作用
从站地址1 字节指定与谁通信,`0x00` 为广播,`0xFF` 一般为空闲/非法
功能码1 字节指定做什么操作,并决定数据域的格式
数据域0~253 字节请求参数或返回数据,格式由功能码定义
CRC 校验2 字节对前所有字节做 CRC-16 校验,**低字节在前**


从站地址域

地址占 1 字节,有效范围 0x01~0xF7(1~247)。0x00 是广播地址:主站发出的广播帧所有从站都会执行,但任何从站都不回复——因此广播只能用于"不需要确认"的写操作。每个从站在设备上通过拨码、旋钮或组态软件设置唯一地址,同一总线地址冲突是现场最常见的"通不上"故障之一。

功能码域

功能码决定这一帧要干什么,也决定数据域怎么解读。最常用的一组如下:


功能码名称用途面向对象
`0x01`读线圈 Read Coils批量读取开关量输出状态Coil
`0x02`读离散量输入 Read Discrete Inputs批量读取开关量输入状态Discrete Input
`0x03`读保持寄存器 Read Holding Registers读取可读写的寄存器值Holding Register
`0x04`读输入寄存器 Read Input Registers只读的量(如传感器采样值)Input Register
`0x05`写单个线圈 Write Single Coil强制某一个开关量Coil
`0x06`写单个寄存器 Write Single Register写某一个寄存器值Holding Register
`0x0F`写多个线圈 Write Multiple Coils批量写开关量Coil
`0x10`写多个寄存器 Write Multiple Registers批量写寄存器Holding Register


这里埋着一个关键机制:异常响应时,功能码 = 原功能码 + 0x80。例如主站用 0x03 请求,从站若出错,回来的帧功能码就是 0x83。主站靠这个最高位就能判断"这是正常回应还是报错"。

数据域

数据域的内容完全由功能码决定,可以是"要读几个寄存器"这样的参数,也可以是"寄存器返回的值"这样的结果,还可以是字节数等辅助字段。下面通过具体例子来看。

CRC 校验域

Modbus RTU 使用 CRC-16 校验,参数固定:


参数取值
多项式(反转型)`0xA001`
初始值`0xFFFF`
最终异或无
字节序**低字节在前**(先传 CRC-Low,再传 CRC-High)
计算范围从站地址 + 功能码 + 数据域(即 CRC 之前的全部字节)


计算过程简述:把 CRC 寄存器预置为 0xFFFF,逐字节与寄存器低字节异或,然后右移 8 次,每次右移若最低位为 1 就再异或 0xA001。全部字节处理完后,寄存器里的 16 位值即为 CRC,按"低位在前"附到帧尾。

https://www.shiott.cn/file-server/images/article/fig_crc_flow.png

实战提示:现场用抓包工具看到"数据明明对却报 CRC 错",九成是 CRC 字节序被写反(有的库按 High-Low 发送)。调试阶段可先固定一帧已知报文验证 CRC 实现。

帧与帧怎么分:3.5 字符静默

RTU 没有起始符、结束符,那接收端怎么知道"一帧从哪开始、到哪结束"?靠的是字符间的时间间隔:

https://www.shiott.cn/file-server/images/article/fig_framing_3_5.png

帧内任意两个字节之间的间隔必须 < 1.5 字符时间,否则会被误判为帧结束。
两帧之间必须有 ≥ 3.5 字符时间 的总线空闲,作为帧定界标志(Start/End of Frame)。

一个"字符"按 Modbus 规定含 11 位(1 起始位 + 8 数据位 + 1 校验位 + 1 停止位)。所以:

text
字符时间(ms) ≈ 11 × 1000 / 波特率3.5 字符时间= 字符时间 × 3.5

以 9600bps 为例,字符时间约 1.146ms,3.5 字符约 4.01ms。波特率越低,这个静默间隔越长——这也是为什么低波特率下帧间隔设置过小会造成粘帧(两帧被当成一帧)。

请求与响应的构成(逐字节拆解)

主站的请求帧与从站的响应帧格式并不相同。总体规律:

text
请求帧   = 地址 + 功能码 + 参数/数据 + CRC正常响应   = 地址 + 功能码 + 字节数/回显数据 + CRC异常响应   = 地址 + (功能码+0x80) + 异常码(1字节) + CRC

下面用几组最常用的功能码把这条规律走一遍。

① 读保持寄存器(功能码 0x03)

场景:主站想读取 1 号从站、起始地址 0x0000、连续 2 个保持寄存器。

https://www.shiott.cn/file-server/images/article/fig_read_holding.png

请求帧 01 03 00 00 00 02 C4 0B:


字节含义值(hex)
`01`从站地址目标 = 1 号
`03`功能码读保持寄存器
`00 00`起始寄存器地址`0x0000`
`00 02`寄存器数量2 个
`C4 0B`CRC低字节 C4 在前


响应帧 01 03 04 00 0A 01 2F 9A 7D:假设两个寄存器值分别为 0x000A、0x012F:


字节含义值(hex)
`01`从站地址应答 1 号自己
`03`功能码回显原功能码
`04`**数据字节数**2 寄存器 × 2 字节 = 4
`00 0A`寄存器 1 数据`0x000A`
`01 2F`寄存器 2 数据`0x012F`
`9A 7D`CRC


交互时序:

https://www.shiott.cn/file-server/images/article/fig_seq_read.png

注意响应中多出的 04 这个"字节数"字段——它是读类功能码的标志,告诉主站后面还有多少数据字节。

② 写单个寄存器(功能码 0x06)

场景:向 1 号从站地址 0x0001 的寄存器写入值 0x0003。


字节含义值(hex)
`01`从站地址
`06`功能码写单寄存器
`00 01`寄存器地址`0x0001`
`00 03`写入值`0x0003`
`98 0B`CRC


text
请求 = 响应: 01 06 00 01 00 03 98 0B

写单个寄存器的响应是请求的原样回显,主站收到后比对是否与自己发出的一致,即可确认写入成功。这是"确认机制"最简洁的一种。

③ 写多个寄存器(功能码 0x10)

场景:向 1 号从站起始地址 0x0001 连续写 2 个寄存器,值为 0x000A、0x012F。

https://www.shiott.cn/file-server/images/article/fig_write_multi.png

请求帧 01 10 00 01 00 02 04 00 0A 01 2F 52 2D:


字节含义值(hex)
`01`从站地址
`10`功能码写多寄存器
`00 01`起始地址`0x0001`
`00 02`寄存器数量2
`04`**数据字节数**2 × 2 = 4
`00 0A 01 2F`待写数据两个寄存器值
`52 2D`CRC


响应帧 01 10 00 01 00 02 10 08 只回"写了哪、写了几个":


字节含义值(hex)
`01`从站地址
`10`功能码
`00 01`起始地址回显
`00 02`数量回显
`10 08`CRC


边界:0x10 单帧最多写 123 个寄存器;对应地 0x03 单次最多读 125 个寄存器。数据量大时必须分包发送。

④ 读线圈(功能码 0x01)

场景:读 1 号从站起始地址 0x0000 的 8 个线圈。线圈是位(bit)级数据,返回时按位打包到字节里。

请求帧 01 01 00 00 00 08 3D CC:


字节含义值(hex)
`01`从站地址
`01`功能码读线圈
`00 00`起始地址
`00 08`线圈数量8 个
`3D CC`CRC


若 8 个线圈状态为 11001101(bit0 对应第一个线圈),打包成 1 字节 0xCD,响应帧 01 01 01 CD 90 1D:


字节含义值(hex)
`01`从站地址
`01`功能码
`01`数据字节数8 位 → 1 字节
`CD`线圈状态`1100 1101`
`90 1D`CRC


https://www.shiott.cn/file-server/images/article/fig_bitpack.png

⑤ 异常响应

如果从站收到合法帧但无法完成请求,返回异常响应。与前面的 0x03 读请求对应,若目标地址不存在,从站回 01 83 02 C0 F1:


字节含义值(hex)
`01`从站地址
`83`功能码 `0x03`+`0x80`标志异常
`02`异常码非法数据地址
`C0 F1`CRC


常见异常码:


异常码名称含义
`0x01`ILLEGAL FUNCTION不支持该功能码
`0x02`ILLEGAL DATA ADDRESS寄存器/线圈地址越界
`0x03`ILLEGAL DATA VALUE数据值非法(超范围)
`0x04`SLAVE DEVICE FAILURE从站内部执行失败
`0x0A`GATEWAY PATH UNAVAILABLE网关路径不可用
`0x0B`GATEWAY TARGET NO RESPONSE网关目标无响应


异常响应只有 1 字节异常码,不带数据域——这是它和正常响应最直观的区别。

完整一轮交互的时间线

把帧定界、请求、处理、响应串起来,一次成功的读操作在总线上是这样的:

https://www.shiott.cn/file-server/images/article/fig_seq_full_timeline.png

主站侧通常有两条超时:应答超时(等响应等多久算失败)和帧间静默。从站处理慢时,主站不能提前判失败,这也是现场把从站响应延时和主站超时配合设置的原因。

现场调试的几条经验

先看地址再看码:通不上先确认从站地址唯一且与主站点名一致;能通但数据错,重点核对功能码和数据域解读方式(尤其寄存器字节序)。
寄存器字序陷阱:Modbus 一个 16 位寄存器内部大端(高字节在前),但把 32 位浮点数拆成两个寄存器时,不同设备对"高字在前/低字在前"的约定不同(ABCD / CDAB / BADC / DCBA),跨品牌对接读出错乱数值多半栽在这里。
别滥用广播:广播写没有响应,主站无从确认是否写成功,只适合对可靠性要求低的批量复位等场景。
控制单次数量:读 125、写 123 是硬上限;轮询大量点时建议合理分组,兼顾实时性和总线负载。
半双工方向切换:主站发完到从站回之间,收发方向要切换,切换太快丢尾码、太慢压缩 3.5 字符间隔——很多"偶发 CRC 错"其实是收发器 DE/RE 控制时序问题,属于上一篇物理层与本篇协议层的交界地带。

小结

Modbus RTU 的一帧就是"地址 + 功能码 + 数据 + CRC"四段式:地址决定谁应答,功能码决定数据域怎么读、也决定响应长什么样,CRC 保证不采到坏帧,而 3.5 字符静默划清了帧的边界。理解了这套固定骨架,再对照每个功能码的字段表,几乎任何一帧报文都能手工拆解——这也是排查通信问题最扎实的基本功。

下一篇我们进入更具体的协议层疑难:地址冲突、波特率不匹配、轮询周期与超时设置、半双工冲突等实战话题,敬请关注。
页: [1]
查看完整版本: 抓包看到一串十六进制一脸懵?手把手教你拆解 Modbus RTU 报文

Baidu
map