1. 写在前面:这个系列要做什么
如果你是一位自动化工程师、IT/OT 融合工程师,或者正在从传统 IT 转向工业物联网的开发者——这个系列是为你准备的。
市面上关于 PLC 数据采集的资料不少,但大多数停留在"教你怎么配置一个 OPC UA 服务器"或者"用 Python 读一个 Modbus 寄存器"的表面操作上。一旦你走进真实工厂,你会发现坑远比想象的多:
- 同样一台 PLC,A 供应商的采集系统跑得好好的,B 供应商的却疯狂丢点
- 测试环境 1 秒采一次没问题,到了产线 100ms 采一次就频繁断连
- 数据读出来了,但数值对不上——不是线接错了,而是字节序反了
- OPC UA 连上了,但 CPU 占用率飙到 80%,操作工抱怨触摸屏反应变慢
这些不是代码 bug,是对底层机理缺乏理解的必然结果。
本系列的目标很简单:每一篇解决一个真实工程问题,挖到协议和系统层面,给出能直接运行的代码验证。
2. 一个真实的故事
2018 年,我经手一个项目:某汽车零部件厂需要将 12 台西门子 S7-1200 的产线数据实时采集到 MES 系统。甲方要求"采集频率不低于 200ms"——听起来很轻松,对吧?
结果上线第一天就出事了。
产线操作工反馈:触摸屏显示的数值和 MES 上看到的数值差了整整 3 秒,而且操作工按下按钮后,MES 那边有时根本收不到信号。
排查了三天,最后发现原因令人哭笑不得:
- PLC 扫描周期约 25ms(程序量中等)
- 数据采集器用 Modbus TCP 轮询 12 台 PLC,每台读取 20+ 个寄存器
- 轮询间隔设成了 200ms,但忽略了单次请求-响应的网络往返时间
- 实际单台 PLC 的完整采集周期 = 200ms 间隔 + 45ms 网络延迟 = 245ms
- 12 台 PLC 串行轮询,最差情况下最后一台要等 245ms × 11 ≈ 2.7 秒才能轮到
- 操作工按下按钮产生的信号变化,在 PLC 内只维持了一个扫描周期就被下次采集覆盖了
数据线都接好了,所有寄存器地址都对,但采集系统就是在"认真地采集错误的数据"。
这就是我说的"第一个深坑":你以为数据采集只是"连接 + 读取",实际上时序、频率、缓冲区、协议开销交织在一起,形成一个复杂的系统问题。
3. 核心冲突:PLC 扫描周期 vs 外部采集周期
3.1 PLC 内部在发生什么
每一台 PLC 都在循环执行一个任务序列:
sequenceDiagram
participant IO as 物理输入
participant CPU as CPU 执行
participant Mem as 数据存储区
participant Comm as 通信处理器
Note over CPU: 一个扫描周期(Scan Cycle)
CPU->>IO: 读取物理输入(PII 刷新)
CPU->>CPU: 执行用户程序(逻辑运算)
CPU->>Mem: 更新 DB 块/寄存器值
CPU->>IO: 写入物理输出(PIQ 刷新)
Note over CPU,Comm: 通信任务在扫描周期的"空闲段"执行
CPU->>Comm: 通信处理器处理外部请求(Modbus/Profinet/OPC UA)
Comm-->>CPU: 返回结果
一个关键事实是:PLC 的通信任务(响应外部数据请求)是在程序扫描的"间隙"执行的,优先级低于用户程序。如果你的采集请求频率过高,PLC 的通信处理器会成为瓶颈,反过来影响程序扫描时间。
不同品牌 PLC 的典型扫描周期如下:
| PLC 品牌/系列 | 典型扫描周期 | 通信处理方式 | 采集频率建议上限 |
|---|---|---|---|
| 西门子 S7-1200 | 10~50ms | 独立通信处理器(CP) | 每 100ms 一次 |
| 西门子 S7-1500 | 1~10ms | 背板总线集成 | 每 50ms 一次 |
| 罗克韦尔 CompactLogix | 5~50ms | 异步通信任务 | 每 100ms 一次 |
| 三菱 FX5U | 20~100ms | 专用通信指令 | 每 200ms 一次 |
| 欧姆龙 NJ/NX | 1~10ms | 实时 EtherCAT | 每 50ms 一次 |
| 施耐德 M340 | 10~50ms | 扫描周期内分时 | 每 150ms 一次 |
一个容易误解的地方:上表的"采集频率建议上限"不是 PLC 的极限,而是不对产线正常运行产生干扰的上限。如果你强行把采集频率拉到 10ms,PLC 的通信处理器可能占用过多总线带宽,导致 PIQ 刷新延迟——也就是操作工触摸屏响应变慢。
3.2 冲突的本质:采样定理 vs 工业现实
学过信号处理的读者应该知道奈奎斯特采样定理:要恢复一个信号,采样频率必须大于信号最高频率的 2 倍。
但在 PLC 数据采集中,问题要复杂得多:
- 信号不是连续信号,而是离散事件:PLC 内部的数值变化是由扫描周期驱动的,两个扫描周期之间的变化你根本不知道
- 采集也不是瞬时完成的:从发送请求到收到响应,有网络延迟、协议解析时间、PLC 处理时间
- 多个采集点共享同一个通信通道:Modbus RTU 串行总线上的所有设备共享 485 线路,Modbus TCP 虽然全双工但 PLC 的通信处理器是单线程处理请求的
timeline
title 理想采集 vs 现实采集
section 理想情况
PLC变化 : 采集器立即感知 : 数据一致性 100%
section 现实情况
PLC变化 : 等待下一个轮询周期 : 网络延迟 : PLC处理时间 : 数据已经是 2~3 个周期前的值
4. 三种"丢数据"的无声场景
4.1 缓存覆盖——PLC 比你想象得快
这是最常见也最隐蔽的场景。
假设:
- PLC 扫描周期 = 20ms(每 20ms 更新一次寄存器值)
- 采集器每 200ms 读一次同一寄存器
- 网络往返延迟 ≈ 10ms
在一个采集周期(200ms)内,PLC 更新了 10 次数据。采集器只读到其中 1 次。中间 9 次变化被无声地跳过了。
如果你的信号是一个脉冲信号(例如操作工按按钮产生的上升沿),脉冲宽度可能只有 30ms,而在 200ms 的采集间隔中,采样恰好错过了这个脉冲:
gantt
title 脉冲信号被错过的时序示意
dateFormat X
axisFormat %s
section PLC 内部
脉冲信号 ON : 0, 30
信号复位 OFF : 30, 200
section 采集器
第 N 次采样 : 0, 5
第 N+1 次采样 : 200, 205
section 结果
采集到? : 0, 5
错过了! : 30, 200
这就是为什么操作工按了按钮,MES 却什么也没收到——数据在物理上产生了,但采集的时间窗没有对准。
4.2 字节序陷阱——读到错的数据比没读到更可怕
这是一个经典陷阱。PLC 内部的 16 位/32 位数值有字节序(Endianness)问题。
西门子 S7 系列使用 大端序(Big-Endian,Motorola 格式),而大多数 x86 架构的 PC 使用 小端序(Little-Endian)。Modbus 协议在应用层定义的是大端序,但落实到不同厂商的协议栈实现,可能会有差异。
举例:PLC 内部有一个 REAL 类型(32 位浮点数)的变量,值为 3.14。
| 存储方式 | 字节 1 | 字节 2 | 字节 3 | 字节 4 | 解释为 Float 的值 |
|---|---|---|---|---|---|
| PLC 内部(Big-Endian) | 0x40 | 0x48 | 0xF5 | 0xC3 | 3.14(正确) |
| x86 直接读取(Little-Endian) | 0xC3 | 0xF5 | 0x48 | 0x40 | -1.94e9(完全错误) |
| 手动交换字节序后 | 0x40 | 0x48 | 0xF5 | 0xC3 | 3.14(恢复正确) |
更复杂的情况出现在 32 位整数和双精度浮点数上:
import struct
# 西门子 PLC 大端序的 32 位浮点数 3.14
plc_bytes = bytes([0x40, 0x48, 0xF5, 0xC3])
# 错误:直接按小端序解析
wrong = struct.unpack('<f', plc_bytes)[0]
print(f"直接小端解析: {wrong}") # -1.94e9,完全错误
# 正确:用大端序解析
correct = struct.unpack('>f', plc_bytes)[0]
print(f"大端解析: {correct:.2f}") # 3.14
# 或者手动翻转字节序
correct_manual = struct.unpack('<f', plc_bytes[::-1])[0]
print(f"翻转后小端解析: {correct_manual:.2f}") # 3.14
更隐蔽的坑:Modbus TCP 协议规定数据载荷为大端序,但有些 PLC(尤其是罗克韦尔)在实现 Modbus TCP 时,字内是大端序,字之间却是小端序(Word swap)。这就是所谓的 "Byte swap" 与 "Word swap" 的区别。如果你的采集代码只做了字节翻转但没有做字翻转,32 位数据依然全是错的。
4.3 通信缓冲区溢出——采集频率的隐形天花板
每个 PLC 的通信处理器都有一个有限大小的接收缓冲区,用于暂存外部请求。当请求到达速度超过处理速度时,缓冲区会填满。
- 西门子 S7-1200 的 Modbus TCP 连接数上限通常是 8 个,每个连接有独立的接收缓冲区
- 超出连接数时,PLC 会直接拒绝新的 TCP 连接,表现为采集器频繁断连重连
- 缓冲区满时,PLC 会丢弃最旧的未处理请求,或直接发送 TCP 重置帧
这就是为什么有些项目在测试阶段(12 台采集器)一切正常,上线后(56 台采集器同时轮询)就频繁报错。不是代码逻辑变了,是通信处理器的并发能力到顶了。
graph LR
subgraph 采集端
C1[采集器 1]
C2[采集器 2]
C3[采集器 3]
end
subgraph PLC 通信处理器
BUF[接收缓冲区最大 8 连接]
PROC[请求处理引擎单线程]
end
subgraph PLC 应用
APP[用户程序区]
end
C1 --> BUF
C2 --> BUF
C3 --> BUF
BUF --> PROC
PROC --> APP
C2 -.拒绝连接.->x[连接数超限]
5. 代码验证:用 Python 还原现场
5.1 实验一:采集频率与数据失真仿真
下面的脚本模拟一个工业信号(带噪声的正弦波)在 PLC 内部以 SCAN_CYCLE_MS 的速度更新,外部采集器以 POLL_INTERVAL_MS 的间隔读取,观察不同频率组合下的数据失真程度。
无需真实硬件,安装依赖后直接运行:
pip install matplotlib numpy
"""
文件名称: sim_timing_conflict.py
功能说明: 模拟 PLC 扫描周期与采集周期的时序冲突
运行环境: Python 3.8+, matplotlib, numpy
使用方法: 调节 SCAN_CYCLE_MS 和 POLL_INTERVAL_MS 观察数据失真变化
"""
import time
import threading
import numpy as np
import matplotlib
matplotlib.use('Agg') # 非交互式后端,避免 Windows 上 GUI 线程检查报错
import matplotlib.pyplot as plt
# 设置中文字体(Windows 常见中文字体)
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'DengXian']
plt.rcParams['axes.unicode_minus'] = False
from collections import deque
# ============ 可调参数 - 请尝试不同组合 ============
PLC_SCAN_CYCLE_MS = 30 # PLC 内部扫描周期(毫秒)
POLL_INTERVAL_MS = 200 # 采集器轮询间隔(毫秒)
SIM_DURATION_SEC = 5 # 仿真持续时间(秒)
# ==================================================
PLC_SCAN_CYCLE = PLC_SCAN_CYCLE_MS / 1000.0
POLL_INTERVAL = POLL_INTERVAL_MS / 1000.0
class SimPLC:
"""
模拟 PLC 内部数据生成。
真实的工业信号往往是:趋势分量 + 周期性波动 + 随机噪声。
"""
def __init__(self):
self.value = 50.0 # 当前值,模拟寄存器
self.scan_count = 0
self.running = True
self.history = deque() # 存储 (时间戳, 真实值)
def scan_cycle(self):
"""模拟 PLC 的每一次扫描周期更新"""
while self.running:
t = time.time()
# 模拟产线信号:50 为基准,±30 的慢变趋势,叠加随机噪声
trend = 30 * np.sin(2 * np.pi * 0.5 * self.scan_count * PLC_SCAN_CYCLE)
noise = np.random.normal(0, 2.0)
self.value = 50.0 + trend + noise
self.history.append((t, self.value))
self.scan_count += 1
time.sleep(PLC_SCAN_CYCLE) # 等待到下一个扫描周期
class SimCollector:
"""
模拟外部采集器,按固定间隔离散采样。
每次采样时读取 SimPLC 的当前值,模拟 Modbus TCP 请求-响应。
"""
def __init__(self, plc):
self.plc = plc
self.collected = deque()
self.running = True
def poll_loop(self):
"""固定周期的轮询循环"""
while self.running and self.plc.running:
t_req = time.time()
# 模拟:采集器发出请求时,读取 PLC 当前值
# 实际 Modbus TCP 中这包括:建连/请求/响应/解析 全链路时间
sampled_value = self.plc.value
self.collected.append((t_req, sampled_value))
# 减去已经过去的时间,尽量保持固定间隔
elapsed = time.time() - t_req
sleep_time = max(0, POLL_INTERVAL - elapsed)
time.sleep(sleep_time)
def run_experiment():
"""运行一次完整的采集仿真实验"""
plc = SimPLC()
collector = SimCollector(plc)
# 启动 PLC 扫描线程和采集线程
t_plc = threading.Thread(target=plc.scan_cycle, daemon=True)
t_col = threading.Thread(target=collector.poll_loop, daemon=True)
t_start = time.time()
t_plc.start()
t_col.start()
# 运行 SIM_DURATION_SEC 秒
time.sleep(SIM_DURATION_SEC)
plc.running = False
collector.running = False
t_plc.join(timeout=2)
t_col.join(timeout=2)
# 提取数据用于绘图
# 转成 numpy 数组以便计算
real_times = np.array([h[0] for h in plc.history])
real_values = np.array([h[1] for h in plc.history])
sample_times = np.array([c[0] for c in collector.collected])
sample_values = np.array([c[1] for c in collector.collected])
# 相对时间(从 0 开始)
real_times -= t_start
sample_times -= t_start
# ====== 计算数据失真统计 ======
# 方法:对采集数据进行线性插值,对比同一时刻真实信号和采集信号的差异
# 由于真实 PLC 数据是阶梯保持的,这里简化为插值比较
common_t = np.linspace(0, SIM_DURATION_SEC, 1000)
# 真实信号插值
real_interp = np.interp(common_t, real_times, real_values)
# 采集信号插值(采集点之间的数据用零阶保持,即前向填充)
sample_interp = np.interp(common_t, sample_times, sample_values)
mae = np.mean(np.abs(real_interp - sample_interp))
rmse = np.sqrt(np.mean((real_interp - sample_interp) ** 2))
max_error = np.max(np.abs(real_interp - sample_interp))
print(f"{'='*50}")
print(f"PLC 扫描周期: {PLC_SCAN_CYCLE_MS} ms")
print(f"采集器间隔: {POLL_INTERVAL_MS} ms")
print(f"PLC 实际更新次数: {len(plc.history)} 次")
print(f"采集器采样次数: {len(collector.collected)} 次")
print(f"数据损失率: {100 * (1 - len(collector.collected) / len(plc.history)):.1f}%")
print(f"平均绝对误差 (MAE): {mae:.3f}")
print(f"均方根误差 (RMSE): {rmse:.3f}")
print(f"最大绝对值误差: {max_error:.3f}")
print(f"{'='*50}")
# ====== 绘图 ======
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
# 上子图:完整信号对比
ax1.plot(real_times, real_values, 'b-', alpha=0.5, linewidth=1,
label=f'PLC 真实信号 ({len(plc.history)} 点)')
ax1.stem(sample_times, sample_values, linefmt='r-', markerfmt='ro',
basefmt='k-', label=f'采集样本 ({len(collector.collected)} 点)')
ax1.set_ylabel('数值')
ax1.set_title(f'PLC 扫描 {PLC_SCAN_CYCLE_MS}ms vs 采集间隔 {POLL_INTERVAL_MS}ms')
ax1.legend(loc='upper right')
ax1.grid(True, alpha=0.3)
# 下子图:误差分布
# 对每个采集时刻,找最近的真实值
sample_indices = np.searchsorted(real_times, sample_times)
sample_indices = np.clip(sample_indices, 0, len(real_values)-1)
matched_real = real_values[sample_indices]
errors = sample_values - matched_real
ax2.stem(sample_times, errors, linefmt='purple', markerfmt='p',
basefmt='k-')
ax2.axhline(y=0, color='gray', linestyle='--')
ax2.set_xlabel('时间 (s)')
ax2.set_ylabel('采集误差')
ax2.set_title(f'采样时刻的瞬时误差')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('timing_conflict_result.png', dpi=150)
print("结果图已保存为 timing_conflict_result.png")
if __name__ == '__main__':
run_experiment()
运行结果说明:
| SCAN_CYCLE_MS | POLL_INTERVAL_MS | 数据损失率 | MAE | 典型场景 |
|---|---|---|---|---|
| 30 | 200 | ~85% | 较大 | 常见错误配置,大量数据变化被错过 |
| 30 | 100 | ~70% | 中等 | 勉强可用,但快速变化信号仍丢失 |
| 30 | 50 | ~40% | 较小 | 较高频率采集,对 PLC 通信负荷加重 |
| 30 | 30 | ~0% | 很小 | 每个扫描周期都采,但通信负荷非常高 |
关键结论:数据损失率 85% 是什么概念?意味着 PLC 内部发生了 10 次变化,你只看到了 1~2 次。如果你的应用依赖于检测快速变化(脉冲信号、报警上升沿、计数器跳变),这个采集方案直接不可用。
5.2 实验二:使用 pyModbus 的真实 Modbus TCP 采集
前面的仿真看到的是时序理论,现在上一个能跑在真实硬件或仿真器上的代码。
这个脚本自带一个本地的 Modbus TCP 从站(模拟 PLC),以及一个采集器客户端,可以端到端地观察采集延迟和数据一致性。不需要真实 PLC,开箱即用:
pip install pymodbus==3.6.8
"""
文件名称: modbus_timing_demo.py
功能说明: 展示 Modbus TCP 数据采集中的时序问题
- 内置 Modbus 从站(模拟 PLC 内部数据更新)
- Modbus 主站(采集器)定时读取
- 记录每次操作的时序数据到 CSV
运行环境: Python 3.8+, pymodbus 3.x
使用方式: 直接运行,无需任何硬件
"""
import time
import csv
import threading
from datetime import datetime
from pymodbus.server import StartTcpServer
from pymodbus.datastore import (
ModbusSlaveContext, ModbusServerContext, ModbusSequentialDataBlock
)
from pymodbus.device import ModbusDeviceIdentification
from pymodbus.client import ModbusTcpClient
# ============ 可调参数 ============
PLC_SCAN_MS = 50 # 模拟 PLC 的数据更新周期(ms)
POLL_INTERVAL_MS = 200 # 采集器轮询间隔(ms)
PLC_TCP_PORT = 5020 # 端口(1024+ 避免权限问题,真实设备用 502)
SAMPLE_COUNT = 50 # 采集样本数
# ================================
PLC_SCAN = PLC_SCAN_MS / 1000.0
POLL_INTERVAL = POLL_INTERVAL_MS / 1000.0
# ---------------------------------------------------------------------------
# 第一部分:启动模拟 PLC(Modbus TCP Server)
# ---------------------------------------------------------------------------
def run_plc_simulator():
"""
启动一个 Modbus TCP 从站,模拟 PLC 行为:
- 在独立的线程中定期更新保持寄存器 (holding register) 的数据
- 模拟"从传感器读取的实时值"
"""
# 创建数据存储区:4 个区域各 100 个寄存器
# store = ModbusSlaveContext(零:di, 一:co, 二:ir, 三:hr)
store = ModbusSlaveContext(
di=ModbusSequentialDataBlock(0, [0]*100),
co=ModbusSequentialDataBlock(0, [0]*100),
hr=ModbusSequentialDataBlock(0, [0]*100),
ir=ModbusSequentialDataBlock(0, [0]*100),
zero_mode=True
)
context = ModbusServerContext(slaves=store, single=True)
# 设备信息(并非必需,但符合 Modbus 标准)
identity = ModbusDeviceIdentification()
identity.VendorName = 'PLC Simulator'
identity.ProductCode = 'SIM'
identity.VendorUrl = 'https://plc-sim.example.com'
identity.ProductName = 'PLCSim v1.0'
identity.ModelName = 'PLCSim-1200'
# 启动一个后台线程,模拟 PLC 扫描周期更新数据
def update_data():
"""模拟 PLC 每个扫描周期更新保持寄存器"""
counter = 0
while True:
# 模拟两个典型工业信号:
# 寄存器 0: 缓慢变化的模拟量(如温度/压力)
analog_val = int(500 + 200 * __import__('math').sin(counter * 0.05))
# 寄存器 1: 脉冲计数器(模拟快速变化的脉冲信号)
pulse_val = counter % 100
# 写入数据到 holding register 0 和 1
context[0].setValues(3, 0, [analog_val, pulse_val])
counter += 1
time.sleep(PLC_SCAN) # 每个扫描周期
t_update = threading.Thread(target=update_data, daemon=True)
t_update.start()
print(f"[PLC Sim] 启动 Modbus TCP 从站,端口 {PLC_TCP_PORT}")
print(f"[PLC Sim] 扫描周期:{PLC_SCAN_MS}ms,每周期更新寄存器")
# 启动 Modbus TCP 服务器(阻塞运行)
StartTcpServer(
context=context,
identity=identity,
address=("0.0.0.0", PLC_TCP_PORT),
)
# ---------------------------------------------------------------------------
# 第二部分:数据采集器(Modbus TCP Client)
# ---------------------------------------------------------------------------
class ModbusCollector:
"""
模拟工业数据采集器,按固定周期间隔通过 Modbus TCP 读取 PLC 数据
"""
def __init__(self, host="127.0.0.1", port=PLC_TCP_PORT):
self.host = host
self.port = port
self.client = ModbusTcpClient(
host=host,
port=port,
timeout=3, # 超时时间(秒)
retries=1, # 失败重试次数
)
self.records = [] # 采集记录列表
def collect_samples(self, count=SAMPLE_COUNT):
"""
按固定间隔采集 count 次数据
每次记录:请求时间、响应时间、寄存器值、往返延迟
"""
print(f"\n[Collector] 开始采集 {count} 个样本,间隔 {POLL_INTERVAL_MS}ms")
print(f"[Collector] 目标: {self.host}:{self.port}")
for i in range(count):
# 采集器发送请求的时间戳 T1
t1 = time.time()
try:
# 读取从站地址 0,保持寄存器,起始地址 0,读取 2 个
# 对应 PLC 模拟器中的 analog_val 和 pulse_val
response = self.client.read_holding_registers(
address=0,
count=2,
slave=1, # Modbus 从站地址
)
# 收到响应的时间戳 T2
t2 = time.time()
if response and not response.isError():
rtt_ms = (t2 - t1) * 1000 # 往返延迟(毫秒)
self.records.append({
'sample_no': i + 1,
't1_req': datetime.fromtimestamp(t1).isoformat(),
't2_resp': datetime.fromtimestamp(t2).isoformat(),
'rtt_ms': round(rtt_ms, 2),
'analog_val': response.registers[0],
'pulse_val': response.registers[1],
})
print(f" #{i+1:3d} analog={response.registers[0]:5d} "
f"pulse={response.registers[1]:3d} "
f"RTT={rtt_ms:6.2f}ms")
else:
print(f" #{i+1:3d} 读取错误: {response}")
except Exception as e:
print(f" #{i+1:3d} 连接异常: {e}")
# 等待到下一个采集周期
time.sleep(POLL_INTERVAL)
self.client.close()
print(f"\n[Collector] 采集完成,共 {len(self.records)} 条有效记录")
def save_to_csv(self, filename="采集数据记录.csv"):
"""保存采集记录到 CSV 文件"""
if not self.records:
print("无数据可保存")
return
fieldnames = [
'sample_no', 't1_req', 't2_resp', 'rtt_ms',
'analog_val', 'pulse_val'
]
with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(self.records)
print(f"数据已保存到 {filename}")
def analyze_timing(self):
"""分析采集时序数据"""
if len(self.records) < 2:
return
rtts = [r['rtt_ms'] for r in self.records]
pulses = [r['pulse_val'] for r in self.records]
print(f"\n{'='*45}")
print("时序分析报告")
print(f"{'='*45}")
print(f"样本数: {len(self.records)}")
print(f"平均 RTT: {sum(rtts)/len(rtts):.2f} ms")
print(f"RTT 最小值: {min(rtts):.2f} ms")
print(f"RTT 最大值: {max(rtts):.2f} ms")
print(f"RTT 标准差: {__import__('statistics').stdev(rtts):.2f} ms")
# 检测脉冲值的跳变化,判断是否错过了变化
pulse_changes = sum(
1 for i in range(1, len(pulses))
if pulses[i] != pulses[i-1]
)
# 按扫描周期估算理论上应该发生的变化次数
total_ms = len(self.records) * POLL_INTERVAL_MS
expected_changes = total_ms / PLC_SCAN_MS
print(f"\n脉冲寄存器分析:")
print(f" 观察到 {pulse_changes} 次变化")
print(f" 理论上 PLC 内部变化约 {expected_changes:.0f} 次")
print(f" 变化捕获率: {pulse_changes/expected_changes*100:.1f}%")
print(f"{'='*45}")
# ---------------------------------------------------------------------------
# 第三部分:主程序
# ---------------------------------------------------------------------------
if __name__ == '__main__':
print("=" * 50)
print("PLC 数据采集时序验证实验")
print("=" * 50)
# 在独立线程启动模拟 PLC(Modbus TCP Server)
t_plc = threading.Thread(target=run_plc_simulator, daemon=True)
t_plc.start()
# 等待服务器就绪
time.sleep(2)
# 启动采集器
collector = ModbusCollector()
try:
collector.collect_samples()
collector.save_to_csv()
collector.analyze_timing()
except KeyboardInterrupt:
print("\n用户中断")
finally:
collector.client.close()
运行这个脚本,你会看到类似这样的输出:
==================================================
PLC 数据采集时序验证实验
==================================================
[PLC Sim] 启动 Modbus TCP 从站,端口 5020
[PLC Sim] 扫描周期:50ms,每周期更新寄存器
[Collector] 开始采集 50 个样本,间隔 200ms
[Collector] 目标: 127.0.0.1:5020
# 1 analog= 500 pulse= 0 RTT= 3.21ms
# 2 analog= 510 pulse= 4 RTT= 2.89ms
...
=============================================
时序分析报告
=============================================
样本数: 50
平均 RTT: 3.12 ms
脉冲寄存器分析:
观察到 4 次变化
理论上 PLC 内部变化约 200 次
变化捕获率: 2.0%
=============================================
看到了吗?变化捕获率只有 2%——PLC 内部发生了 200 次变化,采集器只看到了 4 次。这就是"没有丢包,但数据真的丢了"。
6. 何时该提高频率?何时该降低频率?
给出一个实战判断框架:
flowchart TD
A[确定你的信号特征] --> B{信号变化速度?}
B -->|慢变信号 温度/液位/压力| C[低频采集 500ms~5s 即可]
B -->|快变信号 电机转速/振动| D[需要高频采集 10ms~100ms]
B -->|脉冲信号 按钮/计数值| E[需要边缘捕获机制]
C --> F[CPU 负荷低, 通信稳定]
D --> G{PLC 通信处理器能力?}
G -->|足够| H[使用专用高速接口如 Profinet IRT / EtherCAT]
G -->|不足| I[考虑边缘计算节点本地缓存后批量上传]
E --> J{脉冲最短宽度?}
J -->|> 采集周期| K[常规轮询即可捕获]
J -->|< 采集周期| L[必须使用中断或PLC 内部累计后读取]
实战经验法则:
- 慢信号(温度、液位、压力):500ms~5s 采集一次即可。传感器本身的响应时间就在秒级,采集太快只是浪费带宽。
- 快信号(电机速度、振动):需要 10ms~100ms 级别。但首先要确认 PLC 的通信处理器能不能扛住这个频率。西门子 S7-1200 在 20ms 以内频繁轮询会导致通信阻塞。
- 脉冲信号(按钮、计数器、报警):不要靠"高频轮询"来抓脉冲,应该在 PLC 内部做累计或锁存,采集器读累计值。这是工程上最稳妥的做法。
- 多台采集器共享同一 PLC:总请求频率是叠加的。3 台采集器各 100ms 轮询 = PLC 每 33ms 就要处理一次请求,可能触发通信过载。
7. 总结与下一篇预告
这篇文章揭示了 PLC 数据采集中最容易被忽视的"暗坑":
- PLC 扫描周期 vs 采集周期的时序冲突——采集不是瞬时的,数据在两次采集之间可能已变化多次
- 三种无声的数据丢失场景——缓存覆盖、字节序错误、通信缓冲区溢出
- 代码验证了"无丢包的数据损失"——网络一切正常,但采集到的数据质量远低于预期
核心思想很简单但容易被忽视:PLC 数据采集不是"把线接好、把地址写对"就完事了,它本质上是一个分布式实时系统的时序协调问题。
回到开头的故事——那个花了三天排查的问题,最终解决方案不是改代码,而是:
- 将 12 台 PLC 的轮询改为分组并行,每组分配独立的采集线程
- 将脉冲信号改为在 PLC 内部锁存后再供采集器读取
- 在 MES 侧增加数据时间戳校验,识别滞后数据
👉 下一篇预告:[PLC 数采系列 2] 数据模型与地址映射——为什么你读到的永远是错位的数据。下一篇我们将深入 PLC 的内存布局、符号表导出、字节对齐机制,并给出一个实用的西门子 DB 块解析工具。你将理解为什么同样是读一个 REAL 类型变量,不同品牌 PLC 给出的字节序列截然不同。