为什么 PLC 数据采集是工业数字化的第一个深坑

0 阅读19分钟

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-120010~50ms独立通信处理器(CP)每 100ms 一次
西门子 S7-15001~10ms背板总线集成每 50ms 一次
罗克韦尔 CompactLogix5~50ms异步通信任务每 100ms 一次
三菱 FX5U20~100ms专用通信指令每 200ms 一次
欧姆龙 NJ/NX1~10ms实时 EtherCAT每 50ms 一次
施耐德 M34010~50ms扫描周期内分时每 150ms 一次

一个容易误解的地方:上表的"采集频率建议上限"不是 PLC 的极限,而是不对产线正常运行产生干扰的上限。如果你强行把采集频率拉到 10ms,PLC 的通信处理器可能占用过多总线带宽,导致 PIQ 刷新延迟——也就是操作工触摸屏响应变慢。

3.2 冲突的本质:采样定理 vs 工业现实

学过信号处理的读者应该知道奈奎斯特采样定理:要恢复一个信号,采样频率必须大于信号最高频率的 2 倍。

但在 PLC 数据采集中,问题要复杂得多:

  1. 信号不是连续信号,而是离散事件:PLC 内部的数值变化是由扫描周期驱动的,两个扫描周期之间的变化你根本不知道
  2. 采集也不是瞬时完成的:从发送请求到收到响应,有网络延迟、协议解析时间、PLC 处理时间
  3. 多个采集点共享同一个通信通道: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)0x400x480xF50xC33.14(正确)
x86 直接读取(Little-Endian)0xC30xF50x480x40-1.94e9(完全错误)
手动交换字节序后0x400x480xF50xC33.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_MSPOLL_INTERVAL_MS数据损失率MAE典型场景
30200~85%较大常见错误配置,大量数据变化被错过
30100~70%中等勉强可用,但快速变化信号仍丢失
3050~40%较小较高频率采集,对 PLC 通信负荷加重
3030~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 内部累计后读取]

实战经验法则

  1. 慢信号(温度、液位、压力):500ms~5s 采集一次即可。传感器本身的响应时间就在秒级,采集太快只是浪费带宽。
  2. 快信号(电机速度、振动):需要 10ms~100ms 级别。但首先要确认 PLC 的通信处理器能不能扛住这个频率。西门子 S7-1200 在 20ms 以内频繁轮询会导致通信阻塞。
  3. 脉冲信号(按钮、计数器、报警):不要靠"高频轮询"来抓脉冲,应该在 PLC 内部做累计或锁存,采集器读累计值。这是工程上最稳妥的做法。
  4. 多台采集器共享同一 PLC:总请求频率是叠加的。3 台采集器各 100ms 轮询 = PLC 每 33ms 就要处理一次请求,可能触发通信过载。

7. 总结与下一篇预告

这篇文章揭示了 PLC 数据采集中最容易被忽视的"暗坑":

  • PLC 扫描周期 vs 采集周期的时序冲突——采集不是瞬时的,数据在两次采集之间可能已变化多次
  • 三种无声的数据丢失场景——缓存覆盖、字节序错误、通信缓冲区溢出
  • 代码验证了"无丢包的数据损失"——网络一切正常,但采集到的数据质量远低于预期

核心思想很简单但容易被忽视:PLC 数据采集不是"把线接好、把地址写对"就完事了,它本质上是一个分布式实时系统的时序协调问题

回到开头的故事——那个花了三天排查的问题,最终解决方案不是改代码,而是:

  1. 将 12 台 PLC 的轮询改为分组并行,每组分配独立的采集线程
  2. 将脉冲信号改为在 PLC 内部锁存后再供采集器读取
  3. 在 MES 侧增加数据时间戳校验,识别滞后数据

👉 下一篇预告:[PLC 数采系列 2] 数据模型与地址映射——为什么你读到的永远是错位的数据。下一篇我们将深入 PLC 的内存布局、符号表导出、字节对齐机制,并给出一个实用的西门子 DB 块解析工具。你将理解为什么同样是读一个 REAL 类型变量,不同品牌 PLC 给出的字节序列截然不同。