
简介这份PDF面向工业自动化、PLC与嵌入式通信开发初学者及现场调试人员围绕Modbus协议功能码与报文实例展开帮助读者理解主从设备如何通过统一消息结构完成线圈、输入状态、保持寄存器与输入寄存器的读写。全包仅1个PDF文件约10KB篇幅紧凑便于随查随用内容以0106等常用功能码为重点兼及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]}, Noneexpected_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 位字访问方式是只读还是读写。两两组合正好四张表数据域单位访问方式读功能码写功能码线圈Coil1 位读写0105 / 15离散输入Discrete Input1 位只读02无保持寄存器Holding Register16 位读写0306 / 16输入寄存器Input Register16 位只读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 为 123 号 bit4 为 024 号 bit5 为 025 号 bit6 为 126 号 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 和地址最后发现是转换器的缓冲把两帧粘成了一帧从站按第一帧的长度截断剩下的字节全成了噪声。从那以后我每次接新设备都强制先用单帧、单点、最小数量的命令跑通一轮再逐步加量。希望帮到你。本文还有配套的精品资源点击获取