工业一体机在产线数据采集场景中,经常需要通过Modbus TCP协议与PLC、仪表和传感器等设备通信。本文基于pymodbus库,从协议基础到多设备轮询实战,完整记录在Linux工业一体机上部署Modbus TCP客户端的全过程。
Modbus TCP协议基础
Modbus TCP是Modbus协议的以太网实现版本,协议规范由Modbus组织维护(MB/TCP规范文档)。相比Modbus RTU的串行通信,TCP版本去掉了CRC校验(由TCP层保证可靠性),增加了MBAP报文头(Modbus Application Protocol Header),固定7字节长度。
一个完整的Modbus TCP请求帧结构如下:
```
事务ID(2B)\] \[协议ID(2B)\] \[长度(2B)\] \[单元ID(1B)\] \[功能码(1B)\] \[数据(NB)
```
常用功能码:
-
0x03 读保持寄存器
-
0x04 读输入寄存器
-
0x06 写单个寄存器
-
0x10 写多个寄存器
环境搭建
工业一体机运行Debian 12,Python 3.11。先装依赖:
```bash
pip install pymodbus==3.6.3
pip install pyserial # 如果还需要Modbus RTU over TCP
```
确认网络连通性,假设PLC的IP是192.168.1.50,Modbus TCP端口默认502:
```bash
ping 192.168.1.50
确认能通之后测试端口
nc -zv 192.168.1.50 502
```
单设备通信实现
先写一个最基本的单设备读取:
```python
from pymodbus.client import ModbusTcpClient
class ModbusDevice:
def init(self, ip, port=502, timeout=3, unit_id=1):
self.client = ModbusTcpClient(
ip, port=port, timeout=timeout
)
self.unit_id = unit_id
def connect(self):
if not self.client.connect():
raise ConnectionError(f"无法连接到设备 {self.client.host}:{self.client.port}")
return self
def read_holding(self, address, count=1):
resp = self.client.read_holding_registers(
address=address,
count=count,
slave=self.unit_id
)
if resp.isError():
raise IOError(f"读取保持寄存器失败: {resp}")
return resp.registers
def write_register(self, address, value):
resp = self.client.write_register(
address=address,
value=value,
slave=self.unit_id
)
if resp.isError():
raise IOError(f"写入寄存器失败: {resp}")
return True
def close(self):
self.client.close()
```
用起来很简单:
```python
try:
dev = ModbusDevice("192.168.1.50").connect()
读地址0开始的4个保持寄存器
values = dev.read_holding(address=0, count=4)
print(f"寄存器值: {values}")
写单个寄存器
dev.write_register(address=10, value=100)
dev.close()
except Exception as e:
print(f"通信异常: {e}")
```
多设备轮询的坑
单设备通信跑通了就上多设备。产线上一台工业一体机通常要接好几台PLC或仪表,轮询的时候有几个坑我是踩过的。
**第一个坑:连接复用 vs 每次新建连接**
Modbus TCP的连接是长连接,按理说建一次连接后可以反复用。但实际在工业现场,网络偶发抖动会导致TCP连接半断开状态,你的程序以为连着实际已经断了。我的做法是每轮完整采集后断开重连,牺牲一点延迟换稳定性。
**第二个坑:轮询间隔太短导致PLC响应不过来**
有些低端PLC处理Modbus请求的能力有限,你刚发完上一个请求还没等它处理完就发下一个,它会直接丢包。我测过某国产PLC,连续请求间隔小于50ms就丢数据。
**第三个坑:异常不处理导致整个轮询卡死**
一台设备通信超时了,如果没有超时控制,整个轮询线程就卡在那里。后面的设备都读不到了。
针对这几个坑,对比一下串行和并行两种轮询方案的差异:
| 轮询方案 | 4台设备单轮耗时 | 稳定性 | 实现复杂度 | 适用场景 |
| --- | --- | --- | --- | --- |
| 串行轮询 | 约2.4秒 | 较高 | 低 | 设备少、实时性要求不高 |
| 并行轮询 | 约0.8秒 | 需处理线程异常 | 中 | 设备多、要求快速采集 |
| 并行+每次新建连接 | 约1.0秒 | 高 | 中高 | 网络不稳定的车间 |
| 串行+连接复用 | 约0.6秒 | 网络抖动时低 | 低 | 网络稳定的洁净环境 |
下面是我改进后的多设备轮询实现:
```python
import time
import threading
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor, as_completed
@dataclass
class DeviceConfig:
name: str
ip: str
port: int = 502
unit_id: int = 1
registers: list = field(default_factory=list) # (地址, 数量), ...
interval_ms: int = 100 # 轮询间隔
class MultiDevicePoller:
def init(self, configs, max_workers=4):
self.configs = configs
self.pool = ThreadPoolExecutor(max_workers=max_workers)
self.results = {}
self._running = False
def _poll_single(self, config):
"""单设备采集,每次新建连接"""
result = {"name": config.name, "data": {}, "error": None}
try:
dev = ModbusDevice(
config.ip, config.port,
timeout=2, unit_id=config.unit_id
).connect()
for addr, count in config.registers:
values = dev.read_holding(address=addr, count=count)
result"data"addr = values
寄存器间留间隔,避免PLC处理不过来
time.sleep(config.interval_ms / 1000.0)
dev.close()
except Exception as e:
result"error" = str(e)
return result
def poll_once(self):
"""并行轮询所有设备"""
futures = \[\]
for cfg in self.configs:
f = self.pool.submit(self._poll_single, cfg)
futures.append(f)
for f in as_completed(futures, timeout=10):
r = f.result()
self.resultsr\["name"] = r
return dict(self.results)
def start_loop(self, interval_s=1.0):
"""持续轮询循环"""
self._running = True
while self._running:
results = self.poll_once()
for name, r in results.items():
if r"error":
print(f"{name} 采集失败: {r'error'}")
else:
print(f"{name} 数据: {r'data'}")
time.sleep(interval_s)
def stop(self):
self._running = False
self.pool.shutdown(wait=False)
```
配置和使用
```python
if name == "main":
configs = [
DeviceConfig(
name="主PLC",
ip="192.168.1.50",
unit_id=1,
registers=(0, 10), (100, 4),
interval_ms=80
),
DeviceConfig(
name="温度仪表",
ip="192.168.1.51",
unit_id=2,
registers=(0, 2),
interval_ms=100
),
DeviceConfig(
name="电能表",
ip="192.168.1.52",
unit_id=3,
registers=(0x200, 8),
interval_ms=120
),
]
poller = MultiDevicePoller(configs, max_workers=3)
poller.start_loop(interval_s=1.0)
```
性能优化建议
实测4台设备并行轮询,单轮采集耗时从串行的2.4秒降到并行的0.8秒左右。几个优化点记录一下:
-
**调整max_workers**:不要超过工业一体机的CPU核心数。RK3568是4核,max_workers设3到4个比较合适,留一个核给系统。
-
**超时设短**:timeout=2秒比默认的3秒好。正常响应在100ms内,2秒还没响应基本就是有问题了。
-
**指数退避重连**:设备偶尔超时不用立刻报错,重试一次再报。我在_poll_single里加了简单重试逻辑:
```python
for attempt in range(2):
try:
dev = ModbusDevice(config.ip, ...).connect()
... 读取逻辑
dev.close()
break
except Exception as e:
if attempt == 0:
time.sleep(0.5)
continue
result"error" = str(e)
```
- **日志落盘**:异常频率做统计比实时打印有用。把error写入CSV,定期分析哪台设备通信最不稳定。
常见问题(FAQ)
**Q:Modbus TCP和Modbus RTU在寄存器地址映射上有什么区别?**
寄存器地址映射本身没有区别,两者的地址空间定义一致(保持寄存器0x类、输入寄存器3x/4x等)。主要区别在于传输层和报文格式。Modbus TCP通过MBAP头中的Unit ID实现多设备寻址,而RTU通过总线上的从站地址寻址。代码层面使用pymodbus时,TCP用slave参数对应Unit ID,RTU用slave参数对应从站地址。
**Q:工业一体机上同时跑Modbus TCP和Modbus RTU(串口)需要注意什么?**
需要在代码中分别创建TCP客户端和RTU客户端实例,互不干扰。RTU通信使用串口(如/dev/ttyS0),TCP使用网络套接字。需要注意的是两者不要在同一个线程中交替调用,否则串口的阻塞等待可能影响TCP的轮询节奏。建议将RTU和TCP分别放在独立线程或使用异步框架(如asyncio)管理。
**Q:如何处理Modbus TCP通信中PLC返回的异常码?**
Modbus协议定义了5种标准异常码:01非法功能码、02非法数据地址、03非法数据值、04从站设备故障、05确认。pymodbus在resp.isError()为True时可以通过resp.exception_code获取具体异常码。建议根据异常码做针对性处理,如地址错误应检查配置,设备故障应触发告警并重试。