ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Modbus RTU功能码与CRC调试实战:从报文到回归用例

Modbus RTU功能码与CRC调试实战:从报文到回归用例

简介:这份 PDF 文档围绕 Modbus 协议功能码与通信实例展开,面向工业自动化、嵌入式开发和现场调试人员,帮助读者理解主从站报文结构、功能码语义及常见读写操作。内容以功能码速查表为主线,梳理 01 至 22、保留扩展和异常应答区间,并重点比较 01、02、03、04、05、06 等常用码在数字量与模拟量读写中的差异。实例部分给出读线圈、读输入状态、读保持寄存器、读输入寄存器、强置单线圈和预置单寄存器等请求与响应报文,逐项解释设备地址、命令号、起始地址、寄存器数量、数据字节及 CRC 校验,并提示不足 8 位时的补零规则和异常应答处理,便于对照现场设备排查通信故障。资源包为 1 个 PDF 文件,约 10KB,轻量便于随查随用;已有 2329 人学习,适合需要快速掌握功能码语义与报文解析的初、中级技术人员。

1. 从一串 11 01 00 13 00 25 说起:这份功能码手册到底能省掉哪些猜测

手头这份《Modbus 通讯功能码及实例.pdf》,第一眼像张速查表,翻到中间才发现它把 01 到 21 号功能码的报文格式、示例帧、以及每一个字节的含义都拆开写了。像[11][01][00][13][00][25][CRC低][CRC高]这种十六进制串,配上校验规则,基本可以直接粘进串口调试工具去试。它解决的问题很具体:面对一台只给了寄存器表、没给驱动、说明书还是扫描件的下位机,你怎么在半小时内把一个值读出来、再写回去。

适合两类人。一类是刚接手 485 总线调试、被“设备地址”和“寄存器地址”两个概念绕晕的新手;另一类是写了好几年采集程序、一直靠现成库函数过日子的人——库是个黑匣子,只有把帧拆到字节,你才知道它在背后替你补了哪些默认值、又在哪些地方悄悄做了容错。后面几章就按“帧结构 → 线圈类 → 寄存器类 → 翻车排查 → 回归用例”的顺序往下走,每一步都能落到可执行的东西上。

2. 一帧 RTU 的解剖:8 个字节里 CRC 为什么总要反着放

2.1 地址、功能码、数据、CRC 四段的定位

Modbus RTU 的一帧(准确地说是 ADU)由四段拼成,长度不固定,但首尾固定。手册里的所有示例帧都遵循这个骨架,只是数据域长短不同。

字段长度取值与含义常见误区
从站地址1 字节1–247,0 为广播(从站不回复)以为地址可以是 255,实际 248–255 保留
功能码1 字节01–7F,异常响应时最高位置 1看到 0x83 以为是新功能码,其实是 03 出错
数据域0–252 字节由功能码决定,请求和响应不同把请求格式套到响应上去解析
CRC 校验2 字节低字节在前,高字节在后按常规大端顺序拼接,导致校验全错

功能码出错时从站不会静默,它会返回“功能码 + 0x80”,后面跟一个字节的异常码。手册正文里没把异常码表展开,但实际排查中这几个最常撞见:01非法功能(设备不支持这个功能码)、02非法数据地址(寄存器号超范围)、03非法数据值(写入了不允许的值,比如 05 功能码写了个 0x0001)、04从站设备故障、06从站忙。抓到异常帧比抓到超时幸福得多,至少它告诉你是哪一类错。

还有一条物理层的约定容易被忽略:RTU 靠静默时间断帧,帧内字符间隔不能超过 1.5 个字符时间,帧间至少 3.5 个字符时间。9600 波特率、8 位数据、无校验、1 位停止位(8N1)下,一个字符 11 位约 1.15 ms,3.5 字符就是 4 ms 左右。如果你的采集程序发完一帧立刻切到接收,而 485 芯片的方向切换又慢半拍,最后一两个字节就会被削掉——这个坑在第 5 章还会再碰一次。

2.2 CRC-16 的多项式、初值与字节序

手册里反复强调“CRC 校验在命令中的高低字节顺序和其他相反”,这句话是很多人的第一个翻车点。Modbus 用的是 CRC-16,反射多项式 0xA001,初值 0xFFFF,结果不做异或,最终在帧里低字节在前。

def crc16_modbus(data: bytes) -> bytes: """返回按 RTU 帧顺序排列的两字节:低字节在前""" crc = 0xFFFF # 初值固定,不要改成 0x0000 for b in data: crc ^= b for _ in range(8): if crc & 0x0001: # 最低位为 1 才异或多项式 crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return bytes([crc & 0xFF, (crc >> 8) & 0xFF])

逻辑说明:逐字节异或进 CRC 寄存器,然后右移 8 次,每次判断刚移出的最低位——为 1 就和 0xA001 异或。这里的 0xA001 是 0x8005 的位反转形式,因为 Modbus 采用低位先传的反射算法。函数最后返回的字节序已经是帧内顺序,调用方直接frame + crc16_modbus(frame)即可,千万别再手动调一次[::-1],那是二次反转。

参数怎么改:如果换到别的协议(比如某些自定义校验),要动的是初值、多项式和是否做最终异或三处,而不是简单换个常数。想知道自己实现的到底对不对,别靠肉眼比对十六进制,用设备回帧做验证——发一条 03 读单个寄存器,如果从站有回复,说明 CRC 对了;从站不理你,八成是 CRC 错。

def build_frame(slave: int, func: int, payload: bytes) -> bytes: """通用组帧:地址 + 功能码 + 数据 + CRC""" body = bytes([slave, func]) + payload return body + crc16_modbus(body) # 手册里的 03 示例:读 17 号站、起始地址 107、读 3 个保持寄存器 frame = build_frame(0x11, 0x03, bytes([0x00, 0x6B, 0x00, 0x03])) print(frame.hex(" ")) # 11 03 00 6b 00 03 xx xx

这段组帧函数把“地址和功能码在前、数据在后、CRC 兜底”的顺序固化了,后面所有功能码都复用它。注意起始地址 107 是十进制、写成两字节大端0x00 0x6B,寄存器数量同理。地址位永远高字节在前,只有 CRC 是例外。

2.3 寄存器编号:0 基协议地址和 1 基文档地址的换算

这是新手最容易卡住的地方,比 CRC 还费时间。设备厂家给的寄存器表里写着“40001”,你直接把它塞进报文,从站一定回异常码 02。原因是 Modbus 协议里的地址是 0 基的,而 4xxxx 那套编号是给人类看的“数据区编号”。

数据区文档常见编号对应功能码协议地址换算
线圈(可读写位)0xxxxx01 读、05/15 写减 1
输入位(只读)1xxxxx02 读减 1
输入寄存器(只读)3xxxxx04 读减 1
保持寄存器(可读写)4xxxxx03 读、06/16 写减 1

所以“40001”对应协议地址 0,报文里是00 00;“40108”对应 107,报文里是00 6B——正好对上手册那个示例。实际对接时我一般会先问对方一句“你给的地址是 PLC 地址还是协议地址”,这一句能省掉半小时互相甩锅。有些设备的说明书干脆直接给协议地址(从 0 开始),此时不要再减,否则整体偏移一位,读出来的全是你隔壁寄存器的值。

3. 线圈类 01/02/05/15:位打包规则与 FF00 这条硬规定

3.1 01 与 02:请求只带范围,响应才带数据

读线圈(01)和读输入状态(02)的请求帧结构完全一样,只有功能码那一个字节不同:都是“起始地址 2 字节 + 数量 2 字节”。手册里那个例子读的是 37 个开关量,起始地址 19。

响应帧则多出一个“字节数”字段,后面才是数据。这就是位打包:每一个数据字节代表 8 个开关量,低位对应低地址。

def parse_read_bits(resp: bytes, count: int) -> list: """解析 01/02 的响应(resp 已去掉尾部 CRC)""" addr, func, byte_count = resp[0], resp[1], resp[2] if func not in (0x01, 0x02): raise ValueError(f"不是位读取响应: {func:#04x}") expect = (count + 7) // 8 if byte_count != expect: raise ValueError(f"字节数不符,期望 {expect} 实际 {byte_count}") bits = [] for byte in resp[3:3 + byte_count]: for i in range(8): bits.append((byte >> i) & 1) # 低位在前,bit0 是最低地址 return bits[:count] # 末尾填充位必须丢掉

逻辑说明:(count + 7) // 8是向上取整,37 个线圈就需要 5 个字节(40 位),最后 3 位是填充,协议规定填 0 但很多设备不守规矩,所以解析时按count截断,别拿字节数当真实点数。

参数说明:count必须和你请求帧里发的数量一致,不能从响应反推。01/02 单次最多读 2000 个线圈(0x07D0),超了从站回异常码 03。实际项目里我很少一次读满,一般按 8 或 16 的整数倍分块,好处是字节对齐、调试时十六进制一眼能看出哪一位对应哪个点。

位序这块最容易出玄学。设备手册写“第 0 位是 1 号泵运行”,你解出来发现 1 号泵明明在转却是 0——先别怀疑协议,确认手册里的“第 1 位”到底指 bit0 还是 bit1。行业里两种写法都有,我见过把 bit0 叫“第 1 位”的文档,也见过直接从 bit1 开始编号的。这个只能靠现场核对,改一行代码就能验证。

3.2 05 写单线圈:为什么只认 FF00 和 0000

05 功能码的请求是“线圈地址 2 字节 + 数据 2 字节”,但那两个数据字节不是随便填的。手册写得很明确:[FF][00]表示闭合,[00][00]表示断开,其他数值非法。

def build_write_coil(slave: int, coil: int, on: bool) -> bytes: value = 0xFF00 if on else 0x0000 # 只能这两个值 payload = bytes([(coil >> 8) & 0xFF, coil & 0xFF, (value >> 8) & 0xFF, value & 0xFF]) return build_frame(slave, 0x05, payload)

设计动机值得说一句:本来一个位就够表达开关状态,协议偏要用两个字节的“全 1”或“全 0”来表达。这不是浪费,而是一种极轻量的冗余校验——如果传输中有一位翻转,FF00变成FF01,从站可以判定为非法数据值并回异常码 03,而不是把一个错误的开关状态执行下去。工业现场宁可让你收到一个错误码,也不愿意让一台泵莫名其妙地启停。

成功时从站把请求帧原样回显(8 个字节一模一样),失败时要么不回,要么回异常帧。所以判断写成功的方法不是“有回复”,而是“回复和请求逐字节相等”。这一点写在代码里就是一次resp == req比较,别去解析内容。

3.3 15 写多线圈:字节数、填充位与最后一个字节

15 功能码一次可以写一串连续线圈,数据域是“起始地址 2 字节 + 数量 2 字节 + 字节数 1 字节 + 数据 N 字节”。

def build_write_coils(slave: int, start: int, values: list) -> bytes: n = len(values) byte_count = (n + 7) // 8 buf = bytearray(byte_count) for idx, v in enumerate(values): if v: buf[idx // 8] |= 1 << (idx % 8) # 与解析时同样的低位优先 payload = bytes([(start >> 8) & 0xFF, start & 0xFF, (n >> 8) & 0xFF, n & 0xFF, byte_count]) + bytes(buf) return build_frame(slave, 0x0F, payload)

两个细节决定成败。第一,byte_count是数据字节数而不是线圈数,把 10 个线圈写成byte_count=10是从站回异常码 03 的经典原因。第二,最后一个字节的高位填充必须是 0,有些设备严格校验这个,填了 1 就报非法数据值。

15 的响应只回“起始地址 + 数量”,不像 05 那样整帧回显。所以写成功的判据变成“功能码是 0x0F、起始地址和数量与请求一致”。这两种回显风格的差异,第 4 章讲 06 和 16 时会再出现一次——把它们记住,能省掉很多“明明写成功了程序却判定失败”的调试时间。

4. 寄存器类 03/04/06/16:125 个上限、四种字节序与写入回显

4.1 03 与 04:为什么一次最多读 125 个

03 读保持寄存器、04 读输入寄存器,请求帧和 01/02 长得一样:起始地址 + 数量。响应帧是“字节数 + 数据”,每个寄存器占 2 字节,高字节在前。

def parse_read_regs(resp: bytes, count: int) -> list: """解析 03/04 响应,返回 16 位无符号整数列表""" func, byte_count = resp[1], resp[2] if func not in (0x03, 0x04): raise ValueError(f"不是寄存器读取响应: {func:#04x}") if byte_count != count * 2: raise ValueError(f"字节数不符,期望 {count*2} 实际 {byte_count}") data = resp[3:3 + byte_count] return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)]

手册那个例子返回[06][02][2B][00][00][00][64],字节数 06 表示 3 个寄存器,解出来是 555、0、100 —— 和正文写的完全一致。这种用已知示例反推解析函数是否正确的方法,比凭空造测试数据靠谱得多:先把手册里的示例帧跑通,再去接真实设备。

单次上限 125 的来源很直接:响应帧里的“字节数”只有一个字节,最大 255,但必须是偶数,所以最多 250 个数据字节,也就是 125 个寄存器。超过这个数,组帧阶段就应当自己拦下来分块,而不是等从站回异常。写多寄存器(16)的上限是 123,比读少 2 个,因为请求帧里多了起始地址和数量 4 个字节的开销。

4.2 32 位量跨寄存器:四种字节序怎么试

单个寄存器只有 16 位,遇到温度、累计流量、电度这类 32 位浮点或长整型,必然跨两个寄存器。麻烦在于,协议本身没规定这两个寄存器怎么拼成 32 位,厂家各显神通。

import struct def combine_u32(regs, order="ABCD") -> float: """regs: 两个 16 位寄存器;order: 四个字节的排列方式""" hi = [(regs[0] >> 8) & 0xFF, regs[0] & 0xFF] lo = [(regs[1] >> 8) & 0xFF, regs[1] & 0xFF] table = { "ABCD": hi + lo, # 大端,标准写法 "CDAB": lo + hi, # 字交换,仪表里最常见 "BADC": [hi[1], hi[0], lo[1], lo[0]], # 字节交换 "DCBA": [lo[1], lo[0], hi[1], hi[0]], # 全反转,小端 } raw = bytes(table[order]) return struct.unpack(">f", raw)[0]

不要靠猜。有个很土但极管用的办法:往设备写一个已知值,比如把设定值写成 1.0,然后读回来。1.0 的 IEEE754 表示是0x3F800000,读回的两个寄存器如果是0x3F80和0x0000,就是 ABCD;如果是0x0000和0x3F80,就是 CDAB。一次试探就能锁定整台设备的字节序,比翻手册快得多——很多手册压根不写这个。

还有一种坑:同一个设备上不同变量用不同字节序。某次现场遇到流量是 CDAB、温度是 ABCD,采集程序统一按一种解,结果温度永远是 0.0。这种情况只能按变量逐个标定,配置表里给每个点位加一个 order 字段,别图省事全局统一。

4.3 06 与 16:单写整帧回显,多写只回范围

06 写单个保持寄存器,请求是“寄存器地址 2 字节 + 数据 2 字节”,成功时从站整帧回显。16 写多个保持寄存器,请求是“起始地址 + 数量 + 字节数 + 数据”,响应只回“起始地址 + 数量”。

def build_write_regs(slave: int, start: int, values: list) -> bytes: n = len(values) if n > 123: raise ValueError("16 功能码单次最多 123 个寄存器") body = bytearray([(start >> 8) & 0xFF, start & 0xFF, (n >> 8) & 0xFF, n & 0xFF, n * 2]) for v in values: body += bytes([(v >> 8) & 0xFF, v & 0xFF]) return build_frame(slave, 0x10, bytes(body))

这里有个真实的翻车场景:程序用 16 写 10 个寄存器,写完之后校验resp == req,永远失败,于是重试三次,设备收到三次同样的写入。原因就是拿 06 的判据套到 16 上。正确的做法是按功能码区分判据:05 和 06 比整帧,15 和 16 只比前 6 个字节(地址、功能码、起始地址、数量)。

再补一条手册没写但现场常遇到的:很多设备的寄存器表不是连续的,中间有空地址段。你以为从 40001 连着写 10 个没问题,实际第 6 个地址是保留的,从站收到就回异常码 02,整条写入全部作废。这种情况只能拆成多段,按设备实际支持的地址块分批写。

5. 五个翻车现场排查:地址偏移、CRC 截断、总线超时、写不进、写一半

5.1 现象:读回来的数全是 0 或者全是 0xFFFF

原因通常是三种之一。第一,起始地址用了文档编号 40001 而非协议地址 0,从站按非法地址处理或返回了别的区域。第二,功能码选错了区——线圈用 01 读,寄存器用 03 读,拿 03 去读只读的输入寄存器区,有些设备会直接返回全 0 而不报错。第三,设备本身没在跑,寄存器里就是初值。

解决顺序是:先打印原始十六进制响应,别急着看解析后的值;再确认地址换算;最后拿手册里的示例帧(17 号站、地址 107、读 3 个)去试一台已知正常的设备,把工具链和被测设备分开验证。

5.2 现象:CRC 校验随机失败,重发一次又好了

原因多在物理层和时序上。485 是半双工,发送和接收共用一对线,方向切换靠软件控制。如果write()之后立刻read(),最后几个字节可能还在移位寄存器里没出去,方向就切了,从站收到的是被截断的帧,自然校验不过。另外终端电阻装多了(比如每个节点都焊一个 120 Ω)会把信号幅度拉低,噪声一大就随机出错。

解决办法:发送后等最后一个字节真正移出(按波特率算字符时间,9600 下一个字符约 1.15 ms,8 字节帧约 9.2 ms),再加 2 ms 余量再切方向;总线上只在两端各留一个 120 Ω 终端电阻,中间节点全部去掉;屏蔽层单端接地,别两头都接。

5.3 现象:白天正常,晚上或某台设备启动后集中超时

原因几乎总是拓扑与干扰。星形接线在 485 上是禁忌,分支一长反射就严重;把变频器、伺服的动力线和通讯线捆在一个线槽里,启停瞬间的干扰足以吞掉整帧;波特率设到 115200 而线长超过几十米,边沿变缓导致误码。

解决:改成手拉手的总线拓扑,分支线控制在很短;通讯线用双绞屏蔽,和动力线保持距离、避免长距离平行走;线长和波特率做取舍,几百米以上老老实实降到 9600;超时时间从 100 ms 提到 300–500 ms,重试次数设 2–3 次,别设无限重试,否则一台设备掉线会拖垮整个轮询周期。

5.4 现象:05 写线圈返回成功,继电器就是不动作

原因可能是写到了错误的地址(地址偏移一位,写到了隔壁线圈),也可能是设备有写保护寄存器没解开,还可能是 05 的数据域用了0x0001。最后这个最隐蔽:按协议0x0001是非法值,但部分设备的固件容错,照样执行并回显,于是你以为成功了——换一台严格实现的设备立刻就露馅。

解决:先用读线圈(01)确认地址与实物对应关系,按一下急停看哪一位翻转,比看手册快;检查是否有使能位或写保护;数据域统一用0xFF00/0x0000,这个习惯要刻进代码里,别为了“少写几个字符”用 1 和 0。

5.5 现象:16 批量写入后设备卡死或只写进去前几个

原因有两个。一是单次写入数量超限,16 功能码上限 123 个寄存器,超了要么回异常要么设备固件直接崩;二是跨越了设备的地址空洞,或者设备要求某些参数必须成对写入(比如高低字同时生效),只写一半就进入非法状态。

解决:按 123 分块,块与块之间留 20–50 ms 间隔;对成对参数用 06 写单个寄存器的方式逐对提交,或在一次 16 里把成对的两个寄存器一起写完;写入后立刻用 03 回读校验,别只依赖响应帧——响应只能证明帧被接收,证明不了值被采纳。这一步回读校验,我在所有量产采集程序里都是强制开启的。

6. 把功能码表变成回归用例:参数化脚本与一次现场定位

手册的价值不在读,而在于能变成一套可重复执行的用例。我现在接手任何一台新设备,第一件事是把这份 PDF 里的示例帧抄进一个参数化表格,跑一遍“构造 → 发送 → 校验 → 解析”的闭环,跑不通就先不写业务逻辑。

CASES = [ # (说明, 从站, 功能码, 数据域 hex, 期望响应字节数) ("读线圈 37 点", 0x11, 0x01, "0013 0025", 5 + 5), ("读保持 3 个", 0x11, 0x03, "006B 0003", 3 + 6 + 2), ("写单线圈闭合", 0x11, 0x05, "00AC FF00", 8), ("写单寄存器", 0x11, 0x06, "0001 0003", 8), ] for name, slave, func, payload_hex, expect_len in CASES: payload = bytes.fromhex(payload_hex.replace(" ", "")) req = build_frame(slave, func, payload) resp = send_and_recv(req) # 串口收发,超时 300 ms assert crc16_modbus(resp[:-2]) == resp[-2:], f"{name}: CRC 错" assert len(resp) == expect_len, f"{name}: 长度 {len(resp)} 不符"

payload_hex直接用手册里那串十六进制,去掉空格后bytes.fromhex就能吃;expect_len是按功能码推出来的响应帧总长(含 CRC),它是最便宜的断言——长度不对,后面的解析全是错的。这套用例跑通,说明地址换算、CRC、帧边界、回显规则四件事全对,剩下的才是业务层的事。

真正让我下决心把这套东西固化的,是一次很典型的现场定位:某台设备读温度偶尔返回 0,轮询几个小时才出一次。日志里看不出问题,因为响应帧完整、CRC 也对。把原始帧全部落盘之后才发现,出问题的帧数据域是02 2B 00 00,而正常帧是02 2B 00 64——设备偶尔会把寄存器写成 0,不是通讯问题,是它自己的采集模块在自检时清零。如果没有原始帧日志,这种问题会被无限归因到“Modbus 不稳定”上,然后你去换线、换终端电阻、换波特率,折腾一周也没用。

所以从那以后,我每次接新设备都强制走一遍:先把手册示例帧跑通,再开原始十六进制日志落盘,最后才接业务。日志保留 7 天,出问题第一时间看的是字节,而不是解析后的浮点数。这份功能码手册就放在旁边当对照表用,翻得最多的其实就那六页——01 到 06。希望帮到你。

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

RELATED READING

延伸阅读

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