1. 引言

1.1 什么是Modbus?

Modbus是一种应用层报文传输协议,最初由Modicon(现为施耐德电气)于1979年为可编程逻辑控制器(PLC)通信而设计。它已成为工业自动化领域最广泛使用的通信协议之一,其开放、简单、易于部署的特点使其成为连接电子设备的常用方式。Modbus协议定义了一种主从(Master/Slave)或客户端/服务器(Client/Server)的通信模式,允许主设备与多个从设备之间进行数据交换。

1.2 Modbus的历史与发展

Modbus协议诞生于PLC早期,当时工业设备之间的互连需求迫切。Modicon公司开放了协议规范,使得其他制造商也能在其设备上实现Modbus,从而促进了多厂商设备的互操作性。经过四十多年的发展,Modbus协议依然活跃,并衍生出多个变种以适应不同的物理层和网络环境。2004年,Modbus组织(Modbus Organization)成立,致力于Modbus协议的推广和维护,并发布了Modbus TCP规范。

1.3 Modbus的变种(RTU, ASCII, TCP)

根据数据表示和传输方式的不同,Modbus主要有三种常见形式:

  • Modbus RTU:远程终端单元模式。采用二进制编码,数据以紧凑的二进制格式传输,每个字节由8位数据位、1位停止位(可配置奇偶校验)组成。报文末尾带有循环冗余校验(CRC)以保证数据完整性。RTU模式效率高,广泛应用于串行通信(RS-232/485)。

  • Modbus ASCII:美国标准信息交换代码模式。将每个字节拆分为两个ASCII字符传输,使用可读的字符,但传输效率较低。报文使用特定的开始和结束标记,校验采用纵向冗余校验(LRC)。ASCII模式通常用于调试或通信链路质量较差的环境。

  • Modbus TCP:基于TCP/IP网络的Modbus变种。它将Modbus PDU(协议数据单元)封装在TCP报文中,去除了RTU/ASCII中的地址和校验字段,转而使用MBAP(Modbus应用协议头)作为头部。Modbus TCP使用以太网和TCP/IP协议栈,使得设备可以通过局域网或互联网进行通信。

1.4 为什么选择Modbus TCP?

随着工业以太网的普及,Modbus TCP凭借以下优势成为现代工业通信的热门选择:

  • 标准化:基于成熟的TCP/IP协议,易于与IT系统集成。

  • 高速:以太网带宽远高于串行链路,支持更大数据量的快速传输。

  • 灵活拓扑:可利用以太网交换机、路由器构建复杂网络。

  • 简单性:协议头部简单,无需地址和校验(由TCP层保证可靠性)。

  • 多客户端支持:一个服务器可以同时与多个客户端通信(TCP连接)。

  • 开源支持:大量编程语言(如Python)有成熟的Modbus库,开发门槛低。


2. Modbus协议基础

2.1 主从架构

在传统的串行Modbus(RTU/ASCII)中,网络采用主从模式:一个主设备(Master)发起请求,一个或多个从设备(Slave)响应。每个从设备有唯一的地址(1-247)。主设备发送的请求中包含目标从设备地址,只有地址匹配的从设备才会处理并回复。从设备之间不能直接通信。

Modbus TCP则采用客户端/服务器模型:客户端主动发起TCP连接并发送请求,服务器(通常是现场设备)监听502端口并响应。由于TCP本身提供连接,服务器可以同时处理多个客户端的请求,每个请求由事务标识符区分。

2.2 数据模型

Modbus协议定义了一套数据模型,用于访问设备中的各类数据。这些数据模型分为四种基本类型,每种类型均可独立寻址(地址范围0-65535):

数据模型对象类型访问类型数据位宽典型用途
离散输入只读1位开关状态、传感器状态
线圈读写1位继电器输出、指示灯
输入寄存器16位字只读16位模拟量输入、计数器值
保持寄存器16位字读写16位设定参数、累计值

尽管地址范围从0开始,但在协议报文中的地址编码是16位(0x0000-0xFFFF),实际访问时使用“协议地址”,而设备厂商可能将其映射为“物理地址”或“逻辑地址”。例如,一个线圈可能被称为“00001”,其协议地址为0。

2.3 功能码分类

功能码(Function Code)指示服务器执行何种操作。功能码分为三类:

  • 公共功能码:由Modbus组织定义并保证唯一性,如01(读线圈)、03(读保持寄存器)、05(写单个线圈)、06(写单个寄存器)、15(写多个线圈)、16(写多个寄存器)等。

  • 用户自定义功能码:65-72和100-110,供用户自定义专用功能。

  • 保留功能码:其他未分配的功能码留作未来扩展。

常用公共功能码:

功能码名称数据模型描述
01Read Coils线圈读取多个线圈状态
02Read Discrete Inputs离散输入读取多个离散输入状态
03Read Holding Registers保持寄存器读取多个保持寄存器值
04Read Input Registers输入寄存器读取多个输入寄存器值
05Write Single Coil线圈写入单个线圈状态
06Write Single Register保持寄存器写入单个保持寄存器值
15Write Multiple Coils线圈写入多个线圈状态
16Write Multiple Registers保持寄存器写入多个保持寄存器值
2.4 报文格式(ADU, PDU)

Modbus协议定义了协议数据单元(PDU)和应用数据单元(ADU)。PDU是独立于底层通信的报文部分,由功能码和数据组成。ADU则是在PDU基础上附加特定于网络的头部和校验。

  • PDU = 功能码(1字节) + 数据(n字节,取决于功能码)

  • ADU = 附加信息 + PDU + 校验(某些变种)

对于Modbus RTU,ADU = 从站地址(1字节) + PDU + CRC校验(2字节)。
对于Modbus TCP,ADU = MBAP头部(7字节) + PDU,无地址和CRC(因TCP已提供可靠性)。

2.5 异常响应

当服务器无法正常处理请求时,会返回异常响应。异常响应PDU的功能码为请求功能码加上0x80,随后是一个异常码(1字节),说明错误类型。

常见异常码:

异常码名称描述
01非法功能码服务器不支持请求的功能码
02非法数据地址请求的数据地址超出范围
03非法数据值请求的数据值超出允许范围
04从设备故障服务器在处理请求时发生不可恢复错误
05确认服务器已接受请求但需要长时间处理
06从设备忙服务器正忙,请稍后重试

客户端收到异常响应后,应解析异常码并采取相应措施(如重试、记录错误、调整请求)。


3. Modbus TCP协议详解

3.1 Modbus TCP ADU结构

Modbus TCP的ADU由两部分组成:MBAP头部(7字节)和Modbus PDU(功能码+数据)。

MBAP头部(Modbus Application Protocol Header)包含以下字段:

  • 事务标识符(2字节):用于请求/响应匹配,由客户端生成,服务器在响应中原样返回。

  • 协议标识符(2字节):固定为0x0000,表示Modbus协议。

  • 长度(2字节):后续字节数(包括单元标识符和PDU的长度)。

  • 单元标识符(1字节):相当于传统串行Modbus的从站地址,用于标识通过网关连接的远程从站(如果TCP服务器本身就是最终设备,则通常填0x00或0xFF,但标准推荐填0x00)。

PDU:与Modbus RTU相同,即功能码+数据。

因此,完整的Modbus TCP报文结构如下:

text

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Transaction Identifier          | Protocol Identifier |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Length                        | Unit Identifier     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                     |
|  Function Code   |                   Data ...                  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
3.2 端口502

IANA将TCP端口502分配给Modbus协议。因此,Modbus TCP服务器默认监听502端口,客户端连接时也使用该端口。

3.3 与Modbus RTU的区别
特性Modbus RTUModbus TCP
物理层RS-232/485以太网
传输层串行通信TCP/IP
地址每个从站有唯一地址使用IP地址,单元标识符可选
校验CRC-16由TCP校验和保证
报文头MBAP头部(7字节)
最大PDU大小253字节(RTU附加1地址+2CRC)260字节(受TCP MTU限制)
通信模式严格主从,同一时间只有一个主站客户端/服务器,允许多客户端
连接管理无连接面向连接(TCP)
3.4 报文示例与解析

假设客户端要读取地址为0的保持寄存器(功能码03),起始地址0,数量2。构建的Modbus TCP报文(十六进制):

  • 事务标识符:0x0001(随机)

  • 协议标识符:0x0000

  • 长度:0x0006(后续字节数:1单元标识符 + 1功能码 + 4数据 = 6)

  • 单元标识符:0x01

  • 功能码:0x03

  • 起始地址高字节:0x00

  • 起始地址低字节:0x00

  • 寄存器数量高字节:0x00

  • 寄存器数量低字节:0x02

完整报文:00 01 00 00 00 06 01 03 00 00 00 02

服务器正常响应(假设寄存器值为0x1234和0x5678):

  • 事务标识符:0x0001(原样返回)

  • 协议标识符:0x0000

  • 长度:0x0007(1单元标识符 + 1功能码 + 1字节计数 + 4字节数据 = 7)

  • 单元标识符:0x01

  • 功能码:0x03

  • 字节计数:0x04

  • 数据:0x12 0x34 0x56 0x78

响应报文:00 01 00 00 00 07 01 03 04 12 34 56 78

3.5 连接管理与事务标识符

由于TCP是面向连接的协议,客户端与服务器建立持久连接(或短连接)后,可以在一个连接上发送多个请求。事务标识符用于匹配请求和响应,避免混淆。客户端应为每个请求分配一个唯一的事务ID(通常递增),服务器在响应中必须复制该ID。如果客户端在同一连接上同时发送多个请求(流水线),响应可能乱序返回,但通过事务ID仍可正确配对。

需要注意的是,单元标识符用于通过Modbus TCP网关访问下游串行总线上的从站。如果TCP服务器本身就是最终设备,单元标识符通常设置为0x00或忽略。


4. Python Modbus TCP客户端实现

4.1 选择Python库

Python社区提供了多个Modbus库,各具特点:

  • pymodbus:最流行的Modbus库,支持同步/异步客户端、服务器模拟、RTU/TCP/ASCII等,功能全面,文档丰富。

  • modbus-tk:轻量级库,支持Modbus RTU/TCP,适合嵌入式或资源受限环境。

  • minimalmodbus:专注于串行Modbus RTU,简单易用,但不支持TCP。

  • pyModbusTCP:纯Python实现的Modbus TCP客户端,依赖少,代码简洁。

  • uasyncio-modbus:基于asyncio的异步库。

综合考虑功能、文档和社区活跃度,pymodbus是最佳选择。以下示例均基于pymodbus(版本3.x)。

4.2 安装pymodbus

使用pip安装pymodbus:

bash

pip install pymodbus

如需开发版本(最新特性),可从GitHub安装。

4.3 基本客户端创建与连接

pymodbus提供了多种客户端类,最常用的是ModbusTcpClient(同步)和AsyncModbusTcpClient(异步)。以下演示同步客户端的基本用法:

python

from pymodbus.client import ModbusTcpClient

# 创建客户端,指定服务器IP和端口
client = ModbusTcpClient('192.168.1.100', port=502)

# 连接服务器
connection = client.connect()
if connection:
    print("连接成功")
else:
    print("连接失败")
    exit(1)

# ... 执行读写操作 ...

# 关闭连接
client.close()

connect()方法会建立TCP连接,返回布尔值表示成功与否。客户端内部维护套接字,可以多次读写,最后调用close()释放资源。

4.4 同步客户端 vs 异步客户端
  • 同步客户端:每个操作会阻塞直到完成或超时,适合简单脚本或低并发场景。

  • 异步客户端:基于asyncio,允许并发处理多个请求,适合高吞吐量或需要同时与多个设备通信的应用。

异步客户端示例:

python

import asyncio
from pymodbus.client import AsyncModbusTcpClient

async def read_data():
    client = AsyncModbusTcpClient('192.168.1.100')
    await client.connect()
    # 读取操作
    result = await client.read_holding_registers(0, 2, slave=1)
    print(result.registers)
    client.close()

asyncio.run(read_data())
4.5 实现读写操作

pymodbus的客户端提供了对应功能码的方法,命名直观。下面以同步客户端为例,演示常用操作。

4.5.1 读取线圈 (功能码01)

python

# 读取从地址0开始的5个线圈
result = client.read_coils(0, 5, slave=1)
if result.isError():
    print("读取失败:", result)
else:
    # result.bits是一个列表,每个元素是布尔值
    coils = result.bits
    print("线圈状态:", coils)
4.5.2 读取离散输入 (功能码02)

python

result = client.read_discrete_inputs(0, 8, slave=1)
if not result.isError():
    inputs = result.bits
    print("离散输入:", inputs)
4.5.3 读取保持寄存器 (功能码03)

python

# 读取地址0开始的3个保持寄存器
result = client.read_holding_registers(0, 3, slave=1)
if not result.isError():
    registers = result.registers
    print("寄存器值:", registers)  # 例如 [123, 456, 789]
4.5.4 读取输入寄存器 (功能码04)

python

result = client.read_input_registers(0, 2, slave=1)
if not result.isError():
    registers = result.registers
    print("输入寄存器:", registers)
4.5.5 写单个线圈 (功能码05)

python

# 将地址5的线圈设置为ON (True)
result = client.write_coil(5, True, slave=1)
if not result.isError():
    print("写入成功")
4.5.6 写单个寄存器 (功能码06)

python

# 将地址10的寄存器设置为1000
result = client.write_register(10, 1000, slave=1)
if not result.isError():
    print("写入成功")
4.5.7 写多个线圈 (功能码15)

python

# 从地址0开始连续写入线圈状态 [1,0,1,1]
values = [True, False, True, True]
result = client.write_coils(0, values, slave=1)
if not result.isError():
    print("批量写入线圈成功")
4.5.8 写多个寄存器 (功能码16)

python

# 从地址20开始写入寄存器值 [100, 200, 300]
values = [100, 200, 300]
result = client.write_registers(20, values, slave=1)
if not result.isError():
    print("批量写入寄存器成功")
4.6 异常处理与错误码

所有读写方法返回的对象都有isError()方法判断是否出错。如果出错,可通过exception_code属性获取Modbus异常码,或者打印错误信息。

python

result = client.read_holding_registers(0, 3)
if result.isError():
    # 可能是连接问题或Modbus异常
    if hasattr(result, 'exception_code'):
        print(f"Modbus异常码: {result.exception_code}")
    else:
        print(f"通信错误: {result}")
else:
    # 正常处理

常见的错误包括:连接超时、服务器无响应、非法地址等。可以通过捕获异常或检查返回值来处理。

4.7 超时与重连机制

pymodbus客户端允许设置超时参数:

python

client = ModbusTcpClient('192.168.1.100', timeout=3)  # 3秒超时

默认超时是3秒。如果请求未在超时内返回,将引发异常或返回错误响应。

实际应用中,网络可能不稳定,需要实现重连逻辑。简单示例:

python

def read_with_retry(client, func, max_retries=3):
    for attempt in range(max_retries):
        try:
            result = func()
            if not result.isError():
                return result
            else:
                print(f"尝试 {attempt+1} 失败: {result}")
        except Exception as e:
            print(f"异常: {e}")
        # 重连前等待
        time.sleep(1)
        # 重新连接
        if not client.connected:
            client.connect()
    return None
4.8 批量读写优化

当需要读取大量连续寄存器时,建议使用批量读取而非多次单点读取,以减少网络往返。例如,一次读取100个寄存器比100次读取单个寄存器快得多。同样,写入时尽量使用批量写入。

如果需要读写非连续地址,可以设计请求队列或使用Modbus的“读/写多个寄存器”功能(功能码23)组合操作,但pymodbus也提供了readwrite_registers方法实现组合读写。


5. 实战案例

5.1 与Modbus模拟器通信

首先,使用Modbus模拟器(如Modbus Slave、ModSim)搭建一个虚拟服务器。设置服务器IP为本地回环地址127.0.0.1,端口502,并配置一些寄存器数据。然后编写Python脚本读取这些数据。

python

from pymodbus.client import ModbusTcpClient
import time

client = ModbusTcpClient('127.0.0.1', port=502)
client.connect()

# 读取保持寄存器0-9
result = client.read_holding_registers(0, 10)
if not result.isError():
    print("寄存器0-9:", result.registers)

# 写入单个寄存器
client.write_register(5, 12345)

# 再次读取验证
result = client.read_holding_registers(5, 1)
print("寄存器5现在:", result.registers[0])

client.close()
5.2 从实际设备读取数据

假设现场有一台温控器支持Modbus TCP,IP为192.168.1.200。其文档说明温度值存储在保持寄存器40001(协议地址0),为有符号16位整数,单位0.1°C。我们需要实时读取温度。

python

def read_temperature(client):
    result = client.read_holding_registers(0, 1, slave=1)  # 单元标识符1
    if not result.isError():
        raw = result.registers[0]
        # 转换为有符号整数
        if raw > 32767:
            raw -= 65536
        temp = raw * 0.1
        return temp
    else:
        return None
5.3 数据解析与转换

工业设备常使用32位浮点数或整数组合存储。例如,一个32位浮点数占用两个连续的16位寄存器,常见大端序(高字节在前)或小端序(低字节在前)。假设设备以大端序存储浮点数,如寄存器0(高16位)和寄存器1(低16位)。可使用struct模块解析。

python

import struct

def read_float(client, address):
    result = client.read_holding_registers(address, 2)
    if result.isError():
        return None
    # 将两个16位寄存器组合成32位整数(大端序)
    combined = (result.registers[0] << 16) | result.registers[1]
    # 转换为浮点数(假设符合IEEE754)
    float_val = struct.unpack('>f', struct.pack('>I', combined))[0]
    return float_val

如果需要小端序,只需调整字节顺序:<f<I

5.4 定时轮询与日志记录

在监控系统中,通常需要定期轮询设备数据并记录到文件或数据库。可以使用threading.Timerschedule库实现定时任务。

python

import time
import logging
from pymodbus.client import ModbusTcpClient

logging.basicConfig(filename='modbus.log', level=logging.INFO)

def poll_device():
    client = ModbusTcpClient('192.168.1.200')
    if client.connect():
        temp = read_temperature(client)
        if temp is not None:
            logging.info(f"温度: {temp:.1f}°C")
        client.close()
    else:
        logging.error("连接失败")
    # 10秒后再次执行
    threading.Timer(10, poll_device).start()

poll_device()
5.5 多线程/异步并发请求

如果需要同时监控多个设备,可以为每个设备创建一个线程,或者使用异步客户端提高效率。异步示例:

python

import asyncio
from pymodbus.client import AsyncModbusTcpClient

async def read_device(ip, address, count):
    client = AsyncModbusTcpClient(ip)
    await client.connect()
    result = await client.read_holding_registers(address, count)
    client.close()
    return result.registers if not result.isError() else None

async def main():
    devices = ['192.168.1.101', '192.168.1.102', '192.168.1.103']
    tasks = [read_device(ip, 0, 5) for ip in devices]
    results = await asyncio.gather(*tasks)
    for ip, regs in zip(devices, results):
        print(f"{ip}: {regs}")

asyncio.run(main())

6. 调试与测试

6.1 使用Wireshark抓包分析

Wireshark是分析Modbus TCP流量的利器。设置过滤条件modbustcp.port == 502,即可捕获所有Modbus TCP报文。通过分析报文,可以验证请求/响应是否正确,发现协议错误。

重要观察点:

  • 事务ID是否匹配。

  • 长度字段是否正确。

  • 功能码和数据是否符合预期。

  • 异常码含义。

6.2 常见问题与解决方法
问题现象可能原因解决方法
连接失败IP错误、端口错误、防火墙阻止检查网络配置,ping测试,关闭防火墙
请求超时设备无响应、网络延迟高、超时设置过短增加超时时间,检查设备状态
读取数据异常(全0或全FF)地址错误、设备未配置数据核对设备文档,使用模拟器测试
异常码02(非法地址)请求地址超出设备范围查阅设备手册,调整起始地址
异常码03(非法数据值)写入的值超出允许范围检查写入值是否在范围内
异常码04(从设备故障)设备内部错误重启设备,检查设备日志
多个客户端同时连接冲突设备不支持多连接确保一次只有一个客户端连接,或使用连接池
6.3 Modbus模拟器推荐
  • Modbus Slave:功能强大的模拟器,可模拟多个从站,支持RTU/ASCII/TCP,适合测试客户端。

  • ModSim:简单的Modbus TCP模拟器,免费,可快速搭建测试环境。

  • QModMaster:开源Modbus主站模拟器,可用于发送请求并查看响应。

  • Python内置模拟器:pymodbus提供了StartTcpServer等类,可用Python编写自定义模拟器。


7. 高级主题

7.1 Modbus TCP安全性

Modbus TCP原生没有任何安全机制,所有数据明文传输,易受中间人攻击、重放攻击等。在工业环境中,可采取以下措施增强安全性:

  • 网络隔离:将Modbus TCP设备置于专用VLAN,通过防火墙限制访问。

  • VPN/加密隧道:使用IPsec或OpenVPN等加密通信。

  • TLS封装:Modbus/TCP Security规范(RFC中未正式定义)提出使用TLS加密Modbus报文,即Modbus over TLS,端口802。但设备支持有限。

  • 认证与授权:在应用层添加密码或令牌机制,但会增加复杂性。

7.2 网关与桥接

Modbus TCP网关可以将Modbus TCP请求转换为Modbus RTU/ASCII,从而连接传统串行设备。网关通常有一个以太网接口和一个或多个串行接口。客户端通过TCP连接到网关,并在请求中指定单元标识符(即从站地址),网关根据该标识符将请求转发到相应串行总线上的从站。

在pymodbus中,只需在读写方法中指定slave参数即可:

python

# 请求通过网关访问串行总线上地址为1的设备
result = client.read_holding_registers(0, 10, slave=1)

网关负责处理单元标识符到串行地址的映射,并封装RTU帧。

7.3 性能优化
  • 连接复用:避免频繁创建和销毁TCP连接,使用长连接。

  • 异步IO:对于高并发场景,使用异步客户端,利用asyncio实现事件驱动。

  • 批量操作:合并读写请求,减少交互次数。

  • 调整超时:根据网络延迟设置合理的超时,避免过长的等待。

  • 使用连接池:对于多线程环境,可以为每个线程创建独立的连接,或使用连接池管理。

7.4 与其他工业协议的对比
特性Modbus TCPOPC UAProfinetEtherNet/IP
复杂性
安全性内置安全可选可选
数据模型简单复杂(面向对象)基于PROFIBUS基于CIP
实时性一般一般
跨平台专有较好
适用场景简单监控、数据采集复杂系统集成运动控制、实时IO自动化设备

Modbus TCP以其简单性在数据采集和监控(SCADA)领域广泛应用,适合中小规模系统。对于需要复杂数据建模和高安全性的场景,OPC UA更合适。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐