主题
🏗️ 现场程序常见架构套路
这一页讲的不是"某个函数怎么用",而是一台设备上的程序整体该怎么组织—— 也就是"框架":把若干模块按现场最稳的方式摆在一起。
为什么要有这一页:学会每个模块 ≠ 会写程序。 实际现场最常见的困惑是"我知道 timer.start、也知道 mqtt.publish,但不知道它们该怎么摆在一起"。 下面把现场跑得最稳、被反复验证的几种框架直接给你,每种都能在 例程总览 里找到可下载运行的完整代码。
一、先看清楚:现场的程序长什么样
| 形态 | 占比 |
|---|---|
| 梯形图 + Lua 都有 | 80.2%(474 / 591 台) |
| 只有梯形图 | 8.1% |
| 只有 Lua | 0.5% |
结论:双引擎是常态,不是特例。 别指望只用一种语言就把事情做完。
Lua 的主循环骨架(一台设备可能同时用到多种)
| 骨架 | 占比 | 涉及客户数 |
|---|---|---|
| 周期定时 + 事件回调 | 32.7% | 10 |
| 轮询循环 + 协程 | 23.5% | 2 |
| 周期定时 + 轮询循环 + 协程 | 19.1% | 3 |
| 周期定时(单独) | 6.5% | 6 |
排第一的「周期定时 + 事件回调」被大量互不相干的项目用到, 是普适性最强的一种:回调负责"随时可能来的下发",定时器负责"稳定周期的上报",两件事互不干扰。
二、分工边界:什么该梯形图做,什么该 Lua 做
| 该谁做 | 梯形图 | Lua |
|---|---|---|
| 多路从站轮询(Modbus RTU 主机) | ✅ 周期固定、时序可预期 | ❌ |
| IO 联锁、急停、工艺时序 | ✅ 硬实时 | ❌ 别做 |
| 工程量换算、累计统计 | 勉强 | ✅ |
| JSON / TCP / MQTT / HTTP | ❌ 做不了 | ✅ |
| 接收云端下发 | ❌ | ✅ 回调 |
| 协议解析(S7 / 电表 / 自定义帧) | ❌ | ✅ |
交接方式只有一种:D/M 寄存器。 两边不互相调用函数。 分区约定见 a-03-lua-plc-coordination.md: D0-D499 梯形图、D500-D999 Lua、D1000+ 共享。
三、四个可以直接照着写的骨架
骨架 1:云端双向(订阅下发控制 + 周期上报状态)
场景:远程开关、启停设备、门禁。客户最常用的一类(6 个客户 / 58 台在用)。
完整代码(可直接下载运行,把地址和主题换成自己的):
lua
-- ① 建 MQTT 对象。必须用全局/长生命周期变量持有
mq = mqtt.creat("broker.emqx.io", 1883, "lpplc_001", "", "")
-- ② 订阅必须放在 CONNECTED 回调里
-- 放在外面的话,脚本跑到这里时多半还没连上,订阅会丢
mqtt.reg(mq, mqtt.CONNECTED, function()
mqtt.subscribe(mq, "cmd", 0)
end)
mqtt.reg(mq, mqtt.DISCONNECTED, function()
print("网络断开")
end)
-- ③ 收到下发:按 topic/payload 分支,写 M 去控制现场
mqtt.reg(mq, mqtt.RECVDATA, function(topic, payload)
if topic == "cmd" then
if payload == "on" then plc.m(50, 1) end -- M50 = 开
if payload == "off" then plc.m(50, 0) end -- M50 = 关
end
end)
mq:start()
-- ④ 周期上报状态:先判断在线再发
timer.start(2000, function()
if mqtt.is_linked(mq) == 1 then -- 返回整数,必须 == 1
if plc.m(50) == 1 then
mqtt.publish(mq, "state", "on", 1)
else
mqtt.publish(mq, "state", "off", 1)
end
end
end)要点:
mq必须是全局的 —— 局部变量被 GC 之后所有回调静默失效(不报错,就是不工作)。- 订阅放
CONNECTED回调里。 mqtt.is_linked()返回整数,必须写== 1,写if mqtt.is_linked(mq) then永远是真。- 上报前先判断在线,否则离线期间会堆一大批发不出去的消息。
验证方法:云侧下发 on → 看 M50 是否置 1、现场继电器是否动作; 再手动改 M50 → 看云侧 2 秒内是否收到 state。两头都要验。
常见坑:
- 把
mq写成local mq = ...→ 过一会儿所有回调都失效,且没有任何报错。 - 订阅写在
mq:start()之前的外层 → 订阅丢失,表现是"能发不能收"。 mqtt.is_linked()不写== 1→ 断线时也在发,消息全丢。
骨架 2:采集 → 工程量换算 → 累计 → 上报
场景:水位、电流、温度、流量这类"传感器原始值 → 工程量"的场合。 7 个客户 / 83 台在用,水电站和能耗监测最常见。
完整代码:
lua
-- ① 上电先等一会儿:刚启动时传感器/网络还没就绪,
-- 立刻开始算会得到一批错误值("第一条数据是坏的"就是这个原因)
system.sleep(10000)
累计 = 0
timer.start(5000, function()
-- ② 原始值 → 工程量。量程和公式写清单位与出处,别留魔法数
local raw = plc.d(120) -- 传感器原始值
local v = raw * 500 / 10000 -- 厂家公式:采集值 * 量程 / 10000
-- ③ 零点漂移钳位:低于门槛就当 0,别让噪声参与累计
if v < 10 then v = 0 end
-- ④ 32 位/浮点必须用函数式写法
plc.df(150, v)
-- ⑤ 按采样周期折算累计量(这里 5 秒一次 → /3600 折算成小时量)
累计 = 累计 + v * 5 / 3600
plc.df(160, 累计)
end)要点:
- 上电延时是必需的,不是可选项。现场最常报的"刚上电数据不对"就是这个。
- 换算公式要写清单位和出处(厂家公式 / 现场标定),半年后没人敢改一堆裸数字。
- 零点漂移要钳位,否则小噪声长时间累计会滚成很大的误差。
- 浮点一律
plc.df()/plc.dd()。
验证方法:给传感器一个已知输入(比如用信号发生器给 4mA), 看 plc.df(150) 是否等于按公式手算的值;让程序跑 1 分钟, 看累计量的增量是否 ≈ 工程量 × 1/60。
常见坑:
- 写
plc.DF150/plc.DD100这种属性式写法 → 静默失败,值永远是 0 或旧值。 - 忘了零点钳位 → 设备在停机状态下累计量还在慢慢涨。
- 用
while true do ... sleep() end代替timer.start→ 主线程 sleep 不泵消息队列, 定时器和 MQTT 回调全部失效(表现:连上了但收不到下发、也不上报)。
骨架 3(梯形图):多路 Modbus 从站轮询
场景:一台 PLC 读多个表 / 多个从站。梯形图做这个比 Lua 合适 —— 周期固定、时序可预期。
完整代码(指令表 IL,可直接下载):
LD M8000
MOV K2004 D8126 ; 选串口 2
EXTR K9600 K0 K81 K2 ; 9600 · 8N1 · RTU 主机
MOV K2008 D8126 ; 切到「串口 2 · MODBUS RTU · 连续读写」
LDI T3 ; 自复位定时:每 0.3 秒走一步
OUT T3 K3
LDP T3
INC D99 ; D99 = 当前轮询到第几路
LD= D99 K0
EXTR H103 K0 K4 D0 ; 第 1 路:站号 1,功能码 03,读 4 点 → D0
LD= D99 K1
EXTR H203 K0 K4 D4 ; 第 2 路:站号 2 → D4
LD= D99 K2
EXTR H303 K0 K4 D8 ; 第 3 路:站号 3 → D8
LD= D99 K3
RST D99 ; 回到第 1 路
END要点:
- 用一个计数器 + 一个自复位定时器做状态机,一次只发一路。 绝不能在同一周期里发多条
EXTR,串口会撞车。 - 每路的源地址(D0 / D4 / D8…)要错开,间隔留够 —— 一个浮点占 2 个连续 D,只留 1 个就会覆盖。
LD=比较的是 16 位整数,注意 D99 别溢出。
验证方法:看 D0 / D4 / D8 是不是在轮流刷新(不是只有一个在动); 然后把某一路从站断电,看其它路是否还在正常刷新 —— 合格的轮询状态机不能因为一路挂了就整个停摆。
常见坑:
- 省掉计数器、每个周期都发
EXTR→ 串口撞车,数据乱跳。 - 只靠"读到了就存"不判超时 → 从站掉线后那一格数据一直停在旧值,看起来一切正常。
- 定时器时基搞错:
T0-T199是 100ms(K3= 0.3 秒),不是 3 秒。
骨架 4:报警判定与上报
场景:超阈值、设备断线、通讯失败要报出来。 统计上这是覆盖面最广的一类:9 个客户 / 310 台设备有报警逻辑,比上报还多。
完整代码:
lua
报警 = 0
timer.start(5000, function()
local v = plc.df(150) -- 工程量
local 通讯码 = plc.d(600) -- 通讯状态(0=正常)
-- ① 阈值判定要留回差,否则在临界点会反复报警/复归
if 报警 == 0 and v > 80.0 then
报警 = 1
plc.m(100, 1) -- 报警位,梯形图里用它点灯/停机
elseif 报警 == 1 and v < 75.0 then -- 回差 5.0
报警 = 0
plc.m(100, 0)
end
-- ② 通讯断线单独判:数据"看起来正常"不等于链路正常
if 通讯码 ~= 0 then
plc.m(101, 1)
else
plc.m(101, 0)
end
-- ③ 只在状态**变化**时上报,不要每个周期都发同一条
if 报警 ~= 上次报警 then
上次报警 = 报警
mqtt.publish(mq, "alarm", tostring(报警), 1)
end
end)要点:
- 必须留回差(上例 5.0)。不留回差会在临界点每个周期翻一次, 现场表现是报警灯闪烁、云平台报警记录刷屏。
- 通讯断线要单独判。数据停在旧值看起来"一切正常",是最危险的假象。
- 只在状态变化时上报,不要每周期发同一条 —— 平台侧会被刷爆。
验证方法:把工程量手动改到 81 → 报警位应置 1 且平台收到一条; 改到 78(回差区内)→ 报警应保持,这是回差生效的证据; 改到 74 → 复归并再收到一条。拔掉通讯线 → 断线位应置 1。
常见坑:
- 不留回差 → 临界点抖动。
- 每周期无条件
publish→ 平台报警记录被淹没,真正的报警反而看不见。 - 只判数值不判通讯 → 从站掉线后数值停在旧值,报警永远不触发。
四、把零散模块串成一条链
上面四个骨架不是孤立的,一个完整应用是这条链:
采集(梯形图轮询 / Lua 协议网关)
↓
工程量换算(Lua:量程、公式、钳位)
↓
判断与报警(Lua 或梯形图,必须留回差)
↓
上报(Lua:周期 + 变化触发)
↓
接收下发(Lua 回调 → 写 M/D)每个环节分别在哪一页:轮询见 b-07-modbus-serial.md、 换算见 04-01-plc.md、MQTT 见 05-02-mqtt.md、 JSON 见 11-05-json.md、定时器见 03-02-timer.md。
五、现场最常见反模式 Top 3
mqtt.is_linked()没和1比较 —— 返回整数,直接if恒为真, 断线时还在发,消息全丢。- 回调里没有
pcall保护 —— 未捕获的错误会导致 Lua 状态机重建、脚本 5 秒后重跑, 表现是"设备隔几秒重启一次程序"。 - 疑似用
MOV传送浮点(应为DEMOV)—— 浮点占 2 个连续 D,MOV只搬一半。
更多见 a-05-ai-pitfalls.md。
六、给 AI 的一句话口径
写 LPPLC 程序时:梯形图做周期性的采集与联锁,Lua 做换算、协议与云通讯, 两边只通过 D/M 寄存器交接。 Lua 的主循环用
timer.start,外部事件用:reg()回调,不要写while true + sleep。 MQTT 对象、定时器对象必须全局持有;is_linked()要== 1;浮点用plc.df(); 报警判定必须留回差。