没有硬件也能练 PLC:Factory I/O + OpenPLC 虚拟实验台

PLC 不在乎水箱是钢还是像素,只在乎 I/O 映像。用 Factory I/O + OpenPLC + Modbus 搭实验台:14 级梯形图、互锁与倒计时。

分享
没有硬件也能练 PLC:Factory I/O + OpenPLC 虚拟实验台

一句话结论:想学 PLC,不必先买一台控制器、一个水箱和一套按钮。PLC 根本不在乎水箱是钢的还是像素的,它只在乎自己在网络上读写的那个 I/O 映像。这篇要复现的虚拟实验台由三部分组成:Factory I/O 扮演被控过程(Modbus 服务器),OpenPLC 扮演 PLC(Modbus 主站,同时自带从站服务器),两者之间跑真实的 Modbus TCP——不是进程内的假连接。最终的梯形图程序是 14 级:两个带定时器的锁存流程、一条相互排斥的互锁、两盏面板指示灯和一个由 PLC 计算、经总线传到面板的倒计时。跑通之后,两个数字就能说明一切:灌水计时器走到 2.2 秒、剩余 7.8 秒时,面板显示 8(10 − 2.2 截断取整)——它同时证明了定时器在计数、算术链正确、数据真的走完了 Modbus。

本文完整编译自 dev.to @almahmudkhalif《Virtual PLC Lab: Ladder Logic Control of a Filling Tank with Factory I/O and OpenPLC over Modbus TCP》(2026-10-09),截图为原文作者实机捕获,做中文整理。

Factory I/O 的 Filling Tank(Timers)场景:水箱、进出水阀、Fill/Discharge 按钮与计时显示面板

一、要练什么:一个带真实协议的教学目标

作者给自己定的目标很具体:用 PLC 程序控制一个水箱,而且过程要像真的设备。

  • 按一次按钮开始灌水——操作员不需要按住按钮;
  • 灌水在固定时间后自动停止(没有液位传感器,时间就是唯一的控制参数);
  • 第二个按钮让水箱排水,同样是定时;
  • 灌水和排水绝不允许同时发生——这是真互锁,不是"最好别这样";
  • 面板指示灯显示当前处于哪个阶段;
  • 面板显示实时倒计时,让操作员看得到还剩多久。

用的场景是 Factory I/O 的 3 - Filling Tank (Timers):水箱、进水阀、出水阀、两个带灯按钮(Fill、Discharge)和一个数字计时显示屏。注意:按钮里的灯由 PLC 驱动,不是手指按亮的——所以这块面板同时是状态指示器。

二、为什么用 Modbus TCP,而不是"假连接"

这是整个实验台最值得学的一个设计决定。作者说得很清楚:如果把两个程序用进程内的假链接粘在一起,就什么都学不到——因为你绕过了"现场 I/O 到底怎么到达控制器"这件事。

改用 Modbus TCP 之后,两半程序就是两个真正独立的设备,通过 socket 对话,跟真实系统的方式一致。

角色 用什么 扮演什么
被控过程 Factory I/O v2.5.10(Ultimate Edition) 水箱、阀门、按钮、灯、显示;跑一个 Modbus TCP 服务器
PLC OpenPLC Runtime v4 执行程序;自带的 Modbus 主站(客户端) + 自己的从站服务器
工程站 OpenPLC Editor v4 画梯形图、下载到运行时、在线监视
现场总线 Modbus TCP 在两半之间搬运 I/O

有个容易绕晕的地方要提前讲清:Factory I/O 是 Modbus 服务器(从站),PLC 才是 Modbus 主站(客户端),由 PLC 每 100 ms 轮询一次过程。控制器当客户端听起来别扭,但这恰恰是现实中很多"PLC 对远程 I/O"的通信方式。

OpenPLC Runtime 控制台:从站监听 0.0.0.0:502,主站连上模拟器,PLC 处于 RUNNING

三、步骤 1:配置过程模拟器,先数清楚 I/O

安装 Factory I/O,打开场景 3 - Filling Tank (Timers)。花几分钟逐个点击场景里的元件,读它们的标签名——后面写地址映射时要用到这些完全一致的名字。

在动手接线之前,先看清楚这个场景没有什么。作者最终确定的驱动配置是:

  • 数字输入 3 个(Fill 按钮、Discharge 按钮、"FACTORY I/O (Running)");
  • 数字输出 4 个(进水阀、出水阀、灌水指示灯、排水指示灯);
  • 寄存器输入 0 个;
  • 寄存器输出 1 个(计时显示屏)。

那个 0 就是整个设计的起点:没有任何模拟量液位值被传过来,所以想按液位控制也不可能——灌水和排水时长成了仅有的控制参数,解法只能用定时器。这是一个很好的习惯:先看清接口边界,再决定控制策略。

四、步骤 2:启动 Runtime,并在控制台自查三件事

先启动 OpenPLC Runtime——编辑器需要一个正在运行的目标才能下载,在线监视也依赖它。

检查控制台输出,作者那次长这样:

MODBUS_SLAVE  Server listening on 0.0.0.0:502
MODBUS_SLAVE  Plugin modbus_slave started successfully
MODBUS_MASTER (PASS) Connected to TCP 192.168.56.1:502 (attempt 1)
PLC base tick: 100000000 ns across 1 task(s)
PLC State: RUNNING

要确认的三件事:

  1. 从站插件在 502 端口监听(这是 Runtime 自己的 I/O 映像,后面验证阶段要用);
  2. 主站连上了模拟器的 IP 与端口;
  3. PLC 确实处于 RUNNING,而不是 stopped。

你还会看到 Memory locking not available、SCHED_FIFO(99) 调度失败之类的告警。在虚拟机或 Windows 上这是预期现象——只说明运行时不是硬实时。对一个 100 ms tick 的模拟过程来说完全够用。

五、步骤 3:建项目,把两半接起来

在 OpenPLC Editor 里新建项目(作者的叫 Tank_Filling),一个程序 main。打开 Device Configuration 指向目标运行时;当按钮从 Connect 变成 Disconnect、状态显示 PLC: RUNNING,说明编辑器已接上,之后可以在线看逻辑执行。

然后是两个通信对象:

PLC 自己的从站服务器(项目树 → Servers → modbus):启用;传输方式 Modbus TCP;网络接口 All Interfaces (0.0.0.0);端口 502;从站 ID 1。它让外部客户端在不碰程序的情况下读 PLC 的 I/O 映像——第十节验证要用。

远程设备(项目树 → Remote Devices → modbus1):这是 PLC 的客户端侧,指向 Factory I/O:

Protocol   : Modbus (TCP/IP)
IP address : 192.168.56.1      # Factory I/O 服务器绑定的网卡
Port       : 502
Timeout    : 1000 ms
Slave ID   : 1

然后把过程信号和 PLC 地址绑起来:

方向 Factory I/O 标签 Modbus 引用 PLC 地址
过程 → PLC Fill(按钮) Discrete Input 0 — FC 02 %IX0.0
过程 → PLC Discharge(按钮) Discrete Input 1 — FC 02 %IX0.1
PLC → 过程 进水阀 Coil 0 — FC 15 %QX0.0
PLC → 过程 出水阀 Coil 1 — FC 15 %QX0.1
PLC → 过程 灌水指示灯 Coil 2 — FC 15 %QX0.2
PLC → 过程 排水指示灯 Coil 3 — FC 15 %QX0.3
PLC → 过程 计时显示 Holding Register 0 — FC 16 %QW0

IEC 地址和 Modbus 引用是怎么对上的,值得单独理解一次:IEC 61131-3 里用 %IX(输入位)、%QX(输出位)、%QW(输出字)描述过程映像;Modbus 侧则用离散输入(Discrete Input)、线圈(Coil)、保持寄存器(Holding Register)这些数据区描述对象。编辑器在中间做映射,功能码决定读写的语义——FC 02 读离散输入、FC 15 写多个线圈、FC 16 写多个保持寄存器。搞错方向(比如把"过程→PLC"接成写线圈),症状往往不是报错,而是"值不动"或"逻辑行为怪异",这是新手最费时的一类问题。

两个"照抄会踩"的点:

  • PLC 只读两个离散输入(length 2),所以模拟器的第三个输入 "FACTORY I/O (Running)" 根本到不了程序里。想拿它当"仿真运行中"的总闸,得先把 length 改成 3。
  • 两端的从站 ID 必须一致。 ID 不匹配的后果是静默超时,而不是有用的报错。
OpenPLC Editor 的 Remote Device 配置页:TCP/IP、192.168.56.1:502、超时 1000ms、Slave ID 1,以及 IO Tag Mapping 表

六、步骤 4:声明过程接口(14 个变量)

变量 类型 地址 用途
Fill_Btn BOOL %IX0.0 灌水按钮(操作员输入)
Disch_Btn BOOL %IX0.1 排水按钮(操作员输入)
Fill_Valve BOOL %QX0.0 进水阀
Disch_Valve BOOL %QX0.1 出水阀
Fill_Light BOOL %QX0.2 Fill 按钮灯
Disch_Light BOOL %QX0.3 Discharge 按钮灯
Timer_Disp INT %QW0 面板倒计时
T_Fill TON — 灌水定时器
T_Disch TON — 排水定时器
Fill_Seq BOOL — 灌水阶段锁存
Disch_Seq BOOL — 排水阶段锁存
Fill_sec LREAL — 灌水已用秒数
Disch_sec LREAL — 排水已用秒数
Left_Calc LREAL — 剩余秒数

命名纪律值得学:只有 *_Valve、*_Light 和 Timer_Disp 碰被控过程,其余全是状态与算术。一眼就能分清"哪些线是接出去的"。

七、步骤 5:写梯形图(14 级、四组)

先用结构化文本(ST)表达意图,更好读:

(* ---- 灌水阶段 ---- *)
Fill_Seq  := (Fill_Btn OR Fill_Seq) AND NOT Disch_Seq AND NOT T_Fill.Q;
T_Fill(IN := Fill_Seq, PT := T#10s);
Fill_Valve := Fill_Seq;
Fill_Light := Fill_Seq;

(* ---- 排水阶段 ---- *)
Disch_Seq := (Disch_Btn OR Disch_Seq) AND NOT Fill_Seq AND NOT T_Disch.Q;
T_Disch(IN := Disch_Seq, PT := T#10s);
Disch_Valve := Disch_Seq;
Disch_Light := Disch_Seq;

(* ---- 面板倒计时 ---- *)
IF Fill_Seq THEN
    Fill_sec  := TIME_TO_S(T_Fill.ET);      (* 已用时间 -> 秒 *)
    Left_Calc := 10.0 - Fill_sec;           (* 剩余 *)
    Timer_Disp := TO_INT(Left_Calc);        (* 面板收整数 *)
ELSIF Disch_Seq THEN
    Disch_sec := TIME_TO_S(T_Disch.ET);
    Left_Calc := 10.0 - Disch_sec;
    Timer_Disp := TO_INT(Left_Calc);
END_IF;

Rung 1–3:灌水阶段。 Rung 1 是自锁:

(Fill_Btn OR Fill_Seq) AND NOT Disch_Seq AND NOT T_Fill.Q → Fill_Seq

与 Fill_Btn 并联的 Fill_Seq 触点让"按一次"能启动并保持整个阶段;NOT Disch_Seq 是互锁;NOT T_Fill.Q 让阶段自己结束。Rung 2 是定时器 TON T_Fill,PT := T#10s。Rung 3 用阶段位驱动进水阀——锁存一掉,阀门不可能还开着。

Rung 01:Fill_Btn 与 Fill_Seq 并联自锁,串 Disch_Seq 与 T_Fill.Q 的常闭触点,驱动 Fill_Seq

Rung 4–6:排水阶段。 灌水的镜像,安全条件就在这里:

(Disch_Btn OR Disch_Seq) AND NOT Fill_Seq AND NOT T_Disch.Q → Disch_Seq

Rung 4 的 NOT Fill_Seq 意味着"灌水时排不了水",Rung 1 的 NOT Disch_Seq 意味着"排水时灌不了水"。两个阶段在结构上互斥,而不是靠操作员自觉。

Rung 7–8:面板指示。 Fill_Seq → Fill_Light、Disch_Seq → Disch_Light。对过程不是必需,但顺手把面板变成了状态显示器。

Rung 03:Fill_Seq 常开触点直接驱动进水阀线圈 Fill_Valve

Rung 9–14:倒计时。 定时器的 ET 是 TIME 类型,不能直接做算术,所以 Rung 9 先转换:TIME_TO_S(T_Fill.ET) → Fill_sec;Rung 10 相减(10.0 - Fill_sec → Left_Calc);Rung 11 截断成整数写进显示寄存器(TO_INT(Left_Calc) → Timer_Disp)。Rung 12–14 用同样的两个暂存变量和同一个显示寄存器,为排水阶段重复这条链——程序因此保持得很短。

这里能顺手学到一个真实工程的纪律:预设值 10 秒不要硬编码在多个地方。一旦要把灌水时间改成 12 秒,梯级、定时器 PT、倒计时减法都必须同步修改,漏一处就出现"阀门按 12 秒关、面板却按 10 秒数"的分裂。做法是把它抽成一个常量,三处都引用它。

八、步骤 6:配置 Factory I/O 驱动

回到 Factory I/O 打开驱动页,选 Modbus TCP/IP Server:

Port                : 502
Slave ID            : 1
Network adapter     : PLC 能到达的那块网卡
Write Digital       : Inputs              # 模拟器发给 PLC 的
Read Digital        : Coils               # 模拟器从 PLC 取的
Write Register      : Input Registers
Read Register       : Holding Registers
Scale               : 1
I/O points          : 3 DI, 4 DO, 0 register inputs, 1 register output

驱动启动、PLC 客户端连上后,绿色连接指示会出现。驱动页会把映射在一屏里列出来——这是确认"coil 0 真的是进水阀而不是某盏灯"最快的方法。

九、步骤 7:跑起来,看三件事

挂上编辑器调试器,按下 Fill:Fill_Seq、Fill_Valve、Fill_Light 同时变 TRUE,梯级高亮显示有电流通过;进水阀打开、水箱开始灌水、Fill 灯亮;面板从 10 开始倒数。

作者的捕获:灌水计时器显示已用 2.2 秒、剩余 7.8 秒,面板显示 8(10 − 2.2 截断取整)。一个数字同时验证了三件事:定时器在计数、算术链正确、数值真的经 Modbus 到了模拟器显示屏上。

  • 互锁测试:灌水中故意按 Discharge。调试器里 Disch_Btn = TRUE,但 Disch_Seq = FALSE——命令到达了 PLC,程序拒绝了它,因为 Rung 4 要求 NOT Fill_Seq。出水阀纹丝不动。这就是互锁的意义:操作员可以按错,过程仍然正确。
  • 灌水完成:10 秒到,T_Fill.Q 变 TRUE,锁存断开,进水阀自动关闭,无需再输入。
  • 排水:按 Discharge 锁存 Disch_Seq,出水阀打开、第二盏灯亮、倒计时重新开始——此时调试器显示灌水定时器停在 10.0 秒、排水计时器已用 1.9 秒(剩余 8.1 秒,面板显示 8)。

十、怎么验证:三层,别只信模拟器窗口

第 1 层:先查网络路径,再怀疑逻辑。

ping 192.168.56.1                     # PLC 侧能到达模拟器吗
netstat -ano | findstr :502           # Windows:谁在监听 502
ss -ltnp | grep 502                   # Linux / macOS

如果主站打印了 Connected ... (attempt 1),说明 socket 是通的,剩下的问题只可能在逻辑或地址映射。

第 2 层:轮询 PLC 自己的 I/O 映像。 Runtime 暴露了 502 端口的从站服务器,可以在不打开编辑器的情况下读实时输出:

# pip install pymodbus
from pymodbus.client import ModbusTcpClient

client = ModbusTcpClient("127.0.0.1", port=502)
client.connect()
coils = client.read_coils(0, 4)              # %QX0.0 .. %QX0.3
regs  = client.read_holding_registers(0, 1)  # %QW0 = Timer_Disp
print("Fill valve      :", coils.bits[0])
print("Discharge valve :", coils.bits[1])
print("Fill lamp       :", coils.bits[2])
print("Discharge lamp  :", coils.bits[3])
print("Countdown       :", regs.registers[0])
client.close()

灌水中运行:进水阀与灯 TRUE、倒计时在走、出水阀 FALSE。中途按 Discharge 什么都不该变——从 PLC 外面看到的互锁。注意:不同 pymodbus 版本可能要写成 read_coils(0, 4, slave=1) 或 device_id=1。

第 3 层:对账数字。 面板显示应该永远等于预设值 − 已用时间,截断取整。显示值与调试器里的 ET 对不上,问题就在转换梯级,而不在通信。这一层看起来只是算术,实际是在验证一条完整的端到端链路:逻辑 → 算术 → Modbus 写 → 过程显示,任何一环断了它都会先露馅。

十一、作者总结出的六条经验

  1. 定时器是控制策略,不是退路。 没有液位传感器,时间就是唯一能控的量,这会改变设计:水箱不能"灌到某个液位",只能"灌一段时间"。选了 10 秒,就等于接受液位是结果而不是目标。
  2. 自锁改变了操作员的工作方式。 有锁存,按一次就启动一个会自己完成的阶段;没有锁存,操作员得按住按钮 10 秒——又累又危险。
  3. 互锁属于逻辑,不属于操作规程。 把 NOT Fill_Seq 写进排水梯级,意味着任何按键顺序都不可能同时打开两个阀门;写在手册里,只是祈祷。
  4. 数据方向是最容易搞错的部分。 这里是控制器当 Modbus 客户端、轮询模拟器的服务器;方向、功能码(FC 02 读、FC 15 写线圈、FC 16 写寄存器)占了通信工作的大部分。
  5. 倒计时很便宜,但很值。 三条梯级就把定时器变成面向操作员的显示,而且它是最好的端到端测试:面板数字正确,就说明逻辑、算术、Modbus 写路径全都通了。
  6. 顺手想一想安全。 Modbus TCP 没有认证、没有加密、没有完整性检查——任何能到达 502 端口的东西都能读写过程 I/O。这正是两个程序只需要 IP、端口和从站 ID 就连上的原因,也是真实系统必须依赖网络分段、防火墙和受监控的通道、而不是协议本身的原因。

第 6 条值得再补一句。自锁的按钮还藏着一个真实世界常见的隐患:如果按钮是电平式的、又没有上升沿检测,定时器复位后按住不放会再次触发循环。练习阶段就该养成习惯——在按钮触点前加 R_TRIG(上升沿触发),放到真实面板上时,手感和可靠性都对。

十二、10 个常见坑

坑 你会看到什么 怎么修
两端从站 ID 不一致 超时或静默失败,没有有用的报错 设成同一个 ID(1 就行)
先启动 PLC、后启动模拟器驱动 PLC 连上了,但什么都不动 先起 Factory I/O 驱动,再看连接指示
模拟器服务器绑错网卡 主站永远连不上,ping 也不通 绑到 PLC 实际能到达的网卡
以为有液位传感器 模拟量永远不来,液位控制不工作 看寄存器输入数量——是 0 就说明是定时器场景
电平式按钮没有边沿检测 定时器复位后按住按钮会重启循环 在按钮触点前加 R_TRIG(上升沿)
预设值硬编码在两个地方 改了 T#10s,倒计时却不对 用一个常量,减法也引用它
忘了显示是整数寄存器 数值差一,或显示一直不变 写入 %QW0 前显式 TO_INT
502 端口被占用 从站插件绑定失败 启动 Runtime 前查端口占用
只盯着 3D 场景调试 分不清是 PLC 还是映射出问题 用在线监视 + 从 Modbus 读 PLC 的 I/O 映像
把锁存写成"操作员按住" 需要一直按住按钮 并联阶段位自锁

十三、从虚拟实验台到真实工程

这套实验台练的是通用能力:接口盘点、数据方向、锁存、互锁、定时、端到端验证。迁移到真实项目时,通常沿着两条路走:

  • 留在通用生态:把 OpenPLC 换成真实 PLC(或继续用软 PLC + 现场 I/O),把 Modbus TCP 换成实际使用的现场总线(PROFINET、EtherCAT 等),程序结构不变;
  • 转向西门子博途:在博途里用同样的框架重画梯形图,再用 PLCSIM 做仿真回归。梯形图的编辑风格、定时器实例的组织方式会有差异,但锁存与互锁的结构逻辑是一样的——真正需要重新学的,是博途的工程设计习惯(符号表、数据类型、块接口)。

如果是后者,练习阶段的梯形图可以先通过 LAD2SCL 转成 SCL 源码,或从自然语言描述用 TXT2LAD 生成梯形图骨架,再进博途编译与仿真核对。练习环境里养成的验证习惯,比任何一次"能跑就行"的调试都值钱。

十四、Q&A

Q:没有 Windows 电脑,能搭这套实验台吗? Factory I/O 与 OpenPLC Editor 都在 Windows 上跑得最顺(原文环境就是 Windows + 虚拟机网卡 192.168.56.x)。如果只有 Mac/Linux,可以先从浏览器里的梯形图仿真练起,或用支持你平台的软 PLC 替代,协议部分保持一致。

Q:一定要 Ultimate Edition 吗? 原文用的就是 Ultimate Edition。是否必须取决于驱动支持与场景库;学习目的是把"真实协议 + 独立两半"的结构跑通,选哪个版本看官方当前的能力说明。

Q:10 秒的定时练习,改成 30 秒要动哪些地方? 常量抽出来后只需要一处改动;如果没抽常量,就要同步改定时器 PT 和倒计时减法两处。这正是"预设值管理"这个习惯的练习点。

Q:这个实验有实际工程价值吗? 有。它练的是真实项目的核心动作:先界定接口,再写逻辑,最后用外部手段验证。真实工程只是多了安全回路、硬件诊断和更严格的上线流程,方法是一致的。

十五、小结

虚拟实验台给到的是教科书给不了的东西:按钮、梯级、线圈和水,在同一个画面里,而且链条上的每一环——逻辑、定时、算术、Modbus 映射——都是你自己写错、再自己修好的。最终程序 14 级梯级:两个各自带定时器的锁存阶段、一条交叉互锁、两盏显示当前阶段的灯,加一个在 PLC 里计算、经总线传到过程面板的倒计时。一次按钮启动、自动结束、拒绝同时灌排。

有一台 Windows 机器和几个小时就能复现。作者下一步想加的:让急停切断两个阀门、给按钮加边沿检测,以及做一个读同一个 Modbus 服务器的小 HMI。

想先用最低配置练手,也可以从浏览器里的梯形图仿真开始:BimAnt PLC 在线仿真器 支持触点、线圈、置位/复位、定时器、计数器、比较与数学指令,打开即用;整套博途工程工具的入口见 TIA Forge。


参考来源

  • @almahmudkhalif《Virtual PLC Lab: Ladder Logic Control of a Filling Tank with Factory I/O and OpenPLC over Modbus TCP》,dev.to,2026-10-09。
  • 本文完整编译原文的搭台步骤、地址映射、梯形图逻辑、运行观测、三层验证与常见坑;文中截图来自原文作者实机捕获(Factory I/O / OpenPLC 界面),数字以其运行记录为准。迁移路径、Q&A 与预设值/边沿检测的展开为在来源基础上的整理,供参考。

延伸阅读(本站)

工具入口:PLC 在线仿真器 | LAD2SCL | TIA Forge 总入口

这是「AI × 工业 / PLC」专题的一部分。完整地图见:AI × 工业 / PLC 完全指南