ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modbus RTU报文解析:功能码、CRC与字节序实战避坑

Modbus RTU报文解析:功能码、CRC与字节序实战避坑

简介:这份PDF面向工业自动化、PLC与嵌入式通信开发初学者及现场调试人员,围绕Modbus协议功能码与报文实例展开,帮助读者理解主从设备如何通过统一消息结构完成线圈、输入状态、保持寄存器与输入寄存器的读写。全包仅1个PDF文件,约10KB,篇幅紧凑,便于随查随用;内容以01~06等常用功能码为重点,兼及15、16、17等扩展功能,并给出设备地址、命令号、起始地址、寄存器数量与CRC校验的报文组织方式,配合读写数字量和模拟量的请求/响应示例说明字段含义。已有2332人学习,适合用来对照现场报文、排查通信异常,并快速建立功能码与寄存器地址的对应关系。

1. Modbus 的"笨":为什么一个没有握手和长度字段的协议能活四十年

Modbus 第一次看会让人觉得简陋:没有握手、没有会话、没有长度字段、没有认证,一帧最多 256 字节。可就是这样一套上世纪定下的规矩,至今仍是 PLC、变频器、电表、传感器的公共语言。反直觉的地方在于,它活下来不是因为功能强,而是因为每一帧都自带全部上下文——接收方不需要记住上一帧说过什么,就能独立解析这一帧。功能码就是这套自解释能力的支点:它既说明"要做什么",又隐含"后面还有多少字节"。搞懂这一点,再看那些 01、03、05、06 的报文例子,就不会只停留在背格式的层面。

2. 报文骨架:功能码凭什么能替长度字段

2.1 一条 485 报文逐字节拆开

先看一条最典型的读线圈请求,原始字节是:

[11] [01] [00] [13] [00] [25] [CRC低] [CRC高]

八个字节,一个不多一个不少。把它按字段切开:

  • 11是从站地址的十六进制写法,十进制是 17。一条 485 总线上挂多个设备时,这个字节决定谁来回话,其余设备保持沉默。
  • 01是功能码,表示"读线圈"。
  • 00 13是起始地址,高字节在前,合成 0x0013,十进制 19。
  • 00 25是读取数量,0x0025,十进制 37,表示从 19 号开始连续读 37 个开关量。
  • 最后两字节是 CRC16 校验,覆盖从站地址到数量字段的全部内容。

这条报文里没有"总长度"字段,也没有"报文类型"字段。接收方拿到第一个字节知道该不该理,拿到第二个字节知道要执行什么操作,然后就知道后面还有几个字节要读。整个解析过程是顺着字节流一路推下去的,不需要回看历史。

2.2 长度的缺席:功能码如何承担"自描述"

Modbus RTU 帧没有长度域,这是它最容易被低估的设计。绝大多数二进制协议会在头部放一个 Length 字段,接收方先读长度再读负载,逻辑很直白。Modbus 反过来:接收方必须靠功能码去查"这一帧该有多长"。

这带来两个直接后果。第一,功能码的取值集合必须是封闭且已知的。协议把 1 到 127 划给公共功能码,其中真正被广泛实现的只有 1、2、3、4、5、6、15、16 这几个,加上 7、8、11、12、17 这类诊断和标识功能码。128 到 255 留给异常应答,最高位被置 1 表示"我拒绝执行"。第二,请求帧和响应帧的长度规则不一样:请求帧往往定长,响应帧因为要带数据,需要一个额外的"字节数"字段来补齐长度信息。

以 01 功能码为例。请求永远是 8 字节,接收方读到功能码 01 就知道后面固定还有 6 个字节。而响应是[地址][01][字节数][数据...][CRC低][CRC高],数据部分长度由"字节数"字段给出,读线圈 37 个需要 5 个字节承载(37 除以 8 向上取整),所以字节数就是 05。这是一种半自描述:请求靠功能码定长,响应靠功能码加计数字段变长。

注意:正因为长度信息不是显式字段,任何扩展功能码的私有实现都必须同步修改两端的解析器,否则接收方会按错误长度截断缓冲区。

2.3 一个解析器的骨架

把上面的规则固化下来,解析逻辑大致长这样。这段代码不追求完整,目的是让"功能码决定长度"这件事落在可以执行的形状上:

# 简化的 Modbus RTU 帧解析骨架 def expected_request_len(func: int) -> int: # 常见请求帧:地址(1) + 功能码(1) + 4 字节参数 + CRC(2) if func in (0x01, 0x02, 0x03, 0x04, 0x05, 0x06): return 8 # 15/16 批量写:地址 + 功能码 + 起始(2) + 数量(2) + 字节数(1) + N + CRC if func in (0x0F, 0x10): return -1 # 长度依赖后续的字节数字段,需动态计算 return -1 def parse_response(buf: bytes): if len(buf) < 5: return None, "帧过短" slave, func = buf[0], buf[1] if func & 0x80: # 异常响应:功能码最高位置 1,第 3 字节是异常码 return {"slave": slave, "error": buf[2]}, None byte_count = buf[2] if len(buf) != 3 + byte_count + 2: return None, f"长度不匹配: 期望 {3 + byte_count + 2}, 实收 {len(buf)}" return {"slave": slave, "func": func, "data": buf[3:3 + byte_count]}, None

expected_request_len里的 8 是"地址 1 + 功能码 1 + 参数 4 + CRC 2"。parse_response先看功能码最高位判断是不是异常帧,再用字节数字段反推总长度。两个函数合起来,就是接收端在不看长度字段的情况下把一帧切出来的全部依据。

2.4 3.5 字符间隔:帧边界的另一种定义

RTU 模式没有起始符,也没有结束符。帧与帧之间靠静默时间区分:总线空闲超过 3.5 个字符传输时间,就认为上一帧结束了。9600 波特率下,一个字符 11 位(1 起始 + 8 数据 + 1 校验 + 1 停止),约 1.15 毫秒,3.5 字符大约 4 毫秒。

这解释了为什么 Modbus 对时序敏感,也解释了为什么很多从站实现里会有一个定时器中断专门做帧超时判断。如果你用 USB 转 485 的转换器,驱动层的缓冲策略可能把两帧粘在一起发出去,从站收到的就是一坨无法解析的字节流。现场遇到"偶发不响应",先怀疑的不是 CRC,而是帧间隔被破坏了。

3. 四个数据域:为什么是位/字 × 只读/读写这四张表

3.1 四个象限的分法

Modbus 把从站里可访问的数据按两个维度切分:单位是位还是 16 位字,访问方式是只读还是读写。两两组合,正好四张表:

数据域单位访问方式读功能码写功能码
线圈(Coil)1 位读写0105 / 15
离散输入(Discrete Input)1 位只读02无
保持寄存器(Holding Register)16 位读写0306 / 16
输入寄存器(Input Register)16 位只读04无

这张表是理解整个协议的地图。拿到一个功能码,先定位它落在哪个象限,再去看报文格式,思路就清楚了。01 和 02 的请求格式完全一样,差别只在功能码字节;03 和 04 同理。05 和 06 都是单点写,15 和 16 都是批量写,区别只在操作对象是位还是字。

3.2 为什么只读和读写要分成两张表

物理来源不同是最根本的原因。离散输入通常接实际的限位开关、按钮、继电器触点,这类信号对控制器来说只应该被观测,不应该被程序改写;线圈则是对应的输出继电器,需要能被强制置位。把只读和读写拆成不同地址空间,从站实现时可以分别映射到不同的内存区域,一个指向输入寄存器组,一个指向可写的 RAM 或 EEPROM。

这种拆分还带来一个副作用:它把"误写"挡在了协议层。主机想改一个离散输入,协议里根本没有对应的写功能码,只能作罢。工业现场对确定性要求高,少一个可写入口,就少一类翻车方式。

3.3 地址从 0 开始,文档却写 00001

这是新手最容易踩的坑。报文里的地址是 0 基的:起始地址字段填 0x0000,表示从第一个线圈开始读。但大量设备手册沿用了早期的编号习惯,用五位数表示:

  • 00001–09999 对应线圈
  • 10001–19999 对应离散输入
  • 30001–39999 对应输入寄存器
  • 40001–49999 对应保持寄存器

编号里的第一位只是区号,后四位才是偏移。手册写"线圈 00020",实际报文里的地址是 19。如果你照着 20 去组帧,读到的永远是错位一个的数据,而且 CRC 还校验通过,从站也正常回包,现象非常迷惑——数据看起来"有值",只是整体偏移了一位。

提示:拿到任何一份寄存器表,先用功能码和区号反推真实地址,再用一条单点读命令验证,不要直接批量读。

3.4 功能码全集的分层

除了常用的 1 到 6 和 15、16,协议里还有一批很少被实现的功能码。07 读异常状态、08 回送诊断校验、11 读事件计数、12 读通信事件记录、17 报告从机标识,这些属于诊断与标识类。09、10、13、14、18 是早期为特定型号设备保留的编程与轮询功能,现代设备基本不实现。19 重置通信链路,在发生不可恢复错误后让从站回到已知状态。20、21 读写通用参数,针对扩展存储文件。22 到 64 保留给扩展,65 到 72 留给用户自定义,73 到 119 被定义为非法,120 到 127 保留,128 到 255 用于异常应答。

这个分层本身就是设计取舍的证据:协议把大部分编码空间留空,只保证一小撮功能码在所有设备上行为一致。互操作性来自"少而确定",不来自"多而灵活"。

4. 打包细节:位如何塞进字节,寄存器如何排高低位

4.1 01/02 的位打包:LSB 先,末字节高位补零

读线圈的响应里,数据部分是按字节打包的位串。原始例子里,请求读 37 个线圈,响应数据是 5 个字节:CD 6B B2 0E 1B。第一个字节CD的二进制是1100 1101。

关键在位的顺序:最低位对应起始地址那个线圈,依次往高位排。所以CD展开后,起始地址 19 号对应的位是 bit0,值为 1,表示闭合;20 号对应 bit1,值为 0,断开;21 号 bit2 为 1,闭合;22 号 bit3 为 1;23 号 bit4 为 0;24 号 bit5 为 0;25 号 bit6 为 1;26 号 bit7 为 1。这就和原始资料里"20 号闭合、21 号断开、22 闭合、23 闭合、24 断开、25 断开、26 闭合、27 闭合"对上了,只是编号是 1 基。

最后一个字节存在对齐问题。37 个线圈不是 8 的整数倍,5 个字节共 40 位,最后 3 位是无意义的填充位,协议要求填 0。解析时要把读取数量记住,只取前 37 位,否则会读出三个幽灵线圈。

4.2 03/04 的寄存器:每寄存器两字节,高字节在前

读保持寄存器的响应数据是每个寄存器两个字节,高字节在前。原始例子响应[11][03][06][02][2B][00][00][00][64],字节数是 06,表示三个寄存器共 6 个数据字节。拆开是02 2B、00 00、00 64,对应十进制 555、0、100。

这里没有位序问题,但有字节序问题。Modbus 的寄存器是大端,而 CRC 是小端,同一帧里两种字节序并存,这在第 5 章会单独讲。另外要注意,16 位寄存器装不下 32 位浮点数或 32 位整数,很多设备用两个连续寄存器拼一个 32 位数,拼接顺序(高字在前还是低字在前)由厂商决定,协议本身不管。遇到浮点数读出来是天文数字或接近零,先怀疑字序反了。

4.3 05/06 的回显语义:最简单的确认机制

写单个线圈(05)和写单个寄存器(06)的响应是把请求原样返回。原始例子请求[11][05][00][AC][FF][00],响应也是这八个字节。成功与否不看响应内容,看有没有响应。从站执行失败时不回包,主机靠超时判定。

05 的数据字段只有两个合法值:FF 00表示闭合,00 00表示断开。填00 01是非法的,从站可以拒绝。这个设计看着别扭——一个位为什么要用两个字节表达——但它避免了和 06 的格式分裂:两个功能码的报文骨架完全一致,都是"地址 + 功能码 + 目标地址 + 值 + CRC",解析器可以复用。

异常情况下,从站返回功能码最高位置 1 的帧,第三字节是异常码。常见的异常码有 01(非法功能)、02(非法数据地址)、03(非法数据值)、04(从站故障)。看到81、83这样的功能码,就知道是异常应答,不要再按正常响应去解析数据。

4.4 15/16 的批量写与组帧代码

批量写比单点写多了两个字段:写入数量和字节数。以 15(强置多线圈)为例,请求是"地址 + 功能码 + 起始地址 2 字节 + 数量 2 字节 + 字节数 1 字节 + 数据 N 字节 + CRC 2 字节"。响应则回显前 6 个字节再加 CRC。16(预置多寄存器)结构一样,只是数据部分是 2×N 字节。

组帧时最容易出错的是 CRC 的字节序。下面这段代码把读线圈和 CRC 计算放在一起:

import struct def crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def build_read_coils(slave: int, start: int, count: int) -> bytes: # PDU:功能码 0x01 + 起始地址 + 数量,均按大端打包 pdu = struct.pack(">BHH", 0x01, start, count) frame = bytes([slave]) + pdu c = crc16(frame) # CRC 低字节在前,与其余字段的大端顺序相反 return frame + bytes([c & 0xFF, (c >> 8) & 0xFF]) # 例:读 17 号从站,起始 19,数量 37 print(build_read_coils(0x11, 19, 37).hex(" "))

struct.pack(">BHH", ...)里的>表示大端,B是一个字节,H是两个字节。crc16用的是多项式 0xA001 的查表前版本,初值 0xFFFF。最后拼接时先放c & 0xFF再放高字节,这一步如果写反,从站会直接丢弃整帧,而且没有任何错误提示,只能靠抓包对比。

参数count有上限:读线圈一次最多 2000 个,读寄存器一次最多 125 个。超了从站返回异常码 03。上限来自 PDU 最大 253 字节的约束,不是拍脑袋定的。

5. 常见问题:CRC 字节序、FF00 与地址偏移的坑

5.1 CRC 高低字节顺序与其余字段相反

现象:从站完全不响应,主机侧超时,换一条命令依旧。

原因:Modbus 帧里地址、起始地址、数量都是高字节在前,唯独 CRC 是低字节在前。很多人写组帧代码时顺手把 CRC 也按大端拼了,从站算出来的 CRC 和收到的不一致,直接丢帧。

解决:CRC 单独处理,先取低 8 位再取高 8 位。写完之后用一条已知正确的报文反算验证,比如原始资料里的11 01 00 13 00 25,算出来的 CRC 应该能和示例对上。

5.2 05 功能码的数据只能是 FF00 或 0000

现象:写线圈命令发出去,从站不响应或者返回异常码 03。

原因:协议规定 05 的数据字段只有两个合法值,闭合是FF 00,断开是00 00。有人图省事填00 01,从站按非法数据处理。

解决:组帧时对布尔值做显式映射,不要直接把 1 打包进去。这条规则在 15 功能码里不适用,15 的数据是按位打包的字节串,正常填 0/1 位即可。

5.3 数量超限触发异常码 03

现象:小批量读正常,一读几百个寄存器就返回83 03。

原因:03 功能码一次最多读 125 个寄存器,01 最多 2000 个线圈。超限时从站返回异常码 03(非法数据值),不是 02(非法地址),这两个异常码容易混。

解决:按上限分批读,每批之间留出帧间隔。批量写同理,16 功能码一次最多 123 个寄存器,因为请求里还要占掉字节数和 CRC 的位置。

5.4 地址 0 基与 1 基混用导致整体偏移

现象:读回来的数据"有值",但和预期对不上,而且每一路都偏移一位。

原因:设备手册用 40001 这种编号,报文地址是 0 基。手册写"保持寄存器 40001",报文里应该填 0x0000,填 0x0001 就整体后移一位。

解决:建立一张映射表,把手册区号和偏移拆开,统一换算成 0 基地址再组帧。换算完之后用单点读验证第一个和最后一个地址,两头对上,中间一般不会错。

5.5 广播地址 0 与保留地址段

现象:想同时写多个从站,发了地址 0,所有从站都执行了,但一个都不回包,主机一直等到超时。

原因:地址 0 是广播地址,从站执行但不响应。地址 248 到 255 是保留地址,不应分配给实际设备。

解决:广播只用于写命令,发完不要等响应,靠后续的读命令确认结果。单播地址范围控制在 1 到 247 之间,分配时避开设备出厂默认地址,否则总线上出现两个同地址设备时会互相干扰,现象是两个设备的行为都变得随机。

6. 设计取舍:无状态协议在工业现场为什么反而更可靠

把 Modbus 和后来那些带会话、带长度字段、带认证的协议放在一起看,会发现它几乎在每个维度上都做了"减法"。没有连接的概念,每一帧独立自包含;没有长度字段,靠功能码推断;没有序列号,靠回显和超时确认;没有认证和加密,默认物理链路是可信的。这些减法换来的是一件事:从站的实现可以小到跑在 8 位单片机上,几 KB 的 ROM 就够。

代价也很明确。没有长度字段,意味着功能码集合必须封闭,私有扩展要两端同步改解析器;没有会话,意味着没法做流控,主机只能靠轮询节奏控制负载;没有认证,意味着任何能接上总线的人都能写线圈。这些在办公网络里不可接受的缺陷,在一条几十米长、物理隔离、速率 9600 的现场总线上,反而变成了"够用且可预测"。

字节序的不一致是另一个典型取舍。数据字段大端,CRC 小端,同一帧里两种顺序。从一致性角度看这是瑕疵,但从实现角度看,CRC 计算时逐位右移、低字节先出,天然就是小端;而多字节数值按大端排列,人和调试工具读起来更顺。协议没有为了"统一"去牺牲任何一边的便利,这种不讲究,恰恰是它能被大量低成本设备实现的原因。

验证一套 Modbus 实现是否可靠,我习惯做三件事。第一,拿一条已知正确的报文做基准,手工算出 CRC,和代码输出逐字节对比;第二,构造边界用例——读 0 个、读 2001 个、写非法值、访问越界地址,看异常码是否落在预期集合里;第三,用抓包工具对照时间轴,确认帧间隔没有被转换器压掉。这三步走完,大部分"玄学不响应"都能定位到具体字节。

早些年调试一条总线,从站偶发不回包,查了两天 CRC 和地址,最后发现是转换器的缓冲把两帧粘成了一帧,从站按第一帧的长度截断,剩下的字节全成了噪声。从那以后我每次接新设备,都强制先用单帧、单点、最小数量的命令跑通一轮,再逐步加量。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进