Modbus功能码速查:01/03/05/06报文、CRC16与字节序联调避坑 简介面向工业自动化、嵌入式与PLC通信开发者的Modbus协议速查资料聚焦功能码含义与报文实例适合需要调试串口/以太网设备、核对主从站交互或排查通信异常的初中级技术人员。压缩包仅含1个PDF文件约10KB篇幅精简便于随查随用内容以功能码表为主线覆盖0106等常用读写操作及15、16等多线圈/多寄存器指令并给出读取线圈、输入状态、保持寄存器、输入寄存器与写单线圈、单寄存器的命令与响应格式逐段说明设备地址、起始地址、寄存器数量、数据字节和CRC校验的作用。已有2332人学习可作为现场调试、协议入门和报文对照的轻量参考帮助读者快速理解请求/响应结构并定位通信问题。1. 一份功能码速查表为什么能省掉半天抓包先看清这份 PDF 的骨架调 modbus 最耗时间的往往不是写代码而是对着串口助手上一串十六进制发呆从站一声不吭、回来的字节数对不上、读出来的值差了几百倍。这份《Modbus通讯功能码及实例.pdf》针对的正是这段时间——它把 01 到 21 号功能码的作用列成一张表再用 01/02/03/04/05/06 六个最常用码给出完整的请求帧与响应帧实例逐字节解释设备地址、起始地址、寄存器数量、返回字节数以及 CRC 的排布方式。适合刚接手 RS485 采集、仪表联调、控制器对接的人也适合手上有一套老设备、只拿到一页模糊协议说明的人。它的价值不在于讲原理而在于给了你一份能照着对账的“标准答案”把猜测变成比对。2. 功能码分四张表01~06 之外的码该看哪几条2.1 位操作与字操作01/02/05 与 03/04/06 的分界Modbus 的数据模型并不复杂麻烦在于命名混乱。协议把可访问的数据分成四块每块对应一组功能码记住这个对应关系后面所有报文都能归类数据区读写属性单位读功能码写功能码典型对象线圈Coil可读可写位0105单个/15多个继电器输出、指示灯、启停命令离散输入Discrete Input只读位02无限位开关、按钮、干接点输入保持寄存器Holding Register可读可写16 位字0306单个/16多个设定值、参数、累计量输入寄存器Input Register只读16 位字04无温度、压力、电流等测量值判断一个点用哪个码只需要问两句它是开关量还是数值量你是要读还是要写开关量只读走 02可读写走 01 和 05数值量只读走 04可读写走 03 和 06。这个判断顺序听起来简单但现场选错码的情况极多——把只读的测量值往 03 上试从站回一个异常帧很多人第一反应是接线有问题其实只是码用错了。这里还有一个文档本身的坑要提。原文在“读只可读模拟量寄存器输入寄存器”一节写的备注是“只是第二个字节的命令号不再是 2 而是 4”按上下文这里显然应该是 03 改成 04。01 对 02 是位操作里的读线圈与读输入03 对 04 才是字操作里的读保持寄存器与读输入寄存器。这种一句话的笔误在抄来的协议文档里非常常见看的时候要拿数据区对照表去卡而不是照着字面套。还有一个常被忽略的点功能码只定义了“访问哪块区、做什么动作”它不定义地址。地址是设备厂商自己映射的同样是 40001A 厂可能是温度B 厂可能是运行状态。所以拿到一份功能码表只能解决“帧怎么拼”点表还得另外找。这也是为什么很多人按功能码拼出了完全正确的帧从站也正常回了但解析出来的数值毫无意义——帧没错映射错了。2.2 07~21 这批“冷门码”的取舍判断速查表从 07 一路列到 21中间还夹着 22~64 保留扩展、65~72 用户自定义、73~119 非法功能、120~127 内部保留、128~255 用于异常应答。第一次看容易产生错觉以为这些都要实现。实际工程里不是这样。先说结论新项目 90% 的场景只用 01、02、03、04、05、06加上批量场景的 15 和 16八个码封顶。剩下的按价值排一下优先级值得看一眼的是 08回送诊断校验。它有一个回环测试子功能从站会把收到的报文原样送回来用来单独验证链路通不通不涉及任何寄存器地址。当下位机地址、点表都不确定但你想知道“线到底通没通”时这个码比反复发 03 有用。17报告从机标识在部分设备上能读到厂商信息、型号和运行指示灯状态联调初期用来确认对面到底是什么设备成本很低。11读取事件计数和 12读取通信事件记录属于事后诊断从站维护了通信事件日志出了问题可以回溯。但不是所有设备都实现试一次不响应就放弃别耗在上面。07读取异常状态拿的是 8 个内部线圈的通断状态地址由控制器自己定含义各家不同没有对应手册基本读不懂。09、10、13、14、18 这几条在原文里明确标注只针对特定老型号设备484、584L、884 之类是编程器模拟和长任务探询用的。这些是三十年前的可编程控制器时代的产物现在新选型完全用不上直接跳过。19重置通信链路在从站进入不可恢复状态时有用但粗暴复位会丢掉从站正在执行的动作工业现场要谨慎。提示功能码的高位被用作异常标志。正常请求 01从站返回异常时功能码会变成 0x81数据域只剩一个异常码字节。原文里“128~255 保留用于异常应答”说的就是这件事。写解析器时第一个判断不该是“这是不是 01”而是“这个字节是不是大于 0x80”。3. 把六个实例拆到字节01/03/05/06 的帧结构与解析脚本3.1 读线圈 01请求帧与响应帧的逐字节对账原文给的例子是读 17 号设备的线圈起始地址 19读 37 个请求[11] [01] [00] [13] [00] [25] [CRC低] [CRC高] 响应[11] [01] [05] [CD] [6B] [B2] [0E] [1B] [CRC低] [CRC高]把这两行拆开每个位置的含义是固定的位置请求帧取值含义响应帧取值含义00x11从站地址十进制 170x11原样返回10x01功能码0x01原样返回20x00起始地址高字节0x05后续数据字节数30x13起始地址低字节即 190xCD数据第 1 字节40x00数量高字节0x6B数据第 2 字节50x25数量低字节即 370xB2数据第 3 字节6CRC 低—0x0E数据第 4 字节7CRC 高—0x1B数据第 5 字节8-9——CRC—请求帧是定长 8 字节响应帧长度随数量变化。37 个线圈需要 ceil(37/8) 5 个字节所以响应里的字节数字段是 0x05数据域正好 5 个字节总长 5 5 10 字节。这个“先算长度再核帧”的动作值得养成习惯串口助手上一堆黏包、半包长度对不上就能第一时间发现。解析的关键在位序。第一个数据字节 0xCD 展开成二进制是 1100 1101但 Modbus 把这个字节里的 bit0 当作起始地址对应的那一个点也就是最低位先对应低地址def parse_coils(resp: bytes, start_addr: int) - list: 解析 01/02 响应返回 [(地址, 状态)] 列表 byte_count resp[2] # 第 3 字节是数据字节数 payload resp[3:3 byte_count] # 只取数据域排除帧尾 CRC points [] for i, b in enumerate(payload): for bit in range(8): # bit0 对应本字节里地址最小的点 addr start_addr i * 8 bit points.append((addr, (b bit) 1)) return points # 请求里的起始地址是 0x13 19 for addr, val in parse_coils(bytes.fromhex(110105CD6BB20E1B), 0x13)[:10]: print(addr, val)跑出来前几个点是 19:1、20:0、21:1、22:1、23:0、24:0、25:1、26:1——这就是原文那句“20 号闭合、21 号断开、22 闭合、23 闭合、24 断开、25 断开、26 闭合、27 闭合”的出处原文用的是从 1 开始编号代码里是从 0 开始。函数的三个要点byte_count必须从报文里取而不是假设start_addr必须用请求帧里的值而不是猜payload必须按字节数截断而不是取到帧尾。还有一个边界37 不是 8 的整数倍最后一字节只有 5 位有效高 3 位是填充的 0。如果按完整 8 位解析会多出三个地址56、57、58这些点的值恒为 0不能当真实状态用。解析时应该按请求的数量截断而不是按字节数乘以 8。3.2 03 读保持寄存器一个量占两个字节拼接顺序不能错原文的保持寄存器例子是起始地址 107、读 3 个请求[11] [03] [00] [6B] [00] [03] [CRC低] [CRC高] 响应[11] [03] [06] [02] [2B] [00] [00] [00] [64] [CRC低] [CRC高]响应里字节数字段是 0x06因为一个寄存器占两个字节3 个寄存器就是 6 个字节。数据域两两一组02 2B拼成 0x022B 55500 00是 000 64是 100与原文给出的 555、0、100 完全一致。高位在前这是 Modbus 寄存器内部的默认顺序。def parse_registers(resp: bytes, scale: float 1.0) - list: 解析 03/04 响应返回数值列表 byte_count resp[2] payload resp[3:3 byte_count] if byte_count % 2 ! 0: # 奇数长度说明帧被截断或黏包 raise ValueError(f寄存器数据长度异常: {byte_count}) values [] for i in range(0, byte_count, 2): raw (payload[i] 8) | payload[i 1] # 高字节在前 values.append(raw * scale) return values print(parse_registers(bytes.fromhex(110306022B000000 64.replace( , )))) # [555.0, 0.0, 100.0]scale这个参数是留给实际工程的很多仪表返回的是放大十倍或一百倍的整数比如 235 表示 23.5℃。功能码文档不会告诉你缩放系数它只保证字节传输正确量纲换算必须在点表里做。3.3 05 与 06写命令靠“原样回送”做验证原文对 05 和 06 的响应描述是“如果成功把命令原样返回否则不响应”。这句话的前半句非常好用——写命令的响应帧和请求帧逐字节相同包括 CRC。所以验成功不需要解析任何数据比对一遍就行。05 的请求格式是[地址][05][寄存器地址高][低][数据高][低][CRC低][CRC高]例子是[11][05][00][AC][FF][00][CRC低][CRC高]作用是闭合 0x00AC 这个线圈。注意数据域只允许两个值FF 00表示闭合00 00表示断开其他任何组合都是非法。写01 00或者00 01从站会直接判非法或干脆忽略这是新手最常见的写法错误。06 的格式完全一样只是寄存器地址和数据都是 16 位值例子[11][06][00][01][00][03]是把 1 号保持寄存器写成 3。06 的数据域没有取值限制0x0000 到 0xFFFF 都合法这是它和 05 最本质的区别。def build_write_frame(slave: int, func: int, addr: int, value: int) - bytes: 构造 05/06 写单点帧value 对 05 传 0xFF00 或 0x0000 if func 0x05 and value not in (0xFF00, 0x0000): raise ValueError(05 功能码的数据域只能是 FF00 或 0000) body bytes([slave, func, (addr 8) 0xFF, addr 0xFF, (value 8) 0xFF, value 0xFF]) return body crc16_modbus(body) # crc16_modbus 见下一章第三个参数addr是寄存器或线圈的起始地址注意是“协议地址”而不是手册上印的那个 40001 之类的编号两者差一个偏移这个坑在第 5 章展开。4. CRC16 与字节序文档里那句“高低字节相反”是翻车高发区4.1 手写一份 CRC16/Modbus并理解它为什么这样排原文在 CRC 校验处有一句很关键的注记校验范围是从帧头一直算到数据域最后一个字节且“CRC 校验在命令中的高低字节的顺序和其他的相反”。前半句好理解后半句是绝大多数人第一次实现 CRC 时翻车的地方——数据域是高字节在前CRC 却是低字节在前。算法参数固定多项式 0x8005位反转后参与运算的是 0xA001初值 0xFFFF不做最终异或输入输出都按低位先行处理。写成位运算版本def crc16_modbus(data: bytes) - bytes: 返回 2 字节低字节在前可直接拼到帧尾 crc 0xFFFF 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]) body bytes.fromhex(110100130025) print(body crc16_modbus(body))0xA001是0x8005的位反转结果写错成0x8005会得到一个完全不同、但长度也正好两字节的值——从站直接丢帧你却在怀疑接线。crc变量在循环里是整个帧的累加状态不能每次循环重置这是第二个易错点。如果要跑在采样频率高的采集程序里位运算版本每次 8 次移位一帧几十字节一秒钟几百帧时开销不小。常见做法是预生成一张 256 项的查表def _build_table(): table [] for i in range(256): crc i for _ in range(8): crc (crc 1) ^ 0xA001 if crc 1 else crc 1 table.append(crc) return table CRC_TABLE _build_table() def crc16_fast(data: bytes) - bytes: crc 0xFFFF for b in data: crc (crc 8) ^ CRC_TABLE[(crc ^ b) 0xFF] return bytes([crc 0xFF, (crc 8) 0xFF])查表法只在模块加载时算一次表之后每字节一次异或加一次索引速度提升明显。两种实现的结果必须完全一致上线前拿同一批帧对一遍这是最省事的自检。4.2 大小端与 32 位量为什么 555 会读成 11010寄存器内部高字节在前这一点在单寄存器上没争议。争议出现在 32 位量被拆成两个寄存器的时候。以 0x0000022B十进制 555为例不同设备可能给你四种排法排列方式寄存器 1寄存器 2拼出来的值ABCD大端0x00000x022B555CDAB字交换0x022B0x000036339712BADC字节交换0x00000x2B0211010DCBA全反0x2B020x0000720961536表里 11010 这个数很多人眼熟——它就是 0x2B02恰好是把 555 两个字节换了个位置。现场遇到“值大得离谱但数量级又有点像”时先算一下十六进制十有八九是排法不对。def combine_u32(regs: list, order: str ABCD) - int: 把两个 16 位寄存器合成 32 位无符号整数 hi, lo regs[0], regs[1] if order ABCD: # 标准大端 return (hi 16) | lo if order CDAB: # 字交换最常见 return (lo 16) | hi if order BADC: # 字节交换 return ((hi 0x00FF) 24) | ((hi 0xFF00) 8) | \ ((lo 0x00FF) 8) | ((lo 0xFF00) 8) if order DCBA: return ((lo 0xFF) 24) | (lo 0xFF00) 8 | \ ((hi 0x00FF) 8) | ((hi 0xFF00) 8) raise ValueError(order)order参数不要靠猜。做法是让设备处在一个你已知的物理状态比如把设定值改成 100读回两个寄存器四种排法都试一遍能拼出 100 的那个就是对的然后把这个配置写进点表固化下来。同一批设备排法通常一致换厂家必须重测。注意有符号量要单独处理。0xFFFF 当无符号是 65535当有符号是 -1。温度、位置这类可能为负的量读完必须先判断是否大于 0x7FFF再减 0x10000否则冬天一到数据就飞。5. 联调避坑五条从现场带回来的排查记录5.1 通信层先排除“根本收不到”现象从站完全不响应串口助手什么都收不到。原因通常有三个地址填成了广播地址 0而广播帧从站不回应从站地址和点表上写的不一致RS485 的 A/B 两根线接反。还有一个容易被忽略的——总线上挂了多台设备但没接终端电阻短距离能通线一长就时好时坏。 解决顺序是先用单台设备直连地址从 1 开始试用 08 的回环诊断确认链路这个不依赖任何寄存器地址再逐台并上去。A/B 反接不会烧设备放心对调。现象能收到帧但 CRC 一直校验失败。最常见的原因是 CRC 的高低字节顺序装反了。数据域是高字节在前CRC 是低字节在前这个不一致是协议本身的规定不是实现错误。第二个原因是把 CRC 本身也算进了校验范围——正确范围是帧头到数据域最后一字节不含 CRC。第三个原因比较隐蔽某些 USB 转 485 模块在波特率较高、帧较短时会丢尾字节换个模块或降一档波特率就能验证。现象05 写线圈一直失败从站不响应或者回异常帧。原文说“否则不响应”但实际设备多半会返回一个 0x85 加异常码的帧抓住这个帧看异常码比盲猜快得多。异常码 03 表示数据值非法基本可以确定是数据域写成了 0x0100 或 0x0001 而不是 0xFF00 / 0x0000。5.2 数据层帧对了但数不对现象响应帧长度正确解析出来的数量却是请求的一半或多出一截。读线圈时按“字节数 × 8”去算点数而实际请求的不是 8 的整数倍。37 个线圈返回 5 字节5 × 8 40多出的 3 个是补零位。正确做法是用请求里的数量截断不要用响应字节数反推。现象寄存器值读到了但和现场仪表的显示差十倍、一百倍。功能码只管字节搬运量纲在点表里。温度类常见 ×0.1压力类常见 ×0.01电流类常见 ×0.001。麻烦的是同一个厂家不同型号的系数也可能不同所以拿到一份新设备先读一个已知状态下的值反推系数再写进配置别照抄上一台设备的配置。现象改了参数但读回来还是旧值。06 是写单个寄存器部分设备的参数需要写多个寄存器才生效比如“值 使能位”两个寄存器只写值不写使能设备认为配置未提交。这类设备必须用 16 号功能码批量写或者按手册顺序写完两个寄存器。判断方法是看手册里参数区是不是成对出现。6. 把速查表变成回归用例六条实例的自动化对账文档里的六个实例是很好的回归基线但手工比对一次两次还行改代码之后要重跑就很烦。我的做法是把它们抄成一份用例表每次改完 CRC 或解析逻辑就整体跑一遍几十毫秒出结果。import pytest # 只保留到数据域帧尾 CRC 由脚本现算避免抄错 CASES [ {name: 01 读线圈, req: 110100130025, resp: 110105CD6BB20E1B, start: 0x13, count: 37, kind: coil}, {name: 03 读保持寄存器, req: 1103006B0003, resp: 110306022B00000064, start: 0x6B, count: 3, kind: reg}, {name: 05 写单线圈, req: 110500ACFF00, resp: 110500ACFF00, start: 0xAC, count: 1, kind: write}, {name: 06 写单寄存器, req: 110600010003, resp: 110600010003, start: 0x01, count: 1, kind: write}, ] pytest.mark.parametrize(case, CASES, ids[c[name] for c in CASES]) def test_frame_roundtrip(case): req bytes.fromhex(case[req]) full req crc16_modbus(req) # 补上 CRC 才是完整请求 assert crc16_modbus(full[:-2]) full[-2:] # 自校验 resp bytes.fromhex(case[resp]) if case[kind] write: assert resp req # 写命令原样回送 elif case[kind] coil: pts parse_coils(resp, case[start]) assert len(pts) case[count] # 数据位够用即可多余的补零位不算 else: vals parse_registers(resp) assert vals[0] 555 and vals[2] 100CASES里故意不写 CRC让脚本现算这样即使哪天 CRC 实现被改坏用例也会挂——如果把 CRC 硬编码在字符串里改坏实现后仍然能通过用例就失去了意义。test_frame_roundtrip里第一条断言是自校验第二条按 kind 分流写命令只验原样回送位和字分别走各自的解析器。这套结构扩起来很快新增一台设备就加一条字典。跑起来之后真正的价值不在“通过”而在“挂了之后能立刻定位是哪一层”CRC 挂了是算法问题写命令不回送是协议理解问题解析数量不对是位序问题。分得清这三层现场排查至少省一半时间。从那以后我每次接手一套新的 modbus 从站都会先把速查表里对应的那几条实例抄成用例跑一遍再动手写采集程序——这个习惯救过我很多次比事后翻代码找 bug 便宜太多。希望帮到你。本文还有配套的精品资源点击获取