没有硬件也能练 PLC:Factory I/O + OpenPLC 虚拟实验台
PLC 不在乎水箱是钢还是像素,只在乎 I/O 映像。用 Factory I/O + OpenPLC + Modbus 搭实验台:14 级梯形图、互锁与倒计时。
一句话结论:想学 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),截图为原文作者实机捕获,做中文整理。

一、要练什么:一个带真实协议的教学目标
作者给自己定的目标很具体:用 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"的通信方式。

三、步骤 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
要确认的三件事:
- 从站插件在 502 端口监听(这是 Runtime 自己的 I/O 映像,后面验证阶段要用);
- 主站连上了模拟器的 IP 与端口;
- 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 不匹配的后果是静默超时,而不是有用的报错。

六、步骤 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 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 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 写 → 过程显示,任何一环断了它都会先露馅。
十一、作者总结出的六条经验
- 定时器是控制策略,不是退路。 没有液位传感器,时间就是唯一能控的量,这会改变设计:水箱不能"灌到某个液位",只能"灌一段时间"。选了 10 秒,就等于接受液位是结果而不是目标。
- 自锁改变了操作员的工作方式。 有锁存,按一次就启动一个会自己完成的阶段;没有锁存,操作员得按住按钮 10 秒——又累又危险。
- 互锁属于逻辑,不属于操作规程。 把
NOT Fill_Seq写进排水梯级,意味着任何按键顺序都不可能同时打开两个阀门;写在手册里,只是祈祷。 - 数据方向是最容易搞错的部分。 这里是控制器当 Modbus 客户端、轮询模拟器的服务器;方向、功能码(FC 02 读、FC 15 写线圈、FC 16 写寄存器)占了通信工作的大部分。
- 倒计时很便宜,但很值。 三条梯级就把定时器变成面向操作员的显示,而且它是最好的端到端测试:面板数字正确,就说明逻辑、算术、Modbus 写路径全都通了。
- 顺手想一想安全。 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 完全指南