Modbus协议及基于Python的ModbusTCP客户端实现
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,供用户自定义专用功能。
-
保留功能码:其他未分配的功能码留作未来扩展。
常用公共功能码:
| 功能码 | 名称 | 数据模型 | 描述 |
|---|---|---|---|
| 01 | Read Coils | 线圈 | 读取多个线圈状态 |
| 02 | Read Discrete Inputs | 离散输入 | 读取多个离散输入状态 |
| 03 | Read Holding Registers | 保持寄存器 | 读取多个保持寄存器值 |
| 04 | Read Input Registers | 输入寄存器 | 读取多个输入寄存器值 |
| 05 | Write Single Coil | 线圈 | 写入单个线圈状态 |
| 06 | Write Single Register | 保持寄存器 | 写入单个保持寄存器值 |
| 15 | Write Multiple Coils | 线圈 | 写入多个线圈状态 |
| 16 | Write 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 RTU | Modbus 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.Timer或schedule库实现定时任务。
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流量的利器。设置过滤条件modbus或tcp.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 TCP | OPC UA | Profinet | EtherNet/IP |
|---|---|---|---|---|
| 复杂性 | 低 | 高 | 中 | 中 |
| 安全性 | 无 | 内置安全 | 可选 | 可选 |
| 数据模型 | 简单 | 复杂(面向对象) | 基于PROFIBUS | 基于CIP |
| 实时性 | 一般 | 一般 | 高 | 高 |
| 跨平台 | 好 | 好 | 专有 | 较好 |
| 适用场景 | 简单监控、数据采集 | 复杂系统集成 | 运动控制、实时IO | 自动化设备 |
Modbus TCP以其简单性在数据采集和监控(SCADA)领域广泛应用,适合中小规模系统。对于需要复杂数据建模和高安全性的场景,OPC UA更合适。
更多推荐


所有评论(0)