3. Modbus TCP 协议基础:协议帧结构、功能码分类与寄存器地址映射

Modbus TCP 是工业物联网边缘网关与 PLC、RTU、仪表等现场设备通信的事实标准协议。它基于 TCP/IP 栈,在以太网上传输 Modbus 应用数据单元(ADU)。掌握协议帧结构、功能码分类和寄存器地址映射,是调试边缘网关数据采集通道的必备基础。

💡 核心认知:Modbus TCP 与 Modbus RTU 的最大区别在于去掉了 CRC 校验(由 TCP 层保证)并增加了 MBAP 报文头(7 字节)。理解这一差异,就能快速定位抓包中的帧结构问题。

3.1 协议帧结构:MBAP + PDU

Modbus TCP 帧由两部分组成:MBAP 报文头(Modbus Application Protocol Header)和 PDU(Protocol Data Unit)。PDU 又包含功能码和数据域。

字段 长度(字节) 说明
事务处理标识符 2 用于请求/响应配对,客户端自增
协议标识符 2 固定为 0x0000(Modbus 协议)
长度 2 后续字节数(单位标识符 + PDU 长度)
单位标识符 1 从站地址(网关转发时使用)
功能码 1 操作类型(读/写/诊断)
数据域 N 寄存器地址、数量、数据值等
📐 帧结构速记:MBAP(7 字节)+ 功能码(1 字节)+ 数据域(N 字节)。抓包时,前 7 字节是 MBAP,第 8 字节是功能码,第 9 字节起是数据域。
Modbus TCP 帧结构(MBAP + PDU) MBAP 报文头(7 字节) 事务标识(2) | 协议标识(2) | 长度(2) | 单元标识(1) PDU 功能码(1) + 数据(N) 事务标识 协议标识 长度 单元标识 功能码 数据域 📌 抓包时,前 7 字节为 MBAP,第 8 字节为功能码,后续为数据域

3.2 功能码分类与速查表

Modbus TCP 定义了三类功能码:位操作、字操作和诊断。边缘网关最常用的是 01/02/03/04(读)和 05/06/15/16(写)。

功能码 名称 操作对象 读写方向 典型用途
01 (0x01) 读线圈 DO(数字输出) 读 读取继电器/阀门状态
02 (0x02) 读离散输入 DI(数字输入) 读 读取按钮/限位开关
03 (0x03) 读保持寄存器 AO(模拟输出/参数) 读 读取设定值、累计量
04 (0x04) 读输入寄存器 AI(模拟输入) 读 读取温度、压力、流量
05 (0x05) 写单线圈 DO 写 控制单路开关
06 (0x06) 写单寄存器 AO 写 修改单个设定值
15 (0x0F) 写多线圈 DO 写 批量控制输出
16 (0x10) 写多寄存器 AO 写 批量写入参数表
⚠️ 常见误区:功能码 03 和 04 都读 16 位寄存器,但 03 对应保持寄存器(可读可写),04 对应输入寄存器(只读)。在设备文档中,保持寄存器地址通常以 4xxxx 开头,输入寄存器以 3xxxx 开头。
Modbus 功能码分类(边缘网关常用) 功能码分类 位操作(01/02/05/15) 字操作(03/04/06/16) 诊断(07/08/11/12) 01 读线圈 02 读离散 05 写单线圈 15 写多线圈 03 读保持 04 读输入 06 写单寄存器 16 写多寄存器 📌 边缘网关数据采集主要使用 03/04 读寄存器,控制输出使用 05/06/15/16

3.3 寄存器地址映射:PLC 与网关的“翻译表”

寄存器地址映射是边缘网关配置的核心环节。PLC 端使用物理地址(如 40001),而 Modbus TCP 报文中的地址域使用协议地址(0x0000 起始)。网关需要完成这两者之间的转换。

3.3.1 地址映射规则

寄存器类型 PLC 地址范围 协议地址(十六进制) 功能码 数据宽度
线圈(DO) 00001 ~ 09999 0x0000 ~ 0x270E 01 / 05 / 15 1 bit
离散输入(DI) 10001 ~ 19999 0x0000 ~ 0x270E 02 1 bit
输入寄存器(AI) 30001 ~ 39999 0x0000 ~ 0x270E 04 16 bit
保持寄存器(AO) 40001 ~ 49999 0x0000 ~ 0x270E 03 / 06 / 16 16 bit
🔢 地址转换公式:
协议地址 = PLC 地址 - 地址基址
例如:PLC 地址 40001 → 协议地址 = 40001 - 40001 = 0x0000
PLC 地址 30005 → 协议地址 = 30005 - 30001 = 4 = 0x0004

3.3.2 典型映射示例(边缘网关配置)

假设某厂务系统需要采集以下数据点:

数据点 PLC 地址 寄存器类型 协议地址 功能码 数据类型
冷冻水供水温度 30001 输入寄存器 0x0000 04 FLOAT32(2 寄存器)
冷冻水回水温度 30003 输入寄存器 0x0002 04 FLOAT32(2 寄存器)
冷却塔风机启停 00001 线圈 0x0000 01 / 05 BOOL(1 bit)
冷冻泵频率设定 40001 保持寄存器 0x0000 03 / 06 UINT16(1 寄存器)
寄存器地址映射:PLC 地址 → 协议地址 PLC 地址空间 00001 ~ 09999(线圈) 10001 ~ 19999(离散输入) 30001 ~ 39999(输入寄存器) 40001 ~ 49999(保持寄存器) 基址:00001/10001/30001/40001 减去基址 协议地址(报文) 0x0000 ~ 0x270E 0x0000 ~ 0x270E 0x0000 ~ 0x270E 0x0000 ~ 0x270E 起始地址均为 0x0000 示例 PLC 地址 40001 → 协议地址 = 40001 - 40001 = 0x0000 PLC 地址 30005 → 协议地址 = 30005 - 30001 = 4 = 0x0004

3.4 实战:使用 Python 构造 Modbus TCP 请求帧

以下代码演示如何手动构造一个读保持寄存器请求帧(功能码 03),读取从地址 0x0000 开始的 2 个寄存器。

import struct
import socket

# 构造 MBAP 报文头
transaction_id = 1          # 事务标识,自增
protocol_id = 0             # 协议标识,固定 0x0000
unit_id = 1                 # 单元标识(从站地址)
# 长度 = 单元标识(1) + 功能码(1) + 数据域(4) = 6
length = 6

# 构造 PDU
function_code = 0x03        # 读保持寄存器
start_address = 0x0000      # 起始地址
quantity = 2                # 读取 2 个寄存器

# 打包为字节流
mbap_header = struct.pack('>HHHB', transaction_id, protocol_id, length, unit_id)
pdu = struct.pack('>BHH', function_code, start_address, quantity)
request_frame = mbap_header + pdu

print("请求帧(十六进制):", request_frame.hex())
# 输出示例:000100000006010300000002
# 分解:0001 | 0000 | 0006 | 01 | 03 | 0000 | 0002
🧪 调试技巧:使用 Wireshark 抓包时,设置过滤条件 modbus 或 tcp.port == 502。观察 MBAP 中的 Length 字段是否与实际 PDU 长度一致,这是排查帧结构错误的第一检查点。

3.5 常见问题与避坑指南

  • 地址偏移错误:PLC 地址 40001 对应协议地址 0x0000,不是 0x0001。网关配置时若填 0x0001,会读到错误的寄存器。
  • 字节序问题:Modbus 默认使用 Big-Endian(大端序)。读取 32 位浮点数时,高 16 位在前,低 16 位在后。部分 PLC 使用 Little-Endian,需要在网关侧配置字节交换。
  • 功能码不匹配:读模拟量必须用 03 或 04,不能用 01/02。写多寄存器必须用 16,不能用 06 逐个写(效率低)。
  • 超时与重试:边缘网关默认超时通常为 1~3 秒。对于响应慢的串口透传设备,建议将超时设为 5 秒以上。
⚠️ 生产环境警示:在调试阶段,不要对正在运行的设备使用功能码 05/06/15/16 进行写操作,除非已确认写入值不会导致设备停机或安全事故。建议先在测试设备上验证写功能。