ModbusTCP协议详解:从零读懂一个请求报文
你有没有遇到过这样的场景?
在调试HMI与PLC通信时,Wireshark抓到一串十六进制数据:
00 01 00 00 00 06 09 03 00 00 00 04看着这行“天书”,第一反应是:这是什么?每个字节到底代表什么意思?
别急。今天我们就来彻底拆解这个典型的ModbusTCP请求报文,带你从零开始,真正理解它背后的逻辑结构和工程意义。
为什么是ModbusTCP?
工业现场的设备五花八门——PLC、变频器、温控表、流量计……它们如何“对话”?答案之一就是ModbusTCP。
相比传统的Modbus RTU依赖RS-485总线,ModbusTCP直接跑在以太网上,使用标准TCP/IP协议栈,端口固定为502。这意味着只要设备有网口、能联网,就能接入系统,部署成本低、扩展性强。
更重要的是:它的报文结构清晰、实现简单、工具链成熟,非常适合工程师快速上手开发或排查问题。
但前提是——你要看得懂它的报文。
报文不是魔术,而是“拼图”
我们先来看那个经典示例:
00 01 00 00 00 06 09 03 00 00 00 04总共12个字节。我们可以把它分成两部分来看:
| 组成部分 | 字节数 |
|---|---|
| MBAP头(协议头) | 7 字节 |
| PDU(功能指令) | 5 字节 |
✅MBAP = Modbus Application Protocol Header
它是ModbusTCP特有的封装头,用于在网络中标识和路由Modbus请求。
接下来,我们一步步“拼”出这张通信蓝图。
第一步:事务标识符 —— 我是谁发起的?
前两个字节:00 01
这就是事务ID(Transaction ID),由客户端自动生成,比如第一个请求设为0x0001,第二个可能是0x0002……
作用是什么?
想象你在同时问三台设备:“你现在温度多少?”
等响应回来的时候,你怎么知道哪条回答对应哪个问题?
靠的就是这个ID——服务器会原样返回,不做修改。
所以:
- 请求发出去:Transaction ID = 0x0001
- 响应回来也必须是:Transaction ID = 0x0001
否则就是错乱了。
💡 实践建议:用递增计数器管理事务ID,避免重复或冲突。
第二步:协议类型 —— 这是Modbus吗?
接着两个字节:00 00
这是协议标识符(Protocol ID),固定为0x0000,表示这是标准的Modbus协议。
如果将来有人扩展协议,可能会改成其他值(比如隧道传输),但在绝大多数实际应用中,它永远是0x0000。
记住了:看到非零值,就要警惕是不是私有协议或中间代理做了封装。
第三步:后面还有多少字节?
再两个字节:00 06
这是长度字段(Length),说明从“单元ID”开始,后面还有6个字节的数据。
计算一下:
- 单元ID:1字节 →09
- 功能码:1字节 →03
- 起始地址:2字节 →00 00
- 寄存器数量:2字节 →00 04
合计正好6字节。
⚠️ 注意:这里是大端字节序(Big Endian),高位在前。如果你用小端机器处理网络协议,一定要注意字节翻转!
第四步:目标设备是谁?——单元标识符
第7个字节:09
这个叫单元标识符(Unit ID),原本是从Modbus RTU继承来的概念,用来区分同一个串行总线上多个从站。
但在纯TCP环境中,IP地址已经唯一确定了设备,那它还有什么用?
其实仍有三种常见用途:
1.兼容老系统:某些网关或PLC仍需此字段匹配内部从站;
2.逻辑分区:一台物理设备模拟多个逻辑节点;
3.透传场景:通过TCP转发Modbus RTU报文时保留原始地址。
📌 关键提醒:有些PLC(如施耐德Momentum系列)会严格校验该字段,若不匹配则直接丢包无响应!所以在调试时别忘了检查这一点。
第五步:我想干什么?——功能码登场
第8个字节:03
终于到了核心指令部分——功能码(Function Code)。
0x03表示读保持寄存器(Read Holding Registers),是最常用的读操作之一。
常见的功能码你还应该熟悉这些:
| 功能码 | 操作含义 |
|---|---|
| 0x01 | 读线圈状态(可读写开关量) |
| 0x02 | 读离散输入(只读数字量输入) |
| 0x03 | 读保持寄存器(最常用) |
| 0x04 | 读输入寄存器(只读模拟量输入) |
| 0x05 | 写单个线圈 |
| 0x06 | 写单个保持寄存器 |
| 0x10 | 写多个保持寄存器 |
⚠️ 如果服务器无法执行请求(如地址越界),会返回异常码,例如
0x83表示“对功能码0x03的异常响应”。
第六步:我要读哪里?——起始地址
第9~10字节:00 00
这表示要读取的起始寄存器地址,采用0-based索引。
也就是说:
- 地址0x0000对应 Modbus 地址40001
-0x0001对应 40002
- …以此类推
为什么是40001开头?这是历史惯例:
- 4X区:保持寄存器(Holding Registers)
- 3X区:输入寄存器(Input Registers)
- 1X区:线圈(Coils)
- 0X区:离散输入(Discrete Inputs)
虽然协议里是从0开始编号,但厂商文档通常标成40001、40002……记得做减1转换!
第七步:读几个?——数量指定
最后两个字节:00 04
表示连续读取4个寄存器。
根据Modbus规范,一次最多读125个保持寄存器(因为PDU最大长度为253字节,数据部分最多252字节,每个寄存器占2字节 → 252/2=126,但起始+数量占4字节,故最多125)。
超过这个数会被拒绝或截断。
完整解析对照表
我们将整个报文重新整理如下:
| 字节位置 | 十六进制 | 名称 | 含义说明 |
|---|---|---|---|
| 0–1 | 00 01 | 事务ID | 客户端生成,用于匹配响应 |
| 2–3 | 00 00 | 协议ID | 固定为0,表示Modbus |
| 4–5 | 00 06 | 长度字段 | 后续6字节(Unit ID + PDU) |
| 6 | 09 | 单元ID | 目标设备逻辑地址 |
| 7 | 03 | 功能码 | 读保持寄存器 |
| 8–9 | 00 00 | 起始地址 | 从寄存器0(即40001)开始 |
| 10–11 | 00 04 | 寄存器数量 | 读4个连续寄存器 |
组合起来,这条报文的真实语义是:
“我是事务0x0001,想通过标准Modbus协议,向设备0x09发送一条命令:请把从40001开始的4个保持寄存器的值告诉我。”
实际通信流程长什么样?
让我们还原一次完整的交互过程:
[客户端] [服务器] | | |-------- TCP连接 (→ 192.168.1.100:502) | |<--------------- 连接建立成功 -----------| | | |-------> 发送请求报文 ---------------> | 00 01 00 00 00 06 09 03 ... | | | 解析报文 | 查找内存映射 | 封装响应 |<------- 返回响应报文 ---------------- | 00 01 00 00 00 0B 09 03 08 ... | | | 提取数据 → 更新画面 / 存入数据库响应报文大致结构为:
00 01 00 00 00 0B 09 03 08 [data(8 bytes)]其中0B是11,表示后续11字节(1+8+2?不对!其实是:1字节功能码 + 1字节字节数 + 8字节数据 = 共10字节?等等……)
Wait!这里有个坑!
长度字段 = Unit ID + PDU 总长
响应中的PDU为:
- 功能码:1字节 (03)
- 字节数:1字节 (08)
- 数据:8字节(4个寄存器 × 2字节)
共10字节 → 所以长度字段应为00 0B(即11)?错了!
再看一遍:
长度字段 =Unit ID (1)+PDU (10)=11→00 0B✅ 正确!
所以整个响应确实是合法的。
工程实战中容易踩的坑
❌ 问题1:发了请求,没回?
可能原因:
- 网络不通(ping不通?防火墙拦截?)
- 502端口未开放(特别是Windows Server默认关闭)
- 单元ID不匹配(对方要求是1,你写了FF)
- 功能码不支持(设备没启用保持寄存器读取权限)
🔍 排查方法:
- 用Wireshark抓包,确认是否有SYN握手
- 检查TCP是否连接成功
- 对比正常通信报文,逐字段比对差异
❌ 问题2:数据看起来像乱码?
很可能是字节序问题!
例如:
- 设备存储浮点数为3F80 0000(IEEE 754单精度,表示1.0)
- 但你按0000 803F解释就成了完全不同的数值
解决方案:
- 明确设备手册规定的字节排列方式:
- Big-endian:高位在前
- Little-endian:低位在前
- 或者寄存器内交换(Swap Word)、字节内交换(Swap Byte)
建议封装一个统一的数据解析函数库,支持多种模式切换。
❌ 问题3:多个请求混在一起,响应对不上?
这是典型的并发控制缺失问题。
解决办法:
- 使用唯一的递增事务ID
- 设置合理的超时重试机制(如3秒超时,最多重试2次)
- 避免高频轮询(建议≥100ms间隔)
更高级的做法是引入异步任务队列,按序处理请求与响应。
如何自己构造一个ModbusTCP请求?(Python片段参考)
import socket # 参数配置 HOST = '192.168.1.100' PORT = 502 UNIT_ID = 0x09 START_ADDR = 0x0000 REG_COUNT = 4 # 构造MBAP头 transaction_id = 1 protocol_id = 0 length = 6 # Unit ID(1) + Function Code(1) + Data(4) mbap = transaction_id.to_bytes(2, 'big') + \ protocol_id.to_bytes(2, 'big') + \ length.to_bytes(2, 'big') + \ bytes([UNIT_ID]) # 构造PDU function_code = 0x03 pdu = bytes([function_code]) + \ START_ADDR.to_bytes(2, 'big') + \ REG_COUNT.to_bytes(2, 'big') # 组合成完整报文 packet = mbap + pdu # 发送 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((HOST, PORT)) sock.send(packet) # 接收响应 response = sock.recv(1024) print("Response:", " ".join(f"{b:02X}" for b in response)) sock.close()这段代码可以直接运行测试,帮你验证通信链路是否通畅。
它真的过时了吗?ModbusTCP的未来价值
有人说:“都2025年了,还在讲Modbus?OPC UA早就取代它了。”
话虽如此,现实却是:
✅全球超过70%的工业设备仍支持ModbusTCP
✅ 几乎所有主流PLC(西门子、三菱、欧姆龙、施耐德)都默认开启502端口
✅ 边缘计算网关、IoT平台普遍提供Modbus采集模块
更重要的是:它足够简单、透明、可控。
OPC UA固然强大,但学习成本高、部署复杂;而ModbusTCP一条报文就能搞定的事,为什么要绕一大圈?
就像汇编语言不会消失一样,底层协议永远不会被淘汰——它们只是沉到了水面之下,成为支撑上层架构的基石。
结尾:你能做什么?
现在你已经知道了这串十六进制的含义:
00 01 00 00 00 06 09 03 00 00 00 04下一步呢?
你可以:
- 用Wireshark打开任意ModbusTCP通信记录,尝试手动解析每一帧;
- 写一个小脚本,自动提取所有读保持寄存器的请求;
- 在树莓派上搭建一个ModbusTCP模拟服务器,用于测试HMI;
- 或者干脆给公司写一份《Modbus通信故障排查指南》——立马晋升团队技术担当。
毕竟,真正的高手,不只是会调API,而是连每一个bit都心里有数。
如果你在实现过程中遇到了挑战,欢迎留言交流。