简介:《Modbus通讯功能码及实例.pdf》面向工业自动化、嵌入式与PLC通信初学者及现场调试人员,聚焦Modbus功能码含义、报文结构与读写实例,帮助解决设备互联、集中监控和通信排错中的协议理解问题。资源包共1个PDF文件,大小约10KB,内容以协议功能码速查与实例讲解为主,轻量便于随查随用。已有2329人学习下载,说明其在Modbus入门与调试场景中具有一定参考价值。内容系统梳理01至06等常用功能码及15、16、17等扩展功能,结合线圈、输入状态、保持寄存器、输入寄存器的读写命令与响应示例,展示设备地址、起始地址、寄存器数量、数据字节和CRC校验的报文组织方式,并提及异常状态、诊断校验、事件计数与通信链路重置等排错要点,便于读者对照构造和解析报文、理解主从站交互流程。
1. 功能码:一条只有 8 字节的报文,凭什么统治工业现场几十年
工业现场有一种很反直觉的现象:两个厂商毫无往来的设备,一根双绞线接起来就能读写数据,中间没有驱动、没有 SDK、没有握手协商。撑起这件事的核心,是一条最短只有 8 个字节的报文——功能码(Function Code)就是它的灵魂。搞懂功能码,你就搞懂了 Modbus 为什么能成为工业通信的“普通话”:它把“读什么、写什么、读多少、写多少”全部压缩进一个字节,剩下的交给地址和 CRC 去兜底。这份资料把 01 到 19 号功能码逐一列了出来,真正的价值不在背表格,而在看懂每个字节为什么这么排。
Modbus 的设计哲学是“极简到有点粗糙”。它没有会话层,没有认证,没有重传机制(那是应用层自己的事),主站发问、从站应答,一问一答,节奏像老式对讲机。正是这种朴素让它能跑在 485 总线、以太网、甚至串口光耦上,不同厂商只要认同一张功能码表,就能协同工作。下面从报文结构切进去,一层层拆到字节序、位序和异常应答这些容易翻车的细节。
2. 报文骨架:地址、功能码、数据域与 CRC 是怎么咬合的
2.1 四个字段定死了一条 Modbus RTU 帧
Modbus RTU 的一帧由四段组成:设备地址(1 字节)、功能码(1 字节)、数据域(N 字节)、CRC 校验(2 字节)。地址占第 0 位,功能码占第 1 位,这个顺序不能变,因为从站在接收时就是靠第 1 个字节判断“这帧是不是给我的”,靠第 2 个字节判断“我该走哪个处理分支”。数据域的长度和含义完全由功能码决定,这也是为什么功能码必须先于数据被解析。
一个经常被忽略的点是:功能码的第一个字节同时承担了“操作类型”和“异常标识”两个角色。正常应答时从站原样返回功能码,异常应答时把功能码最高位置 1,也就是加 0x80。所以 0x03 的异常版本是 0x83,0x01 的异常版本是 0x81。主站拿到 0x83 就知道这不是数据,而是一个错误码,紧跟其后的 1 个字节才是异常码。这种“用最高位复用同一个字段”的做法,省掉了一个专门的状态字节,是典型的老派嵌入式节俭设计。
数据域里的多字节数值一律大端序(高字节在前),这一点在原文例子里体现得很清楚:起始地址 19 写成[00][13],读取数量 37 写成[00][25]。寄存器地址和寄存器数量都是 16 位,所以各占两字节。而 CRC 是唯一的小端序字段——低字节在前、高字节在后,原文特意强调“CRC 校验在命令中的高低字节的顺序和其他的相反”,这是新手最容易栽的地方。
2.2 请求与应答的镜像关系
读类功能码的请求和应答结构是镜像的:请求里给“起始地址 + 数量”,应答里给“字节数 + 数据”。写类功能码则更简单,请求和应答几乎完全一样,从站成功就把收到的帧原样吐回去。这种“回显即确认”的机制很省事,但也埋了一个坑:如果主站连续发两条写命令,只收到一条回显,你无法从报文本身判断是哪条丢了,只能靠超时重发。
下面用 Python 拼一条功能码 03 的请求帧,把字节序、CRC 的位置一次说清:
def build_crc16_table(): """预生成 CRC16 查表,Modbus 用的是多项式 0xA001(反向的 0x8005)""" table = [] for i in range(256): crc = i for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 table.append(crc) return table CRC_TABLE = build_crc16_table() def modbus_crc(data: bytes) -> bytes: """计算 Modbus CRC16,返回低字节在前的两字节""" crc = 0xFFFF for b in data: crc = (crc >> 8) ^ CRC_TABLE[(crc ^ b) & 0xFF] # 注意:这里返回时低字节在前,与数据域的大端序相反 return bytes([crc & 0xFF, (crc >> 8) & 0xFF]) def build_read_holding(slave: int, start: int, count: int) -> bytes: """构造功能码 03 请求帧:地址 + 0x03 + 起始地址(大端) + 数量(大端) + CRC""" body = bytes([ slave, 0x03, (start >> 8) & 0xFF, start & 0xFF, # 起始地址高字节在前 (count >> 8) & 0xFF, count & 0xFF, # 寄存器数量高字节在前 ]) return body + modbus_crc(body) # 对应原文示例:读 17 号设备、起始地址 107、读 3 个寄存器 frame = build_read_holding(0x11, 107, 3) print(frame.hex(" ")) # 11 03 00 6b 00 03 + 两字节 CRC这段代码里有两个需要盯死的细节。第一,CRC 初值是0xFFFF,不是 0,这是 Modbus 规范的硬性规定,初值错了整帧校验必然失败。第二,modbus_crc返回时做了字节翻转,crc & 0xFF在前,这和start、count的大端写法正好相反。我见过不少人把 CRC 也按大端拼,结果从站一声不吭,抓包看帧长得“完全正确”,血泪经验就在这里。
2.3 异常应答:从站不说话时到底发生了什么
从站收到帧后有几个硬性检查点:地址是否匹配、CRC 是否正确、功能码是否支持、数据域长度是否合法、起始地址加数量是否越界。任何一项不过,合规从站要么静默丢弃(地址不匹配、CRC 错),要么回一个异常帧(功能码不支持、地址越界)。异常帧结构是:地址 + 功能码|0x80 + 异常码 + CRC,只有 5 个字节。
异常码有明确语义:0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。区分“静默”和“异常应答”很重要——静默通常意味着物理层或地址层的问题,异常应答说明从站活着、协议栈在工作,只是这次请求它不认。排查时先看有没有回帧,再看回的是不是 0x8X,方向立刻清晰一半。
3. 四大数据模型:为什么 01/02/03/04 必须分开
3.1 线圈、离散输入、保持寄存器、输入寄存器的正交划分
Modbus 把可访问的数据分成四块,用两个维度交叉:位/字和只读/可读写。线圈(Coil)是可读写的位,功能码 01 读、05 写单个、15 写多个;离散输入(Discrete Input)是只读的位,功能码 02 读;保持寄存器(Holding Register)是可读写的 16 位字,功能码 03 读、06 写单个、16 写多个;输入寄存器(Input Register)是只读的 16 位字,功能码 04 读。
这个划分不是为了好看,而是为了在协议层面表达“这块数据你能不能动”。把只读量单列出来,从站固件就可以在功能码分发时直接拒绝写操作,不需要额外权限系统。现场调试时经常遇到有人拿 03 去读输入寄存器,读出来全是 0 或者直接异常,原因就是跨了数据模型——地址空间是分开编的,同一个地址 0 在线圈区和寄存器区指向完全不同的东西。
| 数据模型 | 位宽 | 读写性 | 读功能码 | 写功能码 |
|---|---|---|---|---|
| 线圈 Coil | 1 bit | 读写 | 01 | 05 / 15 |
| 离散输入 Discrete Input | 1 bit | 只读 | 02 | 无 |
| 保持寄存器 Holding Register | 16 bit | 读写 | 03 | 06 / 16 |
| 输入寄存器 Input Register | 16 bit | 只读 | 04 | 无 |
3.2 位打包:一个字节怎么装下 8 个开关量
功能码 01 和 02 的应答里,数据是按字节打包的,一个字节装 8 个线圈,低位对应低地址。原文例子给得很实在:返回[CD][6B][B2][0E][1B]共 5 个字节,从 20 号开关开始,0xCD二进制是1100 1101,最低位是 1 表示 20 号闭合,次低位 0 表示 21 号断开,依次往上。也就是说bit0 对应起始地址,bit7 对应起始地址加 7。
如果请求的线圈数量不是 8 的整数倍,最后一个字节的高位是无意义的填充,规范要求置 0。很多从站实现里对这一点的处理很随意,返回的填充位可能是随机值,所以主站在解析时必须按请求数量截断,不能把最后一个字节的 8 位全当真。这是解析侧最常见的越界读取来源。
解析代码长这样:
def parse_coils(payload: bytes, count: int) -> list: """把功能码 01/02 应答的数据域解析成布尔列表 payload: 去掉地址、功能码、字节数、CRC 之后的数据段 count: 请求时指定的线圈数量,用于截断末尾填充位 """ result = [] for byte in payload: for bit in range(8): if len(result) >= count: # 严格按请求数量截断,忽略填充位 return result result.append(bool((byte >> bit) & 0x01)) # bit0 是低地址 return result # 0xCD = 1100 1101,按 bit0 起解析 print(parse_coils(bytes([0xCD]), 8)) # [True, False, True, True, False, False, True, True](byte >> bit) & 0x01这一行是关键,右移而不是左移,决定了解析方向。如果写成(byte >> (7 - bit)),读出来整个位序就反了,而且因为开关量往往成组翻转,现场表现为“设备动作和预期完全对不上”,很难第一眼怀疑到解析方向。
3.3 寄存器是 16 位,但物理量可能是 32 位
保持寄存器一个占 16 位,可现实中的传感器经常是 32 位浮点或 32 位整型,于是就需要两个连续寄存器拼成一个值。协议本身不规定拼接顺序,这就留下了经典的“高低字序”问题:有的设备高字在前(big-endian word),有的低字在前(little-endian word),四种组合都有可能。原文例子返回的 555、0、100 都是单寄存器值,没有暴露这个坑,但只要接真实仪表,字序问题几乎必然遇到。
常见的处理办法是把字序做成可配置参数,读上来先按默认拼,如果数值明显离谱(比如温度读到 1e38),就换字序再试。我一般会建议在设备接入阶段单独做一次“字序标定”,用一个已知量确认,而不是等到上线后靠猜。
4. 写操作的三种粒度:05/06 与 15/16 的取舍
4.1 单点写:05 的 FF00 与 0000 为什么是唯一合法值
功能码 05 写单个线圈,数据域里两个字节表示目标状态,但规范只允许两个值:0xFF00表示闭合,0x0000表示断开。其余任何值都是非法数据值,合规从站应回异常码 0x03。原文特别点出了这一条:“此处只可以是 [FF][00] 表示闭合,[00][00] 表示断开,其他数值非法”。
为什么不用更直观的0x0001?历史原因是早期实现把这个字段当成“写保护校验”,用全 1 全 0 来防止位翻转造成的误动作——如果传输中某一位被干扰翻转,FF00很难恰好变成另一个合法值,从站就会拒绝执行。这是一种非常朴素的抗干扰思路,放到今天看有点笨,但在电气噪声大的现场确实有效。
功能码 06 写单个保持寄存器则没有这个限制,数据域就是目标值的 16 位大端表示,任意值合法。所以 05 和 06 虽然都是“单点写”,语义严格程度完全不同,代码里对 05 必须做合法性校验,对 06 不需要(但仍需检查地址范围)。
4.2 批量写:15/16 的字节数冗余校验
功能码 15 写多个线圈,请求结构是:地址 + 0x0F + 起始地址 + 数量 + 字节数 + 数据 + CRC。注意这里多了一个“字节数”字段,它等于(数量 + 7) // 8。这个字段是冗余的,因为从数量和位打包规则能算出来,但协议坚持带上它,作用是让从站在解析数据域之前就能校验长度是否自洽。
功能码 16 写多个寄存器同理,字节数等于数量 × 2。这两个字段给了从站一个廉价的“帧完整性预检”:如果字节数和数量对不上,直接回异常,不用等 CRC 算完再判断。这种冗余在协议设计里叫“防御性冗余”,代价是一个字节,收益是错误定位更快。
def build_write_multiple_registers(slave: int, start: int, values: list) -> bytes: """构造功能码 16 请求帧""" count = len(values) byte_count = count * 2 body = bytes([ slave, 0x10, (start >> 8) & 0xFF, start & 0xFF, (count >> 8) & 0xFF, count & 0xFF, byte_count, # 冗余字节数字段,(数量*2) ]) for v in values: body += bytes([(v >> 8) & 0xFF, v & 0xFF]) # 每个寄存器大端两字节 return body + modbus_crc(body) # 从地址 0 开始连写两个寄存器:0x0003 和 0x0064 print(build_write_multiple_registers(0x11, 0, [3, 100]).hex(" "))要注意功能码 16 的应答不是回显数据,而是回显“起始地址 + 数量”,只有 8 个字节。这一点和 05/06 的整帧回显不同,主站解析时要按功能码分开处理应答长度,不能一套逻辑套到底。
4.3 原子性与事务边界
Modbus 没有事务概念,一条报文就是一个独立操作。功能码 16 写多个寄存器时,从站要么全写成功、要么回异常,规范要求这个操作是原子的,不允许写一半。但现实中部分低端从站是先写前几个、中途出错就中断,留下半更新状态。所以对时序敏感的场景,宁可拆成多条 06 逐条写并逐条确认,也别盲目信任 16 的原子性——这是现场踩出来的经验。
5. 避坑与排查:五个让通信“看着通、实际不通”的经典问题
5.1 现象:抓包帧完全正确,从站不回任何数据
原因几乎总是 CRC 字节序。很多串口调试工具默认按大端显示 CRC,而 Modbus 要求低字节在前发送,人工拼帧时很容易照着工具显示的顺序抄。解决方式是固定用一段已知正确的帧做基准测试,比如原文的[11][03][00][6B][00][03],算出来的 CRC 必须和设备手册给的一致,不一致就先怀疑自己的 CRC 实现。
5.2 现象:读线圈数量不是 8 的倍数时,最后一个字节的值每次都不同
原因是解析侧没有截断填充位,把从站返回的随机高位当成了真实状态。解决方式是解析函数必须接收“请求数量”参数,按位截断,这一点在上面的parse_coils里已经用len(result) >= count卡死。
5.3 现象:写单个线圈用 0x0001 发出去,从站回异常码 03
原因就是 05 的合法值只有FF00和0000。解决方式是统一封装一个build_write_coil(on: bool),内部映射到这两个常量,杜绝手写数值。
5.4 现象:32 位传感器读数离谱,比如温度变成几万或极大值
原因是高低字序没对上。解决方式是先读两个寄存器原始值打印出来,用已知工况反推字序;或者把字序做成配置项,接入阶段标定一次。
5.5 现象:总线挂多台设备时偶发超时,单台测试正常
原因通常是轮询节奏太密或者地址冲突。Modbus RTU 是半双工,一问一答之间必须留够从站处理时间和总线静默间隔(帧间隔至少 3.5 个字符时间)。解决方式是给轮询加间隔、失败重试次数设上限、并逐台断开确认地址唯一。
6. 设计取舍:极简协议的代价与它为什么还活着
Modbus 功能码体系最值得琢磨的地方,是它几乎把所有复杂度都推给了应用层。协议不定义数据类型,寄存器里放的是整数、浮点还是枚举,全靠设备手册约定;不定义单位,不定义量程,不定义时间戳;不提供认证,任何能接到总线上的人都能发写命令。这些在今天看是明显的短板,但它换来了一个无可替代的优势:任何一颗带串口的单片机,几百行代码就能实现一个合规从站。
对比同代的其他工业协议,有的在链路层就堆了令牌传递和复杂状态机,实现成本高一个量级,最终在成本敏感的现场输给了 Modbus。功能码只用一个字节就完成了操作分发,用最高位复用异常标识,用冗余字节数字段做廉价预检,这些设计单独看都很粗糙,组合起来却形成了一种“够用就好”的均衡。它的可扩展性也留了余地:22 到 64 保留给扩展功能,65 到 72 留给用户自定义,120 到 127 留给内部,128 到 255 专门用于异常应答,区间划分清楚,厂商自定义功能不会和标准功能打架。
真正要警惕的是把这份“简单”误解成“随便”。字节序、位序、CRC 方向、05 的合法值、填充位截断,这五个点每一个都能让通信静默失败,而且失败时协议不会告诉你哪里错了,黑匣子感极强。我现在的习惯是:任何新设备接入,先用手工拼的 03 读一个已知寄存器验证链路,再跑 01 验证位解析,最后才上批量写——这套顺序看起来笨,但能把问题定位在最小范围内,省下的调试时间远超这点准备成本。后悔药不好买,前置验证就是最便宜的那一颗。希望这份功能码清单和上面的字节级拆解,能帮你在下一次现场调试时少绕两个弯。
本文还有配套的精品资源,点击获取