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服务器端的实现,以及如何处理多客户端并发连接。到时候你会看到,服务器端的坑比客户端多得多。