简介:面向工业自动化初学者与现场调试人员的Modbus协议入门资料,聚焦功能码含义与报文实例,帮助读者理解主从设备间数字量、模拟量的读写流程,解决通信报文构造与解析的常见困惑。包内共1个PDF文件,约10KB,篇幅精炼,便于随查随用。内容以功能码为主线,逐一说明01至17号功能码的作用,重点展开最常用的01、02、03、04、05、06号,并给出读线圈、读输入状态、写单线圈、读保持寄存器、写单寄存器等典型命令与响应的字节级示例,标注设备地址、起始地址、寄存器数量与CRC校验的位置和注意事项,同时附有功能码速查表。已有2330人学习,适合作为调试串口与以太网通信时的对照手册,也可用于快速回顾报文结构、排查地址与校验错误。
1. 一份把 Modbus 功能码讲透的 PDF:从报文到字节序的落地手册
调试现场最常听到的一句话是“数据读上来了,但不对”。RS485 线接好了,串口助手也能收到回码,可温度值显示 6553.5,开关状态全是反的——问题往往不在硬件,而在功能码选错、寄存器地址偏移没算、或者 CRC 高低字节放反了。这份《Modbus通讯功能码及实例.pdf》解决的正是这个环节:它把 01 到 21 号功能码逐个列出作用,并对最常用的 01、02、03、04、05、06 六个码给出了完整的请求帧和响应帧拆解,连每个字节代表什么、地址从 0 还是从 1 开始、数据高低位怎么排都写清楚了。适合刚接手 485 总线设备对接的嵌入式工程师、做上位机采集的软件开发者,以及需要快速核对报文格式的现场调试人员。它不是协议规范的替代品,而是一份能放在手边对着串口日志逐字节比对的速查材料。
2. 六个核心功能码的报文结构:请求帧怎么拼、响应帧怎么拆
2.1 为什么 01/02/03/04/05/06 覆盖了八成现场需求
工业现场的设备类型看着杂,落到数据模型上其实只有四类:可读写的开关量(线圈)、只读的开关量(输入状态)、可读写的模拟量(保持寄存器)、只读的模拟量(输入寄存器)。功能码 01 和 02 分别对应前两类开关量的读取,03 和 04 对应后两类模拟量的读取,05 和 06 负责单个线圈和单个寄存器的写入。把这六个码吃透,绝大多数传感器、继电器板、变频器、温控表的对接都能走通。
选型上有个容易混淆的点:同样是读开关量,01 读的是线圈(可被 05 改写),02 读的是输入状态(物理上只读,比如限位开关)。有些设备厂商把两者映射到同一片物理输入上,用 01 也能读到,但这属于实现层面的宽容,不能当成通用做法。同理,03 读保持寄存器(可被 06 改写),04 读输入寄存器(通常是采集值,只读)。对接新设备时,先翻它的寄存器表,看清楚某个地址标的是“保持”还是“输入”,再决定用 03 还是 04,这一步搞反了,回码要么异常要么读出来是另一组数据。
PDF 里对每个功能码都给了“计算机发送命令”和“设备响应”两段字节序列,格式统一为方括号包住每个字节。这种写法看着朴素,但对现场比对特别友好——串口助手抓到的原始字节直接按顺序对上去就行,不用在脑子里做格式转换。
2.2 读类功能码:01/02/03/04 的请求与响应逐字节拆解
读线圈(01)的请求帧结构是:设备地址 1 字节、功能码 1 字节、起始地址高 8 位、起始地址低 8 位、读取数量高 8 位、读取数量低 8 位、CRC 低 8 位、CRC 高 8 位,共 8 字节。PDF 给的例子是[11][01][00][13][00][25][CRC低][CRC高],含义是向 17 号设备读从地址 19 开始的 37 个线圈。响应帧则是:设备地址、功能码、返回字节数、数据 1 到数据 n、CRC 低、CRC 高。例子[11][01][05][CD][6B][B2][0E][1B][CRC低][CRC高]里返回 5 个字节,因为 37 个线圈需要 ceil(37/8)=5 个字节来装。
这里有个必须记住的细节:每个数据字节的 8 个位对应 8 个连续线圈,最低位对应起始地址那个线圈。PDF 明确写了“每一位为 0 表示对应的开关断开,为 1 表示闭合”,并且指出如果询问数量不是 8 的整倍数,最后一个字节的高位部分无意义、置 0。实际解析时,如果你按位从高到低读,状态会整体错位,这是新手最常见的翻车点之一。
读保持寄存器(03)的请求结构和 01 完全一致,只是功能码换成 03。响应帧的区别在于:返回字节数是寄存器数量的两倍,每个寄存器占两个字节,高字节在前。PDF 的例子[11][03][06][02][2B][00][00][00][64][CRC低][CRC高]返回 6 个字节,对应 3 个寄存器,值分别是 0x022B=555、0x0000=0、0x0064=100。这个“一个模拟量返回两个字节、高字节在前”的规则,是后面讲字节序问题的伏笔。
读输入状态(02)和读输入寄存器(04)在报文结构上与 01、03 完全对称,唯一变化是功能码。PDF 用一句话带过“和读取线圈状态类似,只是第二个字节的命令号不再是 1 而是 2”,这种写法在速查场景下够用,但实际对接时仍要确认目标设备的寄存器表里该地址是否真的支持 02/04,有些低成本模块只实现了 01/03,发 02 过去直接超时。
2.3 写类功能码:05 和 06 的“原样返回”约定
写单个线圈(05)的请求帧是:设备地址、功能码 05、线圈地址高 8 位、低 8 位、数据高 8 位、低 8 位、CRC 低、CRC 高。PDF 特别强调,数据域只允许两种取值:[FF][00]表示闭合,[00][00]表示断开,其他数值非法。例子[11][05][00][AC][FF][00][CRC低][CRC高]就是把地址 0x00AC 的线圈置为闭合。
写单个寄存器(06)的结构相同,数据域换成要写入的 16 位值。例子[11][06][00][01][00][03][CRC低][CRC高]是把 1 号寄存器的值设为 3。
这两个写功能码的响应有个共同约定:成功时设备把请求帧原样返回,失败时不响应(或返回异常帧)。PDF 写的是“如果成功把计算机发送的命令原样返回,否则不响应”。这个“原样返回”在调试时非常好用——你发出去的帧和收回来的帧逐字节比对,完全一致就说明写入成功,不用再去解析响应内容。但要注意,部分设备在写入失败时会返回带异常码的响应帧(功能码最高位置 1,比如 06 变成 0x86,后跟异常码),而不是真的“不响应”,所以超时和异常帧两种失败形态都要处理。
下面这段 Python 代码演示了如何用最小依赖拼出 03 功能码的请求帧并做 CRC 校验,适合在没有第三方库的环境里快速验证:
import struct def crc16_modbus(data: bytes) -> bytes: """标准 Modbus CRC16,返回低字节在前、高字节在后的 2 字节""" crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 # 注意:协议要求 CRC 低字节先发,所以这里返回 low, high return struct.pack('<H', crc) def build_read_holding(dev_addr: int, start: int, count: int) -> bytes: """构造 03 读保持寄存器请求帧""" body = struct.pack('>BBHH', dev_addr, 0x03, start, count) return body + crc16_modbus(body) def parse_read_holding_resp(resp: bytes): """解析 03 响应,返回寄存器值列表""" if len(resp) < 5: raise ValueError('响应过短') if resp[1] & 0x80: raise RuntimeError(f'异常响应,异常码={resp[2]}') byte_count = resp[2] if len(resp) != 3 + byte_count + 2: raise ValueError('长度与字节数不符') values = [] for i in range(3, 3 + byte_count, 2): # 高字节在前 values.append((resp[i] << 8) | resp[i + 1]) return values # 对应 PDF 例子:17 号设备,起始 107,读 3 个 frame = build_read_holding(0x11, 107, 3) print(frame.hex(' ').upper())这段代码的关键点有三个。第一,crc16_modbus里用struct.pack('<H', crc)输出小端序,正好满足“CRC 低字节先发”的要求,如果写成大端,现场表现就是设备完全不回码,因为校验对不上。第二,build_read_holding用>BBHH大端打包地址和数量,这是 Modbus 应用层的规定,和 CRC 的字节序是两回事,别混。第三,解析响应时先判断resp[1] & 0x80,这是识别异常响应的通用手法,功能码最高位为 1 就说明设备拒绝了请求,后面那个字节是异常码,比等超时快得多。
参数上,dev_addr取 1 到 247,0 是广播地址(写操作可用,读操作不会有响应);start是寄存器起始地址,注意这里填的是协议地址,不是设备手册上可能标注的“1 基地址”,差 1 的问题后面单独说;count对 03 来说最大 125,因为响应帧的字节数字段只有 1 字节,最多 255 字节数据,留出余量后 125 是安全上限。
3. 地址、字节序与 CRC:三个让报文“看起来对但读不对”的坑
3.1 寄存器地址的 0 基与 1 基:差一位,全盘错
PDF 在讲 01 功能码时写了一句“起始地址为 0”,例子中起始地址 19 对应“20 号开关”。这句话点出了 Modbus 协议层的地址是从 0 开始计数的。但大量设备手册在寄存器表里用的是 1 基编号,比如标注“40001 对应温度值”。如果你直接把 40001 填进请求帧,实际访问的是协议地址 40001,而不是设备期望的 0。
常见做法是:看到 4xxxx 开头的保持寄存器编号,先减 40001 得到协议地址;看到 3xxxx 开头的输入寄存器,减 30001;0xxxx 线圈减 1;1xxxx 输入状态减 1。但这不是铁律,有些厂商的文档直接给的就是协议地址,减了反而错。稳妥的验证方式是:拿一个已知值的寄存器(比如设备铭牌上的固件版本号或固定常量),用两个相邻地址各读一次,看哪个返回的值和文档对得上,就能确定偏移方向。这个笨办法我每次对接新设备都会走一遍,比猜快。
3.2 字节序与字序:0x022B 读成 0x2B02 是怎么回事
PDF 的 03 功能码例子里,返回[02][2B]表示 555,明确是“高 8 位在前”。这是 Modbus 应用层的标准字节序,也就是大端。但问题出在“字序”上:一个 32 位浮点数或 32 位整数要占两个连续寄存器,先发哪个寄存器、每个寄存器内部又是什么字节序,Modbus 规范没有强制规定,完全由设备厂商决定。现场常见的四种组合是 ABCD、CDAB、BADC、DCBA,其中 ABCD 是“高字在前、字内大端”,CDAB 是“低字在前、字内大端”,后两种是字节交换的变体。
PDF 只覆盖了 16 位寄存器,没涉及 32 位数据的字序问题,这是它作为速查材料的边界。实际遇到浮点数时,我的做法是:先按 ABCD 解析,如果数值明显离谱(比如温度读出 1e38 或者 0.0001),再依次试 CDAB、BADC、DCBA,通常两三次就能对上。更靠谱的办法是让设备方提供一个已知值的调试寄存器,比如固定写入 123.456,然后四种组合各解一遍,哪个等于 123.456 就锁定哪种。这个结论要写进项目对接文档,换一批同型号设备直接复用。
3.3 CRC 校验:低字节在前,且校验范围不含自身
PDF 在 01 功能码的说明里写了一句很关键的话:“CRC 校验在命令中的高低字节的顺序和其他的相反。”意思是地址、数量这些字段都是高字节在前,唯独 CRC 是低字节在前。这个不对称设计是 Modbus RTU 的硬性规定,也是手写组帧时最容易翻车的地方。
校验范围是从帧头到 CRC 之前的全部字节,不包括 CRC 自身。上面 2.3 节的代码里,crc16_modbus(body)传入的就是不含 CRC 的body,这一点如果搞错,把 CRC 也算进去,结果永远对不上。另外,CRC 初值是 0xFFFF,多项式是 0xA001(反向的 0x8005),这两个参数在实现时不能改,改了就不是 Modbus CRC 了。
排查 CRC 问题时,一个高效手段是拿 PDF 里的例子做单元测试:用[11][01][00][13][00][25]算出 CRC,和文档里给的[CRC低][CRC高]比对。如果对不上,说明你的 CRC 实现有问题,先修算法再查其他。PDF 的例子虽然没直接写出 CRC 的具体数值,但用同一套算法对多个例子交叉验证,能快速定位是算法错还是字节序错。
4. 避坑与排查:五条来自现场的踩坑记录
4.1 发 01 读线圈,回码功能码变成 0x81
现象:请求帧发出去,设备有回码,但第二个字节是 0x81 而不是 0x01,后面跟一个异常码。原因:功能码最高位置 1 表示异常响应,常见异常码 01 是“非法功能”、02 是“非法数据地址”。这说明设备不支持 01,或者你请求的起始地址/数量超出了它的线圈范围。解决:先确认设备手册里该型号是否实现了 01,再核对地址范围;如果设备只支持 03/04,就改用寄存器读取,别在线圈上死磕。
4.2 03 读回来的值总是实际值的两倍或一半
现象:读温度寄存器,显示 512 而实际是 25.6,或者显示 12.8 而实际是 25.6。原因:设备把数值做了缩放,比如实际值乘以 10 或乘以 100 后存进寄存器,而解析端没做对应除法;也可能是把有符号数当无符号数读了,负温度变成很大的正数。解决:翻寄存器表里的“单位/缩放因子”一栏,解析后除以对应倍数;有符号数用struct.unpack('>h', ...)而不是>H。PDF 的例子给的是原始二进制值,没涉及缩放,这一步得靠设备文档补。
4.3 写 05 成功但继电器不动
现象:05 请求帧发出去,响应原样返回,说明设备接受了,但物理继电器没动作。原因:数据域填了[00][01]而不是[FF][00]。PDF 明确写了只允许[FF][00]闭合、[00][00]断开,其他数值非法。有些设备对非法值不报异常而是静默忽略,响应照样原样返回,造成“写成功但没生效”的假象。解决:组帧时对 05 的数据域做硬编码校验,只允许这两个值,从源头堵住。
4.4 多台设备挂在同一总线上,只有一台能通
现象:单台设备调试正常,挂上第二台后其中一台或两台都开始超时、CRC 错。原因:设备地址冲突,或者终端电阻没接、总线拓扑不是手拉手而是星形。Modbus RTU 要求每个从机地址唯一,范围 1 到 247,出厂默认常常都是 1。解决:逐台单独上电改地址,改完再挂下一台;总线两端各接一个 120 欧终端电阻,分支线尽量短。这类问题不是报文层面的,但表现和报文错误一模一样,排查时先排除物理层再怀疑协议。
4.5 响应帧长度对但数据错位一个字节
现象:03 响应里字节数字段是 6,但解析出来的三个寄存器值整体偏移,第一个值像是把地址字段吃进去了。原因:解析时起始索引算错,把“设备地址、功能码、字节数”三个字节的头部长度记成了 2 或 4。解决:固定用resp[3:3+byte_count]取数据区,并在解析前校验len(resp) == 3 + byte_count + 2,长度不符直接丢弃重发,别硬解。这个校验加上之后,错位问题基本绝迹。
5. 从 PDF 例子到自动化校验:把手工比对变成可回归的测试
PDF 里的例子最大的价值不是让你背下来,而是可以当成测试向量。我一般会把 01、03、05、06 四个例子的请求帧和响应帧抄进一个 JSON 文件,写一个小脚本做两件事:一是用组帧函数重新生成请求帧,和文档例子逐字节比对;二是把文档响应帧喂给解析函数,看输出是否等于文档描述的值(比如 03 例子应解析出 555、0、100)。这样每次改动组帧或解析代码,跑一遍就知道有没有回归。
import json CASES = [ {"name": "read_coils", "req": "11 01 00 13 00 25", "resp": "11 01 05 CD 6B B2 0E 1B"}, {"name": "read_holding", "req": "11 03 00 6B 00 03", "resp": "11 03 06 02 2B 00 00 00 64"}, ] def check_case(case): req = bytes.fromhex(case["req"].replace(" ", "")) # 重新计算 CRC 并追加,验证组帧逻辑 rebuilt = req + crc16_modbus(req) resp = bytes.fromhex(case["resp"].replace(" ", "")) # 响应帧同样补 CRC 后走解析 full_resp = resp + crc16_modbus(resp) if case["name"] == "read_holding": vals = parse_read_holding_resp(full_resp) assert vals == [555, 0, 100], f"解析不符: {vals}" return rebuilt.hex(" ").upper() for c in CASES: print(c["name"], check_case(c))这段脚本的用法是:把 PDF 里每个功能码的例子按req和resp两栏录入,req只填到 CRC 之前,脚本自动补 CRC 并和原始例子比对;resp同样补 CRC 后走解析,断言解析结果等于文档描述。跑通之后,这套用例就是你的“后悔药”——以后换设备、改代码,先跑用例再上线,比在现场拿串口助手一帧帧试快得多。
一个具体技巧:PDF 里 01 功能码的响应例子[CD][6B][B2][0E][1B]对应 37 个线圈,最后一个字节0x1B只有低 5 位有效(因为 37 = 4×8 + 5),高位是补 0 的。解析时如果按 8 位全读,会把补零位当成“断开”的线圈,数量对不上。正确做法是按请求的线圈数量截断,只取前 37 位。这个边界在批量读线圈时特别容易忽略,尤其是数量不是 8 的整倍数的时候。
从那以后我每次对接新设备,都强制先把 PDF 里的例子跑成测试用例,再拿真实设备回码往里填,两套对上了才动业务代码。希望这份材料也能帮你少在字节序和地址偏移上耗时间。
本文还有配套的精品资源,点击获取