PLC 的核心:循环扫描

PLC(可编程逻辑控制器)从诞生之初就与通用计算机走了两条路。通用计算机追求吞吐与灵活,PLC 追求的是确定性:给定同样的输入,在同样的时刻给出同样的输出。为此它采用了一套看起来“笨”但极其可靠的执行模型——循环扫描。一个扫描周期分为三个阶段:输入采样,把所有输入端子的状态一次性读进输入映像区;程序执行,从上到下把用户程序完整跑一遍;输出刷新,把输出映像区的结果一次性送到输出端子。

理解 PLC 的关键,是接受“程序不是随时响应,而是周期性、按顺序、从头到尾执行一遍”这件事。所有看起来反直觉的现象,都能从这个模型里找到解释。

扫描周期通常在 1 到 10 毫秒之间,程序越长、指令越复杂,周期越长。这个数字直接决定了系统能捕捉到多窄的脉冲信号:如果某个传感器的输出只持续 0.5 毫秒,而扫描周期是 5 毫秒,那么这个信号很可能被完整地跳过。这也是为什么急停、安全门这类安全信号不会交给普通 PLC 程序去处理,而是走独立的安全回路或安全 PLC。

三个必须建立的概念
  • 扫描周期程序执行一遍所需时间,是 PLC 的“心跳”,也是所有实时性指标的基准。
  • 映像区输入输出都先写进内存映像,程序运算的是映像而不是硬件端口,这才保证了同一周期内输入状态的一致性。
  • 看门狗监视扫描周期,一旦程序跑飞导致周期超限,立即切到安全状态并报错。

梯形图:从继电器回路继承的表达法

梯形图(Ladder Diagram)的图形长得像电气控制柜里的继电器回路:左边一条母线,右边一条母线,中间是并联与串联的触点,末端接线圈。这不是刻意的形式主义,而是为了让上世纪熟悉继电器柜的电工能够平滑地迁移到编程环境——同一个逻辑,画的还是那张图,只是触点从物理接触变成了内存位。

IEC 61131-3 标准定义了五种编程语言:梯形图(LD)、功能块图(FBD)、结构化文本(ST)、指令表(IL)和顺序功能图(SFC)。实际工程中常见的是混合使用:设备级的联锁与顺序控制用梯形图,看得见逻辑、方便现场维护;涉及运算、字符串、数组、状态机的部分用结构化文本,表达力更强。下面的实践建议来自产线维护的真实教训。

  • 把“主控流程”和“设备动作”分成不同的程序块,主控只负责发出启动、停止、复位这类指令。
  • 每个输出线圈只在一处被赋值。多处赋值是后期调试时最难排查的故障来源。
  • 所有联锁条件写在动作之前的串联回路上,而不是在动作之后再补一个“如果异常则关闭”。
  • 给每个程序块加注释,写清它的输入条件、输出结果与对应的机械部位。

一个能上机的例子:启保停与故障锁存

“启保停”是工业控制里最基础的回路:按下启动按钮,接触器吸合并用自身常开触点保持;按下停止按钮,回路断开。加上热继电器过载保护后,就构成了最小的电机控制逻辑。下面用符合 IEC 61131-3 的结构化文本写成一个功能块,可以直接在支持 ST 的 PLC 工程中调用。

(* 电机启停控制:IEC 61131-3 结构化文本 *)
FUNCTION_BLOCK FB_MotorCtrl
VAR_INPUT
    StartBtn : BOOL;   (* 启动按钮,常开触点 *)
    StopBtn  : BOOL;   (* 停止按钮,常闭触点,正常时为 TRUE *)
    Overload : BOOL;   (* 热继电器,过载时为 TRUE *)
    ResetBtn : BOOL;   (* 故障复位,上升沿有效 *)
END_VAR
VAR_OUTPUT
    Run   : BOOL;      (* 接触器输出 *)
    Fault : BOOL;      (* 故障指示 *)
END_VAR
VAR
    FaultLatch : BOOL; (* 故障锁存位 *)
END_VAR

    (* 故障优先:先处理锁存,再处理运行 *)
    IF Overload THEN
        FaultLatch := TRUE;            (* 过载置位并锁存 *)
    ELSIF ResetBtn AND NOT Overload THEN
        FaultLatch := FALSE;           (* 仅在故障已消失时允许复位 *)
    END_IF;

    Fault := FaultLatch;

    IF FaultLatch OR StopBtn THEN
        Run := FALSE;                  (* 故障或停止优先于启动 *)
    ELSIF StartBtn THEN
        Run := TRUE;                   (* 保持:本周期未断开则继续运行 *)
    END_IF;
END_FUNCTION_BLOCK

这段代码里有三个值得反复咀嚼的设计决定。第一,故障优先级高于启动,任何一个周期里只要 Overload 为真,Run 一定是 FALSE,不存在“抢在保护之前启动”的可能性。第二,故障必须锁存,因为热继电器在电机停机后会自然冷却复位,如果不锁存,操作员一按启动就会再次过载,形成反复冲击。第三,复位需要人工确认,这是安全设计的基本要求:自动复位的故障很容易被忽略,直到事故发生。

把功能块实例化使用即可:Motor1(StartBtn := I0.0, StopBtn := I0.1, Overload := I0.2, ResetBtn := I0.3);,随后用 Motor1.Run 驱动输出线圈。这种“把逻辑封装成可复用块”的习惯,在一条产线上有二十台同型号电机时,能把程序量压到原来的三分之一。

上位机与 PLC 的三种通信路径

PLC 负责现场控制,上位机负责数据汇聚、界面展示与业务流转,两者之间必须有稳定的数据通道。工程上可选的主干路径有三条,各有明确的适用边界。

工业控制柜与左侧发光梯形图逻辑回路通过数据线连接的扁平插画
PLC 承担现场逻辑,上位机承担数据与人机界面:左侧的逻辑回路与右侧的控制柜之间,是通信这一层真正需要设计的部分。
  • 串口 Modbus RTU:成本最低,适合单台设备或老设备改造,缺点是速率低、一条总线上的从站数量有限,通常不超过 32 个。
  • 以太网 Modbus TCP:现场最通用,带宽足够,可以直接跑在产线环网里,端口固定 502。多数中小型 PLC 都原生支持。
  • 厂商协议或 OPC UA:西门子 S7 协议、三菱 MC 协议等能读取更丰富的信息;OPC UA 则在语义与安全上更进一步,适合接入上层平台。
上位机开发最容易踩的三个坑
  • 数据类型PLC 里一个 REAL 占两个寄存器,上位机必须按同样的字节序还原,否则读出来的转速会是天文数字。
  • 轮询频率上位机轮询周期必须明显大于 PLC 扫描周期,否则通信任务会挤占程序执行时间。
  • 写权限只读的点位严禁开放写接口,一次误写设定值就可能造成批量报废。

上位机程序骨架:轮询、重连与写入

上位机程序最常见的写法是“定时器 + 读寄存器”,但这种写法在生产环境里几乎一定会出问题:网络抖动或 PLC 重启后,客户端会停在半开连接上,读操作一直超时却永远不重连,界面上的数据从此定格。下面这段骨架把连接管理、失败计数与主动重建都写清楚了,可以直接作为采集模块的底座。

import time
from pymodbus.client import ModbusTcpClient

PLC_IP, PLC_PORT = "192.168.1.10", 502
POLL_INTERVAL    = 0.5   # 采集周期(秒),需明显大于 PLC 扫描周期
MAX_FAIL         = 3     # 连续失败阈值,超过则重建连接

def connect():
    while True:
        client = ModbusTcpClient(PLC_IP, port=PLC_PORT, timeout=1)
        if client.connect():
            print("PLC 连接成功")
            return client
        print("连接失败,3 秒后重试")
        time.sleep(3)

def main():
    client = connect()
    fails = 0
    while True:
        rr = client.read_holding_registers(address=0, count=8, slave=1)
        if rr.isError():
            fails += 1
            print(f"读取失败 {fails}/{MAX_FAIL}")
            if fails >= MAX_FAIL:
                client.close()          # 主动断开,避免半开连接假死
                client = connect()
                fails = 0
        else:
            fails = 0
            # 约定:转速以 10 倍整数存储,读回后除以 10 还原工程量
            speed = rr.registers[0] / 10.0
            print(f"当前转速 {speed:.1f} rpm")
        time.sleep(POLL_INTERVAL)

if __name__ == "__main__":
    main()

关于写操作,有三条经验规则值得写进团队的开发规范:写之前先读一次确认当前状态,避免盲目覆盖;写之后回读一次确认生效,把“写成功但没生效”这种情况暴露出来;所有写操作记录操作人、时间与旧值新值,出问题时这是唯一的追溯依据。