主题
综合例程: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)逐段要点:
decode必须pcall包。JSON 来自外部,格式一坏就抛异常, 裸调用会把整个回调打断(见 json — JSON 的注意事项)。null解出来不是nil,是json.null(一个 userdata 哨兵)。 直接tostring(v)会打出userdata: 0x5a3f...这种地址,日志上很难看, 所以单独判type(v) == "userdata"打<null>。key_name走参数而不是全局变量。原文是用一个全局key在递归之间传值, 见第七节的对照。pairs的遍历顺序不确定,同一张表两次打印,字段顺序可能不一样, 这是 Lua 的正常行为,不是 bug。要固定顺序就改用ipairs(数组部分)或先把 key 排序。- 嵌套深度极深时不要直接递归打印:cjson 的解码深度上限是 1000 层, 真到那个量级说明数据本身有问题,先查上游。
验证方法:
- 下载运行,日志应出现
out table,随后是一段带缩进的{ "1" = 1.1 … "key" = <null> }。 - 把
sjson换成[[{"a":{"b":{"c":1}},"d":[1,2,3]}]]:应打出三层缩进, 数组部分显示成"1" = 1、"2" = 2(cjson 把 JSON 数组解成从 1 起的 table)。 - 把
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")逐段要点:
collectgarbage("count")的单位是 KB,返回的是 Lua 分配器统计的总占用 (含解释器自身和所有全局变量),所以基线不是 0,几百 KB 是正常的。- 必须先把变量置
nil(或让它离开作用域),回收才有意义。 变量还在被引用时,collect什么也收不走 —— 这是绝大多数"回收没用"的原因。 "collect"是一次完整 GC,会短暂占用 CPU。周期性脚本里不要每轮都调, 只在"确定刚释放了一大批对象"之后调,或者干脆不调(增量 GC 自己在跑)。- 对本固件更重要的一条:
mqtt/http/tcp_client这类对象在被回收时才会释放底层连接资源。 把不再使用的对象置nil再collectgarbage("collect"),能立刻触发它的析构; 反过来,只要还留着一个引用(哪怕是一个再也不会执行的回调闭包在捕获它),它就一直占着。 - 常用的其它参数:
"stop"/"restart"关开自动 GC,"step"单步推进,"isrunning"查当前是否开着。这几个在长期运行的设备上都别乱动。
验证方法:
- 三行数字应满足:第二行 > 第一行,第三行 ≈ 第一行(回落到基线附近)。
- 把
mtable = nil这一行注释掉再下载:第三行仍然停在高位 —— 直观证明"还在引用的东西收不走"。 - 反复下载同一个脚本,基线一次比一次高 → 说明您的脚本里有全局对象没释放, 这才是真正要查的问题(旧脚本残留的对象还挂在全局表上)。
四、用元表写一个"类"
场景:同一条 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 表逐段要点:
setmetatable(o, self)+self.__index = self这两行是"类"的全部机制: 实例o自己是空的,访问o.xxx时先在o里找,找不到就顺着__index到类表里找。- 实例属性必须写
o.xxx,不能写self.xxx。self在这里就是类表本身, 写self.name = name等于把属性写到类上,所有实例共享一份 —— 这是流传教程里最常见的一个坑,第七节给了对照。 - 方法定义在类表上(
function Meter:show()),实例通过__index继承到, 所以只有一份代码,不会每个实例复制一遍。 - 调用时冒号
m1:show()等价于m1.show(m1),self就是m1; 漏写成m1.show()会让self变成nil,报"attempt to index a nil value"。 - 想再派生一层,就用
Child = setmetatable({}, Parent); Child.__index = Child, 查找会沿元表链一路往上。
验证方法:
- 日志应依次出现
A 表 = 100、B 表 = 0、A 表 B 表—— 两个实例互不影响是这段代码写对了的唯一标志。 - 把
o.name = name改回self.name = name再下载:m2:show()会打出B 表 = 0但m1.name也变成了B 表,两个实例的名字被第二次new覆盖了。 - 在梯形图 / 其它回调里引用这些实例时,把实例表放在全局或脚本顶层
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("----------")逐段要点:
resume每次只推进到下一个yield(或函数结束),不会一口气跑完。status的三种常见值:suspended(停在 yield 上,可继续)、running(正在执行)、dead(跑完或出错,永远不能再 resume)。running()返回两个值:当前线程 + 是否为主线程。 在协程里第二个是false,在主流程里是true。create+resume会吞错误:协程内报错时resume返回false, 错误信息, 程序继续往下走、什么都不打印;wrap则把错误直接抛出来。 要"静默"就用create并且每次检查返回值,要"早暴露"就用wrap。- 这一页只是语言层速查,实际业务怎么写协程请看 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)逐段要点:
system.sleep写在协程里是正确写法,不是违规。 协程内的sleep做的事是"注册一个定时器 +coroutine.yield", 到点由主循环自动把协程恢复,不需要您再resume第二次。 所以这两条协程一旦启动就自己一直走,脚本本身已经结束了。- 同样这句写在脚本顶层就是灾难:顶层是主线程,
sleep在那里是真阻塞、 不泵消息队列,循环期间串口回调、MQTT、梯形图交互全部冻住。 三种上下文的完整对比见 并发模型概述 — system.sleep 的三种行为。 resume只在末尾调一次,之后不要再手动推;手动再推会与自动恢复撞在一起, 表现为某轮多执行一次。- 协程是协作式的:只在
yield/sleep处交还执行权。 循环体里如果有长耗时的纯计算,其它协程和所有回调都得等它算完。 - 想停下来:置
running = false,等协程自己走完当前一轮(最迟一个sleep周期后); 强砍用coroutine.close,但协程内已经改到一半的输出口不会被回滚,慎用。
验证方法:
- 日志应出现
co1 run每秒一条、co2 run每五秒一条,两者交错但节奏互不影响。 - 另开一个
timer.start(300, function() print("tick") end):tick照常每 300ms 出现 —— 证明事件循环没有被这两条while true卡住。 - 把
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同一个表是未定义行为, 要边遍历边加就先收集到另一张表、遍历完再合并。
九、相关文档
- 并发模型概述:
system.sleep的三种行为、为什么不能阻塞主循环 - coroutine — 协程:全部函数与三个业务例程
- json — JSON:
encode/decode/json.null - base — 基础库:
pcall/type/tostring等全局函数 - string — 字符串库:
string.rep/string.format/tohex/fromhex - table — 表库:
table.insert/table.sort - system — 系统功能:
system.sleep/system.start_thread - 综合例程:HTTP 上报与拉取:解外部 JSON 的实际用法
- 综合例程:DLT645 电表采集上云:多设备 + 上报的完整链路