Skip to content

综合例程:Lua 语言层(表遍历 / 内存 / 面向对象 / 协程) ​

这一组脚本不碰任何外设,纯是 Lua 语言本身:把解码出来的一整张表打印清楚、 看内存涨了多少能不能降回来、用元表写一个"类"、用协程排两条自己的周期任务。 现场排障时它们出场频率不比采集脚本低。 本页与并发章节分工:并发模型概述 讲为什么不能阻塞, coroutine — 协程 讲怎么用在业务上, 本页只补语言层的基础写法。


一、这一组例程解决什么 ​

想做的事用到的东西本页面
云端下发的 JSON 不知道长什么样,想整张打出来看json.decode + pairs + string.rep第二节
脚本跑久了内存会不会一直涨、能不能主动回收collectgarbage第三节
同类设备有多台,想用"类 + 实例"的方式管理而不是复制粘贴setmetatable + __index第四节
先搞懂协程的每个函数怎么用coroutine.*第五节
两条各自每 1 秒干一次的事,互不干扰地一直跑coroutine + system.sleep第六节

二、把解码出来的表整个打印出来 ​

场景:MQTT / HTTP 回来的 JSON 字段不固定,或嵌套好几层,print 一个字段一个字段写不过来。 一个递归函数把整张表按层级打出来,是最省事的调试手段。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 递归打印任意结构的 table,用来调试 JSON / 配置表
-- ============================================================

-- key 通过参数往下传,不借用全局变量
local function print_table(t, level, key_name)
    level   = level or 1
    key_name = key_name or ""
    local indent = string.rep("  ", level)

    if key_name ~= "" then
        print(indent .. key_name .. " = {")
    else
        print(indent .. "{")
    end

    for k, v in pairs(t) do
        if type(v) == "table" then
            print_table(v, level + 1, tostring(k))
        elseif type(v) == "userdata" then          -- JSON 的 null 解出来是 json.null
            print(string.format("%s%s = <null>", indent .. "  ", tostring(k)))
        else
            print(string.format("%s%s = %s", indent .. "  ", tostring(k), tostring(v)))
        end
    end

    print(indent .. "}")
end

local sjson = [[{"1":1.1,"2":2.2,"3":3.3,"key":null}]]
local ok, tjson = pcall(json.decode, sjson)
if not ok then
    print("JSON 解析失败:", tjson)                 -- 失败时第二个返回值是错误信息
    return
end
print("out", type(tjson))                          -- table
print_table(tjson, 2)

逐段要点:

  1. decode 必须 pcall 包。JSON 来自外部,格式一坏就抛异常, 裸调用会把整个回调打断(见 json — JSON 的注意事项)。
  2. null 解出来不是 nil,是 json.null(一个 userdata 哨兵)。 直接 tostring(v) 会打出 userdata: 0x5a3f... 这种地址,日志上很难看, 所以单独判 type(v) == "userdata" 打 <null>。
  3. key_name 走参数而不是全局变量。原文是用一个全局 key 在递归之间传值, 见第七节的对照。
  4. pairs 的遍历顺序不确定,同一张表两次打印,字段顺序可能不一样, 这是 Lua 的正常行为,不是 bug。要固定顺序就改用 ipairs(数组部分)或先把 key 排序。
  5. 嵌套深度极深时不要直接递归打印:cjson 的解码深度上限是 1000 层, 真到那个量级说明数据本身有问题,先查上游。

验证方法:

  1. 下载运行,日志应出现 out table,随后是一段带缩进的 { "1" = 1.1 … "key" = <null> }。
  2. 把 sjson 换成 [[{"a":{"b":{"c":1}},"d":[1,2,3]}]]:应打出三层缩进, 数组部分显示成 "1" = 1、"2" = 2(cjson 把 JSON 数组解成从 1 起的 table)。
  3. 把 sjson 改成一段坏 JSON(比如少一个右括号):应只出现一行 JSON 解析失败: … 而不是脚本报错中断 —— 这才证明 pcall 生效了。

三、看内存占用并主动回收 ​

场景:脚本要长期跑,怀疑某处一直在漏内存;或者刚用完一个大表 / 一个网络连接对象, 想立刻把资源还回去,不想等垃圾回收自己挑时间。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 量一量内存:造垃圾 -> 看涨多少 -> 断引用 -> 回收 -> 看降多少
-- collectgarbage 是 Lua 的全局函数,不属于任何模块,不需要 require
-- ============================================================

collectgarbage("collect")                                  -- 先把已有的垃圾清干净,得到干净的基线
local c1 = collectgarbage("count")
print("最开始, Lua 的内存为", c1, "KB")

local mtable = { ["x"] = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }
print("现在, 声明 1000 个表, 并加到 mtable 中")
for i = 1, 1000 do
    table.insert(mtable, {})                               -- 不断创建新表塞进 mtable
end
print("现在内存为:", collectgarbage("count"), "KB")

mtable = nil                                               -- 整张表再没有引用,成为垃圾
print("调用收集…")
collectgarbage("collect")                                  -- 立即做一次完整回收
print("收集后, 当前的 Lua 内存为", collectgarbage("count"), "KB")

逐段要点:

  1. collectgarbage("count") 的单位是 KB,返回的是 Lua 分配器统计的总占用 (含解释器自身和所有全局变量),所以基线不是 0,几百 KB 是正常的。
  2. 必须先把变量置 nil(或让它离开作用域),回收才有意义。 变量还在被引用时,collect 什么也收不走 —— 这是绝大多数"回收没用"的原因。
  3. "collect" 是一次完整 GC,会短暂占用 CPU。周期性脚本里不要每轮都调, 只在"确定刚释放了一大批对象"之后调,或者干脆不调(增量 GC 自己在跑)。
  4. 对本固件更重要的一条:mqtt / http / tcp_client 这类对象在被回收时才会释放底层连接资源。 把不再使用的对象置 nil 再 collectgarbage("collect"),能立刻触发它的析构; 反过来,只要还留着一个引用(哪怕是一个再也不会执行的回调闭包在捕获它),它就一直占着。
  5. 常用的其它参数:"stop" / "restart" 关开自动 GC,"step" 单步推进, "isrunning" 查当前是否开着。这几个在长期运行的设备上都别乱动。

验证方法:

  1. 三行数字应满足:第二行 > 第一行,第三行 ≈ 第一行(回落到基线附近)。
  2. 把 mtable = nil 这一行注释掉再下载:第三行仍然停在高位 —— 直观证明"还在引用的东西收不走"。
  3. 反复下载同一个脚本,基线一次比一次高 → 说明您的脚本里有全局对象没释放, 这才是真正要查的问题(旧脚本残留的对象还挂在全局表上)。

四、用元表写一个"类" ​

场景:同一条 485 上挂 8 台同样的仪表,每台都要"建对象 + 加通道 + 读值"。 与其复制粘贴 8 遍,不如用一个 Meter 类,每台 new 一个实例。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 元表 + __index 实现最简"类":属性必须挂在实例 o 上
-- ============================================================
Meter = { name = "", value = 0 }                          -- 类表(当作元表用)

function Meter:new(o, name)
    o = o or {}                                           -- 没有现成表就新建一张
    setmetatable(o, self)                               -- o 的元表指向类表
    self.__index = self                                 -- 找不到字段时沿类表找
    o.name  = name or "meter"                           -- 注意:挂在 o 上,不是 self 上
    o.value = 0
    return o
end

function Meter:set(v)
    self.value = v
end

function Meter:show()
    print(self.name, "=", self.value)
end

local m1 = Meter:new(nil, "A 表")
local m2 = Meter:new(nil, "B 表")
m1:set(100)
m1:show()                                                 -- A 表 = 100
m2:show()                                                 -- B 表 = 0   互不影响
print(m1.name, m2.name)                                  -- A 表  B 表

逐段要点:

  1. setmetatable(o, self) + self.__index = self 这两行是"类"的全部机制: 实例 o 自己是空的,访问 o.xxx 时先在 o 里找,找不到就顺着 __index 到类表里找。
  2. 实例属性必须写 o.xxx,不能写 self.xxx。self 在这里就是类表本身, 写 self.name = name 等于把属性写到类上,所有实例共享一份 —— 这是流传教程里最常见的一个坑,第七节给了对照。
  3. 方法定义在类表上(function Meter:show()),实例通过 __index 继承到, 所以只有一份代码,不会每个实例复制一遍。
  4. 调用时冒号 m1:show() 等价于 m1.show(m1),self 就是 m1; 漏写成 m1.show() 会让 self 变成 nil,报"attempt to index a nil value"。
  5. 想再派生一层,就用 Child = setmetatable({}, Parent); Child.__index = Child, 查找会沿元表链一路往上。

验证方法:

  1. 日志应依次出现 A 表 = 100、B 表 = 0、A 表 B 表 —— 两个实例互不影响是这段代码写对了的唯一标志。
  2. 把 o.name = name 改回 self.name = name 再下载:m2:show() 会打出 B 表 = 0 但 m1.name 也变成了 B 表,两个实例的名字被第二次 new 覆盖了。
  3. 在梯形图 / 其它回调里引用这些实例时,把实例表放在全局或脚本顶层 local, 写成函数内的 local 会在函数返回后被回收。

五、协程标准用法速查 ​

场景:想先把 coroutine 的每个函数到底返回什么搞清楚,再往业务上用。

完整代码(可直接下载运行,纯演示,跑完就结束):

lua
-- ============================================================
-- coroutine 各函数的行为演示:create / resume / status / wrap / yield / running
-- ============================================================
co = coroutine.create(
    function(i)
        print(i)
    end
)
coroutine.resume(co, 1)                -- 把 1 作为参数送进协程 -> 打印 1
print(coroutine.status(co))            -- dead(函数执行完了)

print("----------")

co = coroutine.wrap(
    function(i)
        print(i)
    end
)
co(1)                                  -- wrap 返回"调用即 resume"的函数 -> 打印 1

print("----------")

co2 = coroutine.create(
    function()
        for i = 1, 10 do
            print(i)
            if i == 3 then
                print(coroutine.status(co2))    -- running
                print(coroutine.running())      -- thread: 0x...  false
            end
            coroutine.yield()                  -- 在这里交还执行权
        end
    end
)
coroutine.resume(co2)                  -- 1
coroutine.resume(co2)                  -- 2
coroutine.resume(co2)                  -- 3  running  thread:...

print(coroutine.status(co2))           -- suspended(停在 yield 处,还没跑完)
print(coroutine.running())             -- 主线程自己:thread: 0x...  true
print("----------")

逐段要点:

  1. resume 每次只推进到下一个 yield(或函数结束),不会一口气跑完。
  2. status 的三种常见值:suspended(停在 yield 上,可继续)、 running(正在执行)、dead(跑完或出错,永远不能再 resume)。
  3. running() 返回两个值:当前线程 + 是否为主线程。 在协程里第二个是 false,在主流程里是 true。
  4. create + resume 会吞错误:协程内报错时 resume 返回 false, 错误信息, 程序继续往下走、什么都不打印;wrap 则把错误直接抛出来。 要"静默"就用 create 并且每次检查返回值,要"早暴露"就用 wrap。
  5. 这一页只是语言层速查,实际业务怎么写协程请看 coroutine — 协程 的三个例程。

验证方法:下载运行,日志按上面括号里的注释逐行出现即为正确; co2 那段只 resume 三次,后面的 4~10 不会被打印(协程还停在 yield 上)。


六、两条自持续的周期任务 ​

场景:两件事各按各的节奏无限重复 —— 每秒打一次心跳、每五秒采一次集, 谁也不该挡住谁,也不想为每件小事单开一个 timer。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 两条协程各跑各的无限循环;靠 system.sleep 让出,不阻塞设备
-- ============================================================
running = true                                          -- 全局开关,便于以后停

co1 = coroutine.create(function()
    while running do
        print("co1 run")
        system.sleep(1000)                              -- 协程内:注册定时器后让出
    end
    print("co1 结束")
end)

co2 = coroutine.create(function()
    while running do
        print("co2 run")
        system.sleep(5000)
    end
    print("co2 结束")
end)

coroutine.resume(co1)                                   -- 只在这里各推一次
coroutine.resume(co2)

逐段要点:

  1. system.sleep 写在协程里是正确写法,不是违规。 协程内的 sleep 做的事是"注册一个定时器 + coroutine.yield", 到点由主循环自动把协程恢复,不需要您再 resume 第二次。 所以这两条协程一旦启动就自己一直走,脚本本身已经结束了。
  2. 同样这句写在脚本顶层就是灾难:顶层是主线程,sleep 在那里是真阻塞、 不泵消息队列,循环期间串口回调、MQTT、梯形图交互全部冻住。 三种上下文的完整对比见 并发模型概述 — system.sleep 的三种行为。
  3. resume 只在末尾调一次,之后不要再手动推;手动再推会与自动恢复撞在一起, 表现为某轮多执行一次。
  4. 协程是协作式的:只在 yield / sleep 处交还执行权。 循环体里如果有长耗时的纯计算,其它协程和所有回调都得等它算完。
  5. 想停下来:置 running = false,等协程自己走完当前一轮(最迟一个 sleep 周期后); 强砍用 coroutine.close,但协程内已经改到一半的输出口不会被回滚,慎用。

验证方法:

  1. 日志应出现 co1 run 每秒一条、co2 run 每五秒一条,两者交错但节奏互不影响。
  2. 另开一个 timer.start(300, function() print("tick") end):tick 照常每 300ms 出现 —— 证明事件循环没有被这两条 while true 卡住。
  3. 把 system.sleep(1000) 换成空(不 yield):日志瞬间刷爆且 tick 消失、设备无响应 —— 这就是"协作式"的含义,也是唯一需要靠 sleep/yield 让出的原因。

七、错误写法 vs 正确写法对照 ​

lua
-- 错误一:用全局变量在递归之间传 key
key = ""
function PrintTable(table, level)
    ...
    if key ~= "" then print(indent..key.." = {") else print(indent.."{") end
    key = ""
    for k, v in pairs(table) do
        if type(v) == "table" then key = k; PrintTable(v, level + 1) end
        ...
lua
-- 错误二:把实例属性写到了类上
function Rectangle:new(o, length, breadth)
    o = o or {}
    setmetatable(o, self)
    self.__index = self
    self.length = length or 0            -- self 是类表 Rectangle
    self.breadth = breadth or 0
    self.area = length * breadth
    return o
end
问题后果正确做法
递归里用全局 key 传字段名_G.key 被脚本占用,一旦别处也叫 key(很常见的变量名)就互相踩,打印结果时有时无;多个子表并列时缩进层级也容易错乱把 key 当函数参数传:print_table(v, level + 1, tostring(k))
tostring(v) 直接打所有值JSON 的 null 解出来是 json.null(userdata),日志里是一串内存地址先 type(v) == "userdata" 判一下,打 <null>
实例属性写成 self.xxx属性落在类表上,所有实例共享:后 new 的会把先 new 的覆盖掉,多台设备的数据串在一起一律 o.xxx = …,只有方法才挂在类表上
local c1 = collectgarbage("count") 后指望它"释放"count 只是读数字,不回收任何东西释放靠 collectgarbage("collect"),前提是引用已断
注释写"是本地变量"、代码写的是 mtable = {…}少了 local 就是全局,脚本重新下载后旧表还挂在全局表上,内存基线越量越高明确写 local mtable = {…}
顶层写 while … do system.sleep(1000) end 等同就绪整台设备被一个循环锁死,所有回调失效,网线没插时永远出不来要等就把逻辑放进协程,或改用定时器观测(见串口透传例程第五节)

八、注意事项汇总 ​

  • collectgarbage 是全局函数,不要写成 base.collectgarbage 或 system.collectgarbage, 那会得到 attempt to call a nil value。
  • 脚本重新下载不会自动清空您造的全局对象:只有 local 在脚本顶层的才会随脚本作用域消失。 长期观察内存时,把该 local 的都 local 掉。
  • Lua 里 0 和 "" 都是真:if running then 判不出 0,开关一律显式写 == 1 / == true。
  • 协程内报错不会有任何弹窗式提示:create + resume 的错误藏在第二个返回值里。 调试期建议 local ok, err = coroutine.resume(co); if not ok then print(err) end。
  • 别把"类"和 LuaBridge 的对象混用:mqtt.creat 返回的是带元表的 userdata, 可以再包进实例表里当字段,但不要试图给它 setmetatable 换成您的类表。
  • pairs 遍历表的同时 table.insert 同一个表是未定义行为, 要边遍历边加就先收集到另一张表、遍历完再合并。

九、相关文档 ​

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