第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()的返回值。

注意:默认端口是502,但有些设备可能用了其他端口。比如我遇到过西门子S7-1200,它默认的Modbus TCP端口是502,但有些国产PLC喜欢用5020。记得先确认设备文档。

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}")
小技巧:很多PLC使用大端字节序(Big-Endian),但有些设备用小端。如果你读出来的数值明显不对,试试交换寄存器的顺序。我曾经在一个水处理项目里,被这个字节序问题折腾了整整一个下午。

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()
核心要点:错误处理不是「锦上添花」,而是「雪中送炭」。一个没有错误处理的SCADA系统,就像没有安全阀的压力容器——迟早要出事。

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界面展示出来。到时候你会发现,有了这个采集引擎,后面的工作就水到渠成了。