简介:面向工业自动化与嵌入式开发初学者的Modbus协议入门资料,聚焦功能码含义与报文实例,帮助读者理解主从设备间数字量、模拟量的读写流程。包内为1个PDF文档,压缩包约10KB,篇幅精炼,适合作为通信调试时的速查手册。内容系统梳理01至19号功能码的作用,重点展开01读取线圈、02读取输入状态、03读取保持寄存器、04读取输入寄存器、05强置单线圈、06预置单寄存器等常用功能码,并给出完整报文示例,逐段解析设备地址、命令号、起始地址、寄存器数量与CRC校验各字段含义,同时附设备响应格式说明。已有2330人学习,适合需要快速掌握Modbus报文构造与解析、排查通信问题的工程师对照查阅。
1. 一份把 Modbus 功能码讲透的 PDF:从报文到字节序的落地手册
调试现场最怕的不是设备不响应,而是响应了但数据对不上。某次产线联调,PLC 读回来的温度值始终是 65535,查了两小时才发现是功能码 03 的寄存器数量字段填成了字节数。这类问题翻遍协议文档也未必能找到答案,但一份把功能码和实例报文逐字节拆开的资料能省下大量时间。这份《Modbus通讯功能码及实例.pdf》就是干这个的:它把 01 到 19 号功能码的作用列成表,再对最常用的 01、02、03、04、05、06 六个功能码给出完整的请求与响应报文示例,每个字节标出含义。适合刚接触 Modbus 的嵌入式开发者、做上位机对接的软件工程师,以及需要排查 485 总线通信问题的现场调试人员。它不是协议规范的替代品,而是一份能对着报文逐字节核对的速查手册。
2. 六个核心功能码的报文结构:逐字节拆开看
2.1 读线圈与读输入状态:01 和 02 的位打包逻辑
功能码 01 读取可读写线圈状态,02 读取只读输入状态,两者报文结构完全一致,只是命令号不同。请求帧固定 8 字节:设备地址、功能码、起始地址高字节、起始地址低字节、寄存器数量高字节、寄存器数量低字节、CRC 低字节、CRC 高字节。资料里给的例子是[11][01][00][13][00][25][CRC低][CRC高],设备地址 0x11 即十进制 17,起始地址 0x0013 即 19,读取数量 0x0025 即 37 个线圈。
响应帧的结构值得单独说,因为它是新手最容易翻车的地方。响应格式为:设备地址、功能码、返回字节数、数据 1 到数据 n、CRC 低、CRC 高。返回字节数等于(读取数量 + 7) // 8,因为每个字节打包 8 个线圈状态,不足 8 的倍数时高位补 0。资料例子中读 37 个线圈,返回 5 个字节,对应 40 个位,最后 3 位无意义。数据字节内低位对应低地址线圈,比如[CD]展开成二进制1100 1101,bit0 对应起始地址那个线圈,bit1 对应下一个,依此类推。
这里有个实操细节:很多国产仪表或变频器的线圈地址从 1 开始编号,而报文里的起始地址从 0 开始。资料明确写了“起始地址为 0”,但现场手册往往标“线圈 1 对应地址 0”。对接时如果读不到预期线圈,先把手册地址减 1 再试,这是血泪经验里排第一位的检查项。
# 构造功能码 01 请求帧,读取从站 0x11 起始地址 0x0013 的 37 个线圈 import struct def build_read_coils(slave_addr, start_addr, count): frame = struct.pack('>BBHH', slave_addr, 0x01, start_addr, count) crc = calc_crc16(frame) # CRC 计算见 2.4 节 return frame + struct.pack('<H', crc) # CRC 低字节在前 req = build_read_coils(0x11, 0x0013, 37) # 期望输出: 11 01 00 13 00 25 <crc_lo> <crc_hi>上面代码里struct.pack('>BBHH', ...)的大端格式对应 Modbus 报文的高字节在前规则,而 CRC 用'<H'小端打包,因为 CRC 在报文中低字节先发。这个大小端混用是 Modbus 的固定规则,不是笔误,写代码时如果统一用大端打包 CRC,从站会直接丢弃帧。
2.2 读保持寄存器与输入寄存器:03 和 04 的双字节拼接
功能码 03 读保持寄存器、04 读输入寄存器,请求帧结构和 01/02 相同,但“寄存器数量”字段的含义从线圈个数变成了寄存器个数。资料例子[11][03][00][6B][00][03][CRC低][CRC高]表示读起始地址 0x006B 即 107 开始的 3 个保持寄存器。
响应帧的关键差异在于:每个寄存器占 2 字节,返回字节数等于寄存器数量乘 2。例子中返回[11][03][06][02][2B][00][00][00][64][CRC低][CRC高],字节数 0x06 对应 3 个寄存器,数据依次为 0x022B=555、0x0000=0、0x0064=100。资料特意点明“一个模拟量需要返回两个字节”,这句话背后是无数人踩过的坑:把两个字节当成两个独立寄存器解析,结果数值全错。
拼接时高字节在前、低字节在后,即value = (data[0] << 8) | data[1]。但要注意,有些设备厂商在寄存器内部又做了一次字交换,比如把 32 位浮点数拆成两个寄存器时低字在前。这种情况协议本身不规定,只能查设备手册。我一般会先用 03 读一个已知值的寄存器,比如设备铭牌上的量程上限,确认字节序后再批量解析。
def parse_read_holding_response(resp): # resp 为完整响应帧,含地址、功能码、字节数、数据、CRC byte_count = resp[2] data = resp[3:3 + byte_count] values = [] for i in range(0, byte_count, 2): values.append((data[i] << 8) | data[i + 1]) # 高字节在前 return values # 对资料例子解析应得到 [555, 0, 100]2.3 写单线圈与写单寄存器:05 和 06 的“原样返回”约定
功能码 05 写单个线圈,请求帧里“下置数据”字段只有两个合法值:FF 00表示闭合,00 00表示断开,其他值从站应视为非法。资料例子[11][05][00][AC][FF][00][CRC低][CRC高]就是把地址 0x00AC 的线圈置为闭合。功能码 06 写单个保持寄存器,数据字段就是目标值,例子[11][06][00][01][00][03][CRC低][CRC高]把 1 号寄存器写成 3。
这两个功能码的响应规则很特别:成功时从站原样返回请求帧,失败时不响应。这意味着上位机不能靠“收到响应”判断成功,还要比对响应帧和请求帧是否逐字节一致。实际项目里我见过从站返回了功能码但数据字段被改的情况,虽然少见,但校验时加上帧比对不亏。另外 05 和 06 一次只能操作一个对象,要批量写必须用 15 和 16,资料在功能码表里列了但没给实例,需要自己按 01/03 的结构类推。
2.4 CRC16 校验:低字节在前的唯一例外
资料在 01 功能码部分专门提醒“CRC 校验在命令中的高低字节的顺序和其他的相反”。Modbus RTU 的 CRC16 多项式为 0xA001(反向),初值 0xFFFF,计算范围从帧头到 CRC 之前的所有字节。算出的 16 位结果在报文中低字节先发、高字节后发,这和地址、数据字段的高字节在前规则相反。
def calc_crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc # 校验资料中 01 请求帧前 6 字节 frame = bytes([0x11, 0x01, 0x00, 0x13, 0x00, 0x25]) crc = calc_crc16(frame) print(f"CRC低=0x{crc & 0xFF:02X} CRC高=0x{crc >> 8:02X}")这段代码可以直接抄进任何 Python 上位机项目。参数上唯一要改的是data的切片范围:发送前对“帧头到数据末尾”算 CRC,接收后对“帧头到 CRC 前”算 CRC 再和收到的两个字节比对。如果比对失败,先检查是不是把 CRC 两个字节也算进去了,这是最常见的翻车点。
3. 从 PDF 到可运行代码:搭一套最小验证环境
3.1 用虚拟串口对跑通 03 功能码
拿到资料后别急着接真实设备,先用虚拟串口把请求响应链路跑通。Linux 下用socat创建一对虚拟串口,Windows 下可以用 com0com,原理一样:一端发,另一端收。下面以 Linux 为例,开两个终端,一个模拟主站发请求,一个模拟从站回响应。
# 终端 1:创建虚拟串口对 socat -d -d pty,raw,echo=0 pty,raw,echo=0 # 输出会显示两个 /dev/pts/N,比如 /dev/pts/3 和 /dev/pts/4# 终端 2:主站侧,向 /dev/pts/3 发 03 请求 import serial, struct from crc_util import calc_crc16 # 用 2.4 节的函数 ser = serial.Serial('/dev/pts/3', 9600, timeout=1) req_body = struct.pack('>BBHH', 0x11, 0x03, 0x006B, 0x0003) req = req_body + struct.pack('<H', calc_crc16(req_body)) ser.write(req) print('sent:', req.hex()) resp = ser.read(64) print('recv:', resp.hex())从站侧在/dev/pts/4上读请求、解析、拼响应。这一步的价值在于:你能在不碰硬件的情况下确认自己的 CRC 算对了、字节序拼对了、响应解析逻辑没写反。等虚拟环境跑通,再把串口名换成/dev/ttyUSB0接真实 485 转换器,问题范围就缩小到接线和从站参数上了。
3.2 串口参数与 485 方向控制
Modbus RTU 的串口参数在资料里没展开,但现场九成通信失败都出在这里。常见配置是 9600 波特率、8 数据位、无校验、1 停止位,但这不是唯一答案,必须和从站手册一致。校验位尤其容易错:有些设备默认偶校验,主站配成无校验后能发不能收,因为从站回帧的校验位对不上。
485 总线还需要方向控制。USB 转 485 模块一般自动切换收发方向,但如果是自己画板子用 MAX485 这类芯片,需要用一个 GPIO 控制 DE/RE 引脚:发送前拉高,发完等最后一个字节移出移位寄存器再拉低。拉早了下半帧被截断,拉晚了总线冲突。我一般会在write()之后加一个按波特率估算的延时,9600 下每字节约 1.04ms,8 字节帧留 2ms 余量比较稳。
提示:虚拟串口测试通过不代表真实总线通过。接线时 A 接 A、B 接 B,但不同厂商的 A/B 标法可能相反,读不到就先把两根线对调试一次,这是成本最低的排查动作。
3.3 用资料里的实例帧做回归用例
资料给了六个功能码的完整示例帧,这些帧本身就是现成的测试用例。把每个请求帧的期望响应帧写成断言,每次改完代码跑一遍,能挡住大部分回归问题。比如 03 的例子,输入11 03 00 6B 00 03加正确 CRC,断言解析结果为[555, 0, 100]。01 的例子断言返回字节数为 5、第一个数据字节为 0xCD。这种用例不需要真实设备,跑在 CI 里也很快。
| 功能码 | 请求关键字段 | 期望响应要点 |
|---|---|---|
| 01 | 起始 0x0013,数量 0x0025 | 返回 5 字节,首字节 0xCD |
| 03 | 起始 0x006B,数量 0x0003 | 返回 6 字节,值 555/0/100 |
| 05 | 地址 0x00AC,数据 FF 00 | 原样返回请求帧 |
| 06 | 地址 0x0001,数据 00 03 | 原样返回请求帧 |
这张表可以直接转成参数化测试的数据源。注意 05 和 06 的断言是帧相等而不是字段相等,因为“原样返回”是协议约定,任何字节差异都值得警惕。
4. 避坑与排查:五条现场踩出来的记录
4.1 现象:读 03 返回异常码 0x83,原因:寄存器数量超限
请求功能码 03 时从站回[地址][83][02][CRC],0x83 是 03 的最高位置 1 表示异常,异常码 02 表示非法数据地址。常见原因是请求的起始地址加数量超出了从站支持的寄存器范围,或者数量字段填了 0。资料里没写数量上限,但多数从站限制单次读 125 个寄存器以内。解决方法是把数量降到 100 以内分段读,并核对从站手册的寄存器映射表,确认地址区间真实存在。
4.2 现象:数据能读但值明显偏大或偏小,原因:字节序或数据类型误判
用 03 读一个温度寄存器,返回01 F4,按无符号解析是 500,但实际温度是 5.00。这是典型的定点数缩放问题,不是通信错误。另一种情况是 32 位数据跨两个寄存器,高字低字顺序和预期相反,读出来是个天文数字。解决方法是先确认寄存器数据类型(16 位整型、32 位浮点、定点缩放),再确认字序。我一般会写一个探测脚本,把同一地址按几种组合都解析一遍,和已知物理量比对。
4.3 现象:05 写线圈成功但设备没动作,原因:地址偏移或线圈类型不匹配
05 请求原样返回了,说明从站接受了帧,但继电器没吸合。先查地址:手册标的“线圈 1”在报文里可能是地址 0,也可能是地址 1,取决于厂商是否做了 1-based 偏移。再查类型:有些点位是只读输入状态,用 05 写虽然从站可能不报错,但不会生效,应该确认该点位是否支持写操作。解决方法是先用 01 读回该线圈状态,确认写入前后的变化,再对照手册确认可写属性。
4.4 现象:总线挂多台设备后通信时好时坏,原因:终端电阻与共地缺失
单台设备调试正常,挂上第三台后开始丢帧。485 总线是差分信号,长距离或高波特率下需要在总线两端各加 120 欧终端电阻,中间节点不加。另外所有设备的 GND 要共地,否则共模电压漂移会导致误码。解决方法是先降波特率到 4800 验证是否改善,再检查终端电阻和屏蔽线接地。这类问题没有软件层的后悔药,只能从物理层查。
4.5 现象:CRC 校验偶尔失败但帧内容看起来正确,原因:帧间隔不足或半双工冲突
从站回帧的 CRC 偶尔对不上,但把收到的字节打印出来看内容又是对的。常见原因是主站发完请求后立刻切到接收,但 485 芯片的方向切换有延时,截掉了响应帧的头几个字节;或者总线上有多个主站同时发送造成冲突。解决方法是发送后留足 3.5 个字符时间的静默间隔再切接收,并确认总线上只有一个主站。用示波器看 A/B 线波形是最直接的确认手段。
5. 把功能码表变成调试工具:一个地址扫描脚本的写法
资料里的功能码表最有价值的用法不是背,而是驱动一个扫描脚本。现场拿到一台陌生从站,不知道它支持哪些寄存器、哪些可读,与其翻手册不如扫一遍。下面这个脚本按 03 功能码从地址 0 开始逐段读,把有响应的地址区间和值打印出来,遇到异常码就跳过。参数上把起始地址、结束地址、步长做成命令行参数,步长默认 10,因为大多数设备的寄存器是连续块状分布的。
import serial, struct, sys from crc_util import calc_crc16 def read_holding(ser, slave, addr, count): body = struct.pack('>BBHH', slave, 0x03, addr, count) ser.write(body + struct.pack('<H', calc_crc16(body))) resp = ser.read(5 + count * 2) if len(resp) < 5 or resp[1] & 0x80: return None n = resp[2] return [(resp[3+i] << 8) | resp[4+i] for i in range(0, n, 2)] def scan(port, slave, start, end, step=10): ser = serial.Serial(port, 9600, timeout=0.3) for addr in range(start, end, step): vals = read_holding(ser, slave, addr, step) if vals: print(f"0x{addr:04X}: {vals}") ser.close() if __name__ == '__main__': scan(sys.argv[1], int(sys.argv[2], 0), int(sys.argv[3], 0), int(sys.argv[4], 0))用法是python scan.py /dev/ttyUSB0 0x11 0 200,从站地址 0x11,扫 0 到 200。脚本里timeout=0.3是关键参数:太短会把慢响应的从站误判为无响应,太长会让扫描变慢。扫出来的地址区间再对照资料里 03 的报文结构手工构造单次请求验证,比盲猜快得多。这个脚本我只在实验室和授权设备上跑,生产总线上乱扫可能触发从站的异常处理逻辑,这一点要有数。
从那以后我每次接手新从站,都先用这个脚本扫一遍地址空间,再拿资料里的实例帧做交叉验证,最后才写业务代码。这套顺序帮我省掉了大量“以为是协议问题其实是地址问题”的来回。希望帮到你。
本文还有配套的精品资源,点击获取