Skip to content

🏗️ 现场程序常见架构套路 ​

这一页讲的不是"某个函数怎么用",而是一台设备上的程序整体该怎么组织—— 也就是"框架":把若干模块按现场最稳的方式摆在一起。

为什么要有这一页:学会每个模块 ≠ 会写程序。 实际现场最常见的困惑是"我知道 timer.start、也知道 mqtt.publish,但不知道它们该怎么摆在一起"。 下面把现场跑得最稳、被反复验证的几种框架直接给你,每种都能在 例程总览 里找到可下载运行的完整代码。


一、先看清楚:现场的程序长什么样 ​

形态占比
梯形图 + Lua 都有80.2%(474 / 591 台)
只有梯形图8.1%
只有 Lua0.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)

要点:

  1. mq 必须是全局的 —— 局部变量被 GC 之后所有回调静默失效(不报错,就是不工作)。
  2. 订阅放 CONNECTED 回调里。
  3. mqtt.is_linked() 返回整数,必须写 == 1,写 if mqtt.is_linked(mq) then 永远是真。
  4. 上报前先判断在线,否则离线期间会堆一大批发不出去的消息。

验证方法:云侧下发 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)

要点:

  1. 上电延时是必需的,不是可选项。现场最常报的"刚上电数据不对"就是这个。
  2. 换算公式要写清单位和出处(厂家公式 / 现场标定),半年后没人敢改一堆裸数字。
  3. 零点漂移要钳位,否则小噪声长时间累计会滚成很大的误差。
  4. 浮点一律 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

要点:

  1. 用一个计数器 + 一个自复位定时器做状态机,一次只发一路。 绝不能在同一周期里发多条 EXTR,串口会撞车。
  2. 每路的源地址(D0 / D4 / D8…)要错开,间隔留够 —— 一个浮点占 2 个连续 D,只留 1 个就会覆盖。
  3. 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)

要点:

  1. 必须留回差(上例 5.0)。不留回差会在临界点每个周期翻一次, 现场表现是报警灯闪烁、云平台报警记录刷屏。
  2. 通讯断线要单独判。数据停在旧值看起来"一切正常",是最危险的假象。
  3. 只在状态变化时上报,不要每周期发同一条 —— 平台侧会被刷爆。

验证方法:把工程量手动改到 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 ​

  1. mqtt.is_linked() 没和 1 比较 —— 返回整数,直接 if 恒为真, 断线时还在发,消息全丢。
  2. 回调里没有 pcall 保护 —— 未捕获的错误会导致 Lua 状态机重建、脚本 5 秒后重跑, 表现是"设备隔几秒重启一次程序"。
  3. 疑似用 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(); 报警判定必须留回差。

LPPLC 工业物联网云平台 · 软硬件与云平台全栈自研