第4章:Modbus主站实现——用pymodbus搭建你的第一个通信节点
好,到了真正动手的时候了。前面几章我们把SCADA的骨架搭好了,也了解了Modbus协议的基本原理。现在,我们要让这个系统真正「活」起来——通过Modbus TCP去读写设备数据。
我个人习惯把Modbus主站比作「翻译官」。它负责把我们的读写请求,翻译成PLC、仪表这些从站设备能理解的报文。反过来,把从站的响应数据,翻译成Python能处理的变量。说白了,就是一座桥。
4.1 准备工作:安装pymodbus库
在开始之前,先确认你的环境里装了pymodbus。我用的是3.x版本,跟2.x有一些API上的差异,这里统一用3.x的写法。
pip install pymodbus
嗯,就这么简单。如果你在公司内网环境,记得配好代理或者用离线包。我在一个化工厂项目里就吃过这个亏——现场没网,折腾了半天才把依赖包传上去。
4.2 创建Modbus TCP主站客户端
创建一个Modbus TCP主站,其实就三步:建立连接、读写数据、关闭连接。先看最基础的代码:
from pymodbus.client import ModbusTcpClient
# 创建客户端实例
client = ModbusTcpClient('192.168.1.100', port=502)
# 尝试连接
if client.connect():
print("连接成功!")
else:
print("连接失败,请检查IP和端口")
exit()
这里有个坑,我一开始也踩过——ModbusTcpClient的构造函数并不会真正去连接设备,它只是初始化了一个对象。真正的连接动作,是在调用connect()方法时才发生的。所以一定要检查connect()的返回值。
4.3 读取从站数据
连接建立后,就可以开始读数据了。Modbus定义了多种数据类型,最常用的是线圈(Coil)和保持寄存器(Holding Register)。
4.3.1 读取线圈状态
线圈对应的是开关量,比如电机的启停状态、阀门的开闭状态。用read_coils()方法:
# 读取从站地址1,起始地址0,读取10个线圈
result = client.read_coils(address=0, count=10, slave=1)
if result.isError():
print("读取失败:", result)
else:
for i, bit in enumerate(result.bits):
print(f"线圈 {i}: {'ON' if bit else 'OFF'}")
你想想看,为什么这里要指定slave=1?因为Modbus TCP虽然没有了物理上的从站地址,但协议里保留了Unit ID字段,用来区分同一个网关下的不同设备。我见过有人把这个参数漏了,结果读回来的数据全是乱的。
4.3.2 读取保持寄存器
保持寄存器是16位的,用来存模拟量、温度、压力这些数值。用read_holding_registers():
# 读取从站地址1,起始地址0,读取5个寄存器
result = client.read_holding_registers(address=0, count=5, slave=1)
if result.isError():
print("读取失败:", result)
else:
for i, value in enumerate(result.registers):
print(f"寄存器 {i}: {value}")
这里要注意,result.registers返回的是一个列表,每个元素是0-65535之间的整数。如果你的设备数据是浮点数,那就需要做转换。我习惯用struct库来处理:
import struct
# 假设两个连续的寄存器存了一个32位浮点数
raw_bytes = struct.pack('>HH', result.registers[0], result.registers[1])
float_value = struct.unpack('>f', raw_bytes)[0]
print(f"转换后的浮点数: {float_value}")
4.4 写入数据
读数据只是第一步,SCADA系统更重要的功能是控制——写入数据。
4.4.1 写单个线圈
# 写线圈地址0,值为True(ON)
result = client.write_coil(address=0, value=True, slave=1)
if result.isError():
print("写入失败:", result)
else:
print("写入成功!")
4.4.2 写单个寄存器
# 写寄存器地址0,值为1234
result = client.write_register(address=0, value=1234, slave=1)
if result.isError():
print("写入失败:", result)
else:
print("写入成功!")
4.4.3 批量写入
有时候需要一次性写入多个值,比如设定一组PID参数:
# 批量写入寄存器,从地址0开始,写入[100, 200, 300]
values = [100, 200, 300]
result = client.write_registers(address=0, values=values, slave=1)
if result.isError():
print("批量写入失败:", result)
else:
print(f"成功写入 {len(values)} 个寄存器")
4.5 错误处理——让你的程序更健壮
工业现场的环境很复杂。网线松了、设备断电了、从站忙不过来……这些情况随时可能发生。如果你的程序不做错误处理,一旦出问题,整个SCADA系统就可能卡死。
我总结了一套「三层错误处理」的策略:
| 层级 | 处理方式 | 适用场景 |
|---|---|---|
| 第一层 | 检查返回值 | Modbus协议层面的错误(如非法地址) |
| 第二层 | try-except捕获异常 | 网络连接异常、超时 |
| 第三层 | 重试机制 | 临时性故障(如设备忙) |
看一个完整的例子:
import time
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException, ConnectionException
def read_with_retry(client, address, count, slave, max_retries=3):
"""带重试的读取函数"""
for attempt in range(max_retries):
try:
result = client.read_holding_registers(
address=address,
count=count,
slave=slave
)
# 第一层:检查Modbus协议错误
if result.isError():
print(f"第{attempt+1}次尝试:协议错误 - {result}")
continue
return result.registers
except ConnectionException as e:
# 第二层:网络连接异常
print(f"第{attempt+1}次尝试:连接异常 - {e}")
# 尝试重新连接
client.connect()
except ModbusException as e:
# 其他Modbus异常
print(f"第{attempt+1}次尝试:Modbus异常 - {e}")
# 第三层:等待后重试
time.sleep(1)
# 所有重试都失败
raise Exception("读取失败,已重试%d次" % max_retries)
# 使用示例
client = ModbusTcpClient('192.168.1.100')
client.connect()
try:
data = read_with_retry(client, 0, 10, 1)
print("读取成功:", data)
except Exception as e:
print("最终失败:", e)
finally:
client.close()
4.6 完整示例:一个简单的数据采集循环
最后,我把上面这些知识点串起来,写一个实际项目中常用的数据采集循环:
import time
import logging
from pymodbus.client import ModbusTcpClient
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class ModbusCollector:
def __init__(self, host, port=502, slave=1):
self.host = host
self.port = port
self.slave = slave
self.client = ModbusTcpClient(host, port)
def connect(self):
if self.client.connect():
logger.info(f"已连接到 {self.host}:{self.port}")
return True
else:
logger.error(f"连接失败 {self.host}:{self.port}")
return False
def collect(self):
"""采集一次数据"""
try:
# 读取10个保持寄存器
result = self.client.read_holding_registers(
address=0, count=10, slave=self.slave
)
if result.isError():
logger.error(f"采集错误: {result}")
return None
data = {
'timestamp': time.time(),
'values': result.registers
}
return data
except Exception as e:
logger.error(f"采集异常: {e}")
return None
def run(self, interval=5):
"""持续采集,interval为采集间隔(秒)"""
if not self.connect():
return
try:
while True:
data = self.collect()
if data:
logger.info(f"采集到数据: {data['values']}")
# 这里可以添加存储、报警等逻辑
else:
logger.warning("本次采集失败")
time.sleep(interval)
except KeyboardInterrupt:
logger.info("用户中断采集")
finally:
self.client.close()
logger.info("连接已关闭")
# 启动采集
if __name__ == '__main__':
collector = ModbusCollector('192.168.1.100')
collector.run(interval=2) # 每2秒采集一次
这个类封装了连接、采集、重连、日志等常用功能。你可以在run()方法的循环里,加入数据入库、界面刷新、报警判断等逻辑。说白了,这就是一个微型SCADA采集引擎的核心骨架。
嗯,到这里,Modbus主站的基础实现就讲完了。下一章我们会聊怎么把采集到的数据存起来,以及如何用Web界面展示出来。到时候你会发现,有了这个采集引擎,后面的工作就水到渠成了。