简介这是一份面向工业自动化初学者、电气工程师与嵌入式开发者的 Modbus 通讯入门资料聚焦协议功能码含义与实际报文构造帮助读者解决「看不懂功能码、不会拼报文、调试串口通讯无从下手」等问题适合作为 RS-485 与以太网通讯调试的案头速查手册。压缩包内仅 1 个 PDF 文件约 10KB篇幅精简但内容集中便于随时打印或手机端查阅。资源从功能码总表切入逐条说明 0106 读取线圈、读取输入状态、读取保持寄存器、读取输入寄存器、强置单线圈、预置单寄存器等常用功能的作用并补充 0722 号的诊断、编程、事件计数、报告从机标识等扩展功能码末尾以读写数字量与模拟量的典型命令为例拆解设备地址、命令号、起始地址高低位、寄存器数量与 CRC 校验的字段排布与响应格式指出线圈状态按位解析、模拟量需两字节合并等易错细节。目前已有 2332 人学习下载可作为协议速查与报文对照的实用参考。1. 功能码表背得再熟现场还是读不出数工控现场最常见的场面屏上数值不动工程师蹲在配电柜前串口助手点了十几次发送回上来的还是那几个字节83 02。01 读线圈、03 读保持寄存器这些 Modbus 通讯功能码早背下来了可报文拼出去就是不通。这份《Modbus通讯功能码及实例.pdf》补的正是中间那一段——它把 01 到 21 号功能码的作用逐条列清楚又对最常用的 01、02、03、04、05、06 六个码给了完整的收发报文范例每个字节单独解释含义。刚接手 485 总线调试的新手可以照着例子一帧一帧对已经会用现成库、但说不清帧里每个字节来历的熟手也能从里面的地址约定和 CRC 顺序上找到自己踩过的坑。它不是协议规范原文是一份能直接摆在调试桌上对照的速查材料。2. 报文骨架地址、功能码、数据、CRC 四段怎么排Modbus 的玄学感大多来自「同样的字节换个工具就通不了」。原因往往不在功能码选错而在帧的外层结构没对齐。原文档开头那句话说得挺准协议定义的是控制器能认识的消息结构不管它跑在哪种网络上。意思是 PDU功能码 数据这一层是固定的外面套什么壳由承载方式决定。把这一层分清楚后面拆 01 到 06 才不会乱。2.1 RTU 帧里到底有哪几段串口上跑的绝大多数是 RTU 模式一帧由四段拼成中间没有分隔符全靠字节位置识别段长度说明从站地址1 字节1~247 单播0 是广播只用于写从站不回应功能码1 字节01~7F 正常请求最高位置 1即 80 以上表示异常应答数据域0~252 字节起始地址、数量或者实际的寄存器/线圈数据CRC 校验2 字节低字节在前高字节在后地址段是最容易被忽略的一段。原文档举的例子用0x11也就是十进制 17 号站一条 485 总线上挂十几台设备很常见地址重复或者没拨码就是「发出去没反应」的头号原因。数据域长度上限不是随便定的整帧最长 256 字节扣掉地址、功能码和 CRC剩下 253 字节给 PDU再扣掉功能码本身数据域最多 252 字节。这个数字直接决定了功能码 03 一次最多读 125 个寄存器——后面避坑那章会专门讲这条上限怎么把人绊倒。ASCII 模式则是另一套壳帧头:帧尾CR LF每个字节拆成两个 ASCII 字符传输校验用 LRC 而不是 CRC。有些老设备手册写着「支持 ASCII」你按 RTU 发过去自然一片沉默。还有 Modbus TCP在 PDU 前面加 7 字节 MBAP 头事务标识、协议标识、长度、单元标识没有 CRC。三种承载的 PDU 是一样的所以 01 到 06 的报文逻辑学一遍就够换承载只换外壳。2.2 CRC16 为什么总是高低字节反着发原文档在讲 01 功能码时特意提了一句CRC 校验在命令中的高低字节顺序和其他字段相反。这句话值一晚上的调试时间。CRC 计算本身是按字节顺序从头算到尾但拼进帧尾时先放低 8 位再放高 8 位。很多串口助手让你填「CRC 高字节在前」或者「低字节在前」选错了从站会直接把帧丢掉一个字节都不回。def crc16_modbus(data: bytes) - bytes: CRC-16/MODBUS初值 0xFFFF反向多项式 0xA001无最终异或 返回两个字节已经按低字节在前排好可直接拼到帧尾 crc 0xFFFF for b in data: crc ^ b # 当前字节异或进低 8 位 for _ in range(8): # 逐位处理 if crc 0x0001: # 最低位为 1 则右移后异或多项式 crc (crc 1) ^ 0xA001 else: crc 1 return bytes([crc 0xFF, (crc 8) 0xFF])这段函数有三个参数点是固定的不要凭印象改初值必须是0xFFFF而不是0x0000多项式用反向形式0xA001写成正向的0x8005再自己翻转位序出错概率极高最后不做异或。计算范围只覆盖从站地址到数据域最后一个字节不包含 CRC 自身——先把 CRC 算进去再校验这是个典型的自锁死循环。提示如果手头工具算出来的两个字节和你算的正好相反先别怀疑算法八成是字节序选项的问题。2.3 拿原文档那条 01 命令逐字节对一遍原文档给的读线圈例子是[11][01][00][13][00][25]加 CRC。用 pyserial 手工发一帧比在串口助手里逐个填格子更能看清结构import serial def build_read_coils(dev: int, start: int, count: int) - bytes: body bytes([dev, 0x01]) start.to_bytes(2, big) count.to_bytes(2, big) return body crc16_modbus(body) # 拼帧尾低字节在前 frame build_read_coils(0x11, 0x0013, 0x0025) print(frame.hex( )) # 前 6 字节应为 11 01 00 13 00 25 ser serial.Serial(portCOM3, baudrate9600, bytesize8, parityN, stopbits1, timeout0.5) ser.write(frame) resp ser.read(64) # 读多少不固定靠超时截断 print(resp.hex( ))参数含义对着原文档看0x11是想通讯的从站地址十进制 170x01是读线圈的功能码0x0013是起始地址十进制 19且起始地址从 0 开始计0x0025是要读 37 个开关量。to_bytes(2, big)保证起始地址和数量都是高字节在前这是 Modbus 数据域的通用约定唯独 CRC 例外。timeout0.5这个值不是随便填的。波特率 9600 下一帧几十字节约几十毫秒半秒足够。设太长会让轮询周期被拖垮设太短会在从站处理慢的时候误判成丢包。读响应用read(64)而不是read(1)是因为你不知道从站会回多少字节靠超时把整帧收完再解析比按长度死等稳。3. 六个高频功能码01 到 06 的报文逐条拆原文档的最后一句结论很实在01、02、03、04、05、06 是最常用的用它们就能实现对下位机的数字量和模拟量读写。剩下的 07 到 21 号里有一半是给特定型号设备用的历史遗留日常调试碰不到。所以拆报文的重心就该压在这六个码上把请求和应答的字节位对齐。3.1 01 与 02位是怎么打包进字节的01 读线圈、02 读输入状态报文结构完全一样只差第二个字节。区别在语义线圈是可读可写的逻辑位继电器输出、指示灯输入状态是只读的开关量限位、按钮。原文档给的请求是[11][01][00][13][00][25]响应是[11][01][05][CD][6B][B2][0E][1B]。响应第三字节05是返回的字节个数37 个开关量需要 5 个字节40 位最后 3 位无意义补 0。数据部分从第四字节开始0xCD对应索引 19 到 26 这 8 个开关0xCD 1100 1101 bit01 → 20 号闭合 bit40 → 24 号断开 bit10 → 21 号断开 bit50 → 25 号断开 bit21 → 22 号闭合 bit61 → 26 号闭合 bit31 → 23 号闭合 bit71 → 27 号闭合最低位对应起始地址那个开关往后逐位递进。这个顺序反过来记的人不少表现就是「读出来的位全反了」。原文档在响应说明里点了这一条但只有一句话实际调试时建议先拿一个已知状态的位去验证方向别一上来就解析一整片。数量参数上还有个边界01 和 02 一次最多读 2000 个位因为 2000 位 250 字节加上地址、功能码、字节数刚好卡在 253 的 PDU 上限内。超了从站回异常码 03不是回 0。3.2 03 与 04一个寄存器两个字节高字节永远在前03 读保持寄存器、04 读输入寄存器这两个在生产环境里出现频率最高因为绝大多数传感器测量值、设备配置参数都放在保持寄存器里。原文档的请求例子是[11][03][00][6B][00][03]从站 17起始地址0x006B即十进制 107读 3 个寄存器。响应是[11][03][06][02][2B][00][00][00][64]。第三字节06是返回的字节个数3 个寄存器 × 2 字节 6。数据按高字节在前排列寄存器高字节低字节值第 1 个地址 1070x020x2B555第 2 个地址 1080x000x000第 3 个地址 1090x000x64100这里有个有效性检查很容易漏响应的字节个数必须等于 请求数量 × 2对不上就说明帧错位了继续往下解析只会得到一串看似合理实则错误的数。数量上限是 125 个寄存器同样受 253 字节 PDU 限制。04 和 03 的报文结构一模一样只把功能码换成 04读的是只读输入寄存器。有些设备手册把两者混着标实际用 03 读输入寄存器回异常码 02这时候换 04 就通了。3.3 05 与 06写单个为什么要「原样回显」05 强置单线圈、06 预置单寄存器这两个是写操作语义上有个共同点成功时从站把收到的请求原样返回失败时不响应回异常帧的情况另说。原文档对 05 的说明里写得很直接——如果成功把命令原样返回否则不响应。05 的请求是[11][05][00][AC][FF][00]。地址0x00AC是要操作的线圈数据域FF 00表示闭合00 00表示断开。原文档特别强调「只可以是 FF00 表示闭合、0000 表示断开其他数值非法」。这条不是建议是硬约束发01 00有些从站直接丢弃有些会当成非法数据值回异常码 03还有些老设备会做出不可预期的动作。我一般会在组帧函数里直接拦掉非法值不让它出串口。06 的请求是[11][06][00][01][00][03]把地址 1 的保持寄存器写成 3。数据域是完整的 16 位值没有 05 那种只能取两个值的限制但同样只能一次写一个。写操作用广播地址 0 时从站不回应这时候别等响应等就是超时。批量初始化参数可以这么干代价是不知道谁写失败了。3.4 15 与 16原文档只给了名字报文得自己拼原文档的功能码表里列了 15 强置多线圈、16 预置多寄存器但没给报文例子。实际项目里这两个码用得非常频繁——一次写 10 个参数用 06 得发 10 帧用 16 一帧搞定轮询周期能省一大截。15 的请求结构[地址][0F][起始地址高][起始地址低][数量高][数量低][字节数][数据 1]...[数据 n][CRC低][CRC高]。字节数 ⌈数量 / 8⌉数据域按位打包规则和 01 的响应一样。16 的请求结构[地址][10][起始地址高][起始地址低][数量高][数量低][字节数][数据高][数据低]...[CRC低][CRC高]。这里字节数 数量 × 2别和 15 搞混15 是位、16 是寄存器。两者的响应都是回显前 6 个字节地址、功能码、起始地址、数量加 CRC不回显数据。写多个的数量上限15 是 1968 个线圈16 是 123 个寄存器都比读操作略小因为请求帧里多了一个字节数和一个数据域。def build_write_registers(dev: int, start: int, values: list) - bytes: 16 号功能码一次写多个保持寄存器 if not 1 len(values) 123: raise ValueError(16 号功能码单次最多写 123 个寄存器) body bytes([dev, 0x10]) start.to_bytes(2, big) body len(values).to_bytes(2, big) body bytes([len(values) * 2]) # 字节数 寄存器数 × 2 for v in values: body (v 0xFFFF).to_bytes(2, big) # 负数按补码截 16 位 return body crc16_modbus(body)v 0xFFFF这一步是为了处理负数。想写 -1 到寄存器直接to_bytes(2, big, signedTrue)也能出FF FF但值超范围时会抛异常用掩码更省心。参数校验放在组帧函数里比在业务代码里到处 if 要稳。4. 把静态表变成能跑的代码组帧、解析、交叉验证PDF 里的表格和例子是给人看的真到项目里得变成函数。这一步的价值不在写出多优雅的封装而在于把「哪些参数有边界」「哪几个字节要检查」固化下来让下一次调试不用重新推一遍。原文档给的是帧的样例代码要补的是样例之外的所有分支。4.1 组帧三条参数边界必须卡住组帧函数最容易写成一个简单的字节拼接然后在上线第一周被各种越界值打脸。三条边界我一般强制卡从站地址 1~2470 留给广播248 以上保留起始地址 0~65535数量按功能码分档。READ_LIMITS {0x01: 2000, 0x02: 2000, 0x03: 125, 0x04: 125} def build_read(dev: int, func: int, start: int, count: int) - bytes: if func not in READ_LIMITS: raise ValueError(f不是读功能码: {func:#04x}) if not 1 dev 247: raise ValueError(从站地址应在 1~2470 是广播无响应) if not 0 start 0xFFFF: raise ValueError(起始地址应在 0~65535) if not 1 count READ_LIMITS[func]: raise ValueError(f功能码 {func:#04x} 单次数量上限 {READ_LIMITS[func]}) body bytes([dev, func]) start.to_bytes(2, big) count.to_bytes(2, big) return body crc16_modbus(body)start从 0 开始这是 Modbus 数据域的规矩和原文档里「起始地址为 0」的说法一致。写组帧函数时如果不卡这条遇到设备手册标 40001 的地址很容易直接把 40001 填进去to_bytes(2)会直接抛溢出——这算是幸运的至少当场报错而不是发出去回一个异常码 02 让你猜半天。4.2 解析响应字节数、位展开、异常帧三处检查解析比组帧更容易出隐性 bug因为错的数据看起来也是数字不会报错。三处检查按顺序做class ModbusError(Exception): pass EXC_TEXT {0x01: 非法功能, 0x02: 非法数据地址, 0x03: 非法数据值, 0x04: 从站设备故障, 0x06: 从站设备忙} def parse_bits(resp: bytes, count: int) - list: 解析 01/02 响应返回从起始地址开始的位列表 if len(resp) 5: raise ModbusError(响应过短可能帧被截断) if resp[1] 0x80: # 最高位为 1 是异常帧 code resp[2] raise ModbusError(f异常码 {code:#04x} {EXC_TEXT.get(code, 未知)}) nbytes resp[2] if nbytes ! (count 7) // 8: # 检查点一字节数是否匹配 raise ModbusError(f字节数不符期望 {(count 7) // 8}收到 {nbytes}) if len(resp) 3 nbytes 2: raise ModbusError(数据域长度不足) bits [] for i in range(nbytes): b resp[3 i] for j in range(8): bits.append((b j) 0x01) # 检查点二低位在前 return bits[:count] # 末尾补零位要截掉三个检查点里异常帧判定必须放最前面。resp[1] 0x80这一句就是原文档里「128~255 保留用于异常应答」的落地写法从站收到处理不了的请求会把功能码最高位置 1 回给你比如 03 变成0x83再接一个异常码字节。如果先做字节数检查0x83这条路径会撞上「字节数不符」的错误信息把真正的原因盖掉。(count 7) // 8是向上取整别写成count // 8读 37 个位会被算成 4 字节然后和响应里的 5 字节对不上白白多排查一轮。寄存器解析的逻辑类似只是检查点换成「字节数 count × 2」再按大端拼回 16 位def parse_regs(resp: bytes, count: int, signed: bool False) - list: if resp[1] 0x80: raise ModbusError(f异常码 {resp[2]:#04x} {EXC_TEXT.get(resp[2], 未知)}) if resp[2] ! count * 2: raise ModbusError(f字节数不符期望 {count * 2}收到 {resp[2]}) out [] for i in range(count): hi, lo resp[3 i * 2], resp[4 i * 2] v (hi 8) | lo if signed and v 0x8000: v - 0x10000 # 有符号量补码还原 out.append(v) return outsigned这个开关很实用。温度、位移这类量经常用有符号 16 位存读回来0xFFFF到底是 65535 还是 -1全看这一句。原文档的例子里没有负数但现场几乎一定会碰到。4.3 手写帧和库对不上时先对齐哪三样项目里通常会用现成的库pymodbus 这类。手写帧留着的意义是当「对照组」库读出来的数不对用它确认到底是帧的问题还是解析的问题。from pymodbus.client import ModbusSerialClient cli ModbusSerialClient(portCOM3, baudrate9600, parityN, stopbits1, bytesize8, timeout1) rr cli.read_holding_registers(address107, count3, slave17) print(rr.registers) # 期望 [555, 0, 100]与原文档例子一致 cli.close()对齐三样东西就够了第一address参数是不是 0 基。有些库要求传 0 基地址有些允许传 40001 这种带区号的混用就是整体偏移。第二slave/unit的参数名。新旧版本 API 改过名报 TypeError 时先看函数签名别急着改逻辑。第三波特率和校验位。库默认可能是 8N1设备是 8E1 的时候一个字都通不了而且不会报错只会超时。手写帧先跑通、库再跑通、两边结果一致这套流程走一遍后面换设备心里就有底了。真正省时间的不是库本身是知道自己手里的帧长什么样。5. 避坑五类反复出现的翻车现场原文档把功能码和作用列得很全但现场出问题的地方往往不在「功能码选错」而在地址换算、字节序、超时设置这些边角。下面五条是我在不同项目里反复碰到的按「现象 → 原因 → 解决」写清楚碰上了可以直接对号入座。5.1 回异常码 02地址整体差了一位现象读保持寄存器请求发出去立刻收到11 83 02换成读别的地址也一样。原因九成是地址基准没换算。设备手册标的是 40001、40002 这种「区号 1 基」写法而 Modbus 数据域用的是 0 基地址40001 对应的 PDU 地址是 040002 对应 140011 对应 10。手册标 40108帧里要填 107。原文档例子里起始地址 107 就是这么来的。解决建一张换算表贴在代码旁边。线圈 00001~09999 → 地址 0~9998离散输入 10001~19999 → 地址 0~9998输入寄存器 30001~39999 → 地址 0~9998保持寄存器 40001~49999 → 地址 0~9998。四个区号各自从 0 开始不是连续编号。另外注意数量也要跟着算读 40001 开始的 3 个是地址 0、数量 3不是地址 1、数量 3。5.2 05 写线圈从站没反应也没报错现象用 05 功能码强置线圈发送后既没有原样回显也没有异常帧就是一片安静。原因基本是把数据域写成了01 00。原文档写得很明确只有FF 00表示闭合、00 00表示断开其他数值非法。不少实现会直接丢弃这种帧不做任何回应看起来就像设备没上电。解决在组帧函数里做白名单值只允许 True/False 两种转换成FF 00或00 00再拼帧。顺带检查一下是不是把 05 和 06 混了——06 写寄存器是可以传任意 16 位值的用 06 的逻辑去写线圈就会产出01 00这种非法值。5.3 数值放大 256 倍或者乱跳字序错了现象读一个 32 位浮点或者 32 位整数拆成两个寄存器拼回来结果要么大得离谱要么小数点乱飘。原因是一个 32 位量跨两个寄存器存储时高字和低字的先后顺序由设备厂商决定有 ABCD、CDAB、BADC、DCBA 四种常见排法Modbus 规范本身没强制。解决找一个已知值做基准比如设备当前显示 25.0把它读回来按四种排列各试一遍哪个能得到 25.0 就用哪个写死在配置里。不要靠猜也不要在不同设备上复用同一套字序假设。16 位量没有这个问题高字节在前是统一的。5.4 从站完全沉默CRC 或帧间隔出了问题现象帧内容逐字节核对没错从站一个字节都不回。原因有两个高频点CRC 两个字节的高低顺序反了或者两帧之间的间隔太短。RTU 靠 3.5 个字符时间的静默来划分帧边界轮询程序在上一帧响应还没收完就发下一帧从站会把两帧粘成一帧校验失败后直接丢弃。解决先用串口助手单独发一帧确认能回能回再上代码。轮询循环里加固定间隔9600 波特率下留 10~20 毫秒足够。另外确认串口参数8E1 和 8N1 混用是超时的经典来源设备手册上写的校验位经常被当成默认值忽略。5.5 一次读 200 个寄存器回异常码 03现象读少量寄存器正常把数量提到 200立刻回83 03。原因是数量超上限。03 和 04 一次最多读 125 个寄存器这个数由 253 字节的 PDU 上限倒推出来125 × 2 3 253。数量填 126 就超了从站回「非法数据值」。解决在组帧函数里按功能码卡上限超了直接抛异常别让它出串口。确实要读更多就拆成多帧每帧 125 个以内中间留帧间隔。拆包的时候记得起始地址要按已读数量累加别每帧都从同一个地址开始。6. 冷门功能码不是废码用 08、11、12、17 把故障钉到字节上大部分人调通 01 到 06 就停了原文档功能码表里 07 往后那一片从此不再看。其实其中几个在排障时挺值钱08 回送诊断校验可以把诊断报文原样送进从站再取回来验证链路是不是真的通11 读事件计数能让你在不拆设备的情况下判断这段时间发生过多少次通信异常12 读通信事件记录直接给出错误类型17 报告从机标识则能确认对面到底是哪一型设备、运行指示灯状态如何。设备型号对不上、地址表拿错版本这类问题用 17 号码一查就清楚。不过日常更常用的还是异常码本身。从站回83 02的时候异常码那一个字节已经把方向指出来了异常码含义现场通常指向01非法功能从站不支持该功能码或网关没做映射02非法数据地址起始地址加数量越界或 0 基没换算03非法数据值数量超上限或 05 写了非 FF00/000004从站设备故障从站内部执行失败常见于写 Flash06从站设备忙上一帧没处理完轮询太快0A / 0B网关路径不可用 / 目标无响应串口服务器到下级设备这一段断了配一个把异常码翻成中文的小函数比每次翻手册快得多。上面EXC_TEXT那张字典就是这个用途出错时异常信息里直接带中文日志里一眼能看出是地址错还是值错。还有一个容易忽略的细节异常帧的结构是[地址][功能码|0x80][异常码][CRC低][CRC高]只有 5 个字节没有数据域。解析函数如果按正常响应的长度去读会一直等到超时然后把真正的原因盖掉。先判最高位再判长度这个顺序不能反。从那以后我每接一台新设备第一件事不是写业务逻辑而是先用 03 号功能码把一个已知寄存器读出来把返回帧抄在纸上逐字节对一遍再拿 17 号码确认设备身份最后才动业务代码。这套流程看着慢实际比在现场猜一晚上快得多。这份速查材料摆在旁边当对照表用比背下来管用。希望帮到你。本文还有配套的精品资源点击获取