4、Modbus TCP实战:TCP/IP封装、MBAP报文头、连接管理、Python实现TCP客户端
好,咱们今天来啃一块硬骨头——Modbus TCP。说实话,很多工程师一听到“TCP/IP协议栈”就头大,觉得那是搞网络的人该操心的事。但做工业现场集成,你躲不开它。我当年第一次调试一个远程IO站,死活连不上,最后发现是MBAP报文头里的长度字段算错了。嗯,这种坑,踩过一次就记住了。
4.1 从串口到以太网:Modbus TCP到底改了啥?
Modbus RTU走的是串口,一帧数据发完,大家靠时间间隔来判断帧结束。但TCP/IP网络不一样,它是流式传输,数据像水管里的水一样源源不断。你想想看,如果没有一个明确的“帧边界”,接收方怎么知道一条报文从哪开始、到哪结束?
所以Modbus TCP引入了MBAP报文头。说白了,就是在原来的RTU报文前面,加了一个7字节的“信封”。这个信封告诉接收方:这条报文有多长、是谁发的、有没有出错。
核心变化:
- 去掉了RTU的CRC校验(TCP/IP底层已经做了)
- 去掉了地址字段(用IP+端口识别设备)
- 增加了MBAP报文头(7字节)
4.2 MBAP报文头:这7个字节到底在干啥?
我习惯把MBAP报文头拆成四个部分来记。你只要记住这四块,写代码时就不会漏字段。
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务处理标识符 | 2字节 | 用于匹配请求和响应。客户端每发一条报文,这个值加1 |
| 协议标识符 | 2字节 | 固定为0x0000,表示Modbus协议 |
| 长度 | 2字节 | 后续字节数(从单元标识符开始算起) |
| 单元标识符 | 1字节 | 相当于RTU里的从站地址,用于网关路由 |
这里有个容易踩的坑——长度字段。我曾经见过一个同事,把整个TCP报文长度都算进去了,结果服务器端解析出来的数据全是乱的。记住:长度字段只算“单元标识符 + PDU”的字节数,不包括MBAP头本身。
注意:事务处理标识符是客户端自己维护的。我建议用递增的方式,每次请求加1。但要注意,如果并发请求很多,这个值可能会溢出。实际项目中,我一般用循环队列来管理。
4.3 连接管理:长连接还是短连接?
这个问题,我在每个项目里都会被问到。我的回答很简单:工业现场,用长连接。
为什么?你想想看,PLC或者RTU每秒钟可能要采集几十个点。如果每次读写都建立一次TCP连接,三次握手的时间开销就够你受的。而且频繁建立和断开连接,对嵌入式设备的资源消耗也很大。
但长连接也有坑。比如网络抖动导致连接断开,客户端不知道,还在傻等响应。我一般会在客户端加一个心跳机制——每隔30秒发一个空请求,如果连续3次没有响应,就主动重连。
我的经验:连接超时时间设成5秒比较合适。太短了容易误判,太长了现场调试时急死人。
4.4 Python实现:手写一个Modbus TCP客户端
好,理论说完了,咱们动手写代码。我不喜欢用现成的库,因为出了问题你根本不知道底层怎么封装的。自己写一遍,心里才有底。
下面这个例子,实现了一个读取保持寄存器的功能码(03)。
import socket
import struct
class ModbusTCPClient:
def __init__(self, host, port=502):
self.host = host
self.port = port
self.sock = None
self.transaction_id = 0
def connect(self):
"""建立TCP连接"""
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(5.0)
try:
self.sock.connect((self.host, self.port))
print(f"已连接到 {self.host}:{self.port}")
except Exception as e:
print(f"连接失败: {e}")
self.sock = None
def disconnect(self):
"""断开连接"""
if self.sock:
self.sock.close()
self.sock = None
def build_mbap_header(self, length, unit_id=1):
"""构建MBAP报文头"""
self.transaction_id += 1
# 事务ID(2) + 协议ID(2) + 长度(2) + 单元ID(1)
return struct.pack('>HHHB',
self.transaction_id,
0x0000,
length,
unit_id)
def read_holding_registers(self, start_addr, quantity, unit_id=1):
"""读取保持寄存器 (功能码03)"""
if not self.sock:
print("未连接")
return None
# 构建PDU: 功能码(1) + 起始地址(2) + 寄存器数量(2)
pdu = struct.pack('>BHH', 0x03, start_addr, quantity)
# 构建完整报文: MBAP头 + PDU
mbap = self.build_mbap_header(len(pdu) + 1, unit_id) # +1 是因为单元ID
request = mbap + pdu
try:
# 发送请求
self.sock.send(request)
# 接收响应 (MBAP头7字节 + 响应数据)
response = self.sock.recv(1024)
if len(response) < 9:
print("响应太短")
return None
# 解析MBAP头
tid, pid, length, uid = struct.unpack('>HHHB', response[:7])
# 解析PDU
func_code = response[7]
if func_code == 0x83: # 异常响应
error_code = response[8]
print(f"Modbus异常: {error_code}")
return None
# 读取数据字节数
data_len = response[8]
# 读取寄存器值 (每个寄存器2字节)
values = []
for i in range(quantity):
val = struct.unpack('>H', response[9 + i*2 : 11 + i*2])[0]
values.append(val)
return values
except socket.timeout:
print("响应超时")
return None
except Exception as e:
print(f"通信错误: {e}")
return None
# 使用示例
if __name__ == '__main__':
client = ModbusTCPClient('192.168.1.100')
client.connect()
# 读取从地址0开始的10个寄存器
result = client.read_holding_registers(0, 10)
if result:
print(f"寄存器值: {result}")
client.disconnect()
4.5 代码里的小心思
这段代码看起来简单,但有几个地方我特意处理过:
- 事务ID递增:每次请求自动加1,方便调试时追踪报文
- 超时处理:5秒超时,这是工业现场比较通用的值
- 异常码解析:如果返回的功能码是0x83(0x03 + 0x80),说明设备返回了异常
我曾经在一个项目里,设备返回的异常码是0x02(非法数据地址),我查了半天才发现起始地址写错了。所以,异常处理一定要做全,不然现场调试时你会疯掉。
4.6 避坑指南
我曾经踩过的坑:
- 字节序问题:Modbus TCP用的是大端序(Big-Endian)。如果你用C#或者Java,默认就是大端,没问题。但如果你用Python的struct,一定要用
>前缀指定大端。 - 粘包问题:TCP是流式协议,一次recv可能收到多条报文。我一般会先读7字节MBAP头,根据长度字段再读剩下的数据。
- 端口号:默认是502,但有些设备会改成其他端口。记得先确认。
嗯,今天就先聊到这儿。下一节咱们会讲Modbus TCP服务器端的实现,以及如何处理多客户端并发连接。到时候你会看到,服务器端的坑比客户端多得多。