你试过在单片机上跑脚本语言吗?

在 Arduino 上点个灯,写 C 是基本功。但如果需求变成了"让用户自己写脚本来控制设备",事情就变了。

拿 Arduino UNO 来说——2KB RAM,32KB Flash。你想在上面跑 Lua,让用户写业务逻辑。选项不多:

方案一:移植 eLua。eLua 的 VM 自身就要吃掉十几 KB Flash,给应用代码剩不下空间。2KB 的 RAM?初始化完 VM 就快没了。

方案二:自己写解释器。词法分析、语法分析、字节码执行引擎……三个月后你还在修 GC 的 bug。

根本问题不在 Lua 本身——Lua 已经是嵌入式领域最轻量的方案了。问题在于解释器那层翻译开销:逐条读字节码、跳转到 C 函数、解码操作数、层层返回。这些消耗在 PC 上不算什么,在单片机上是真金白银。

那能不能不走这条路?不解释,直接编译?把 Lua 源码变成机器码,让芯片裸跑?

Cleuton Sampaio 最近用 Rust 写了一个东西叫 TinyLua ,做的就是这件事。它把 Lua 源代码直接编译成 AVR/ARM 原生机器码——跳过 VM、跳过解释器、跳过 GC。编译出来的二进制直接烧进 Arduino,和 C 固件一模一样。

零 VM 是什么概念?先聊聊传统 Lua 怎么跑

写过 Lua C API 的人都知道,你代码里调用的是解释器:

// 传统嵌入式 Lua——你需要一个完整的解释器
lua_State *L = luaL_newstate();  // 分配 VM 状态,吃内存
luaL_openlibs(L);                // 加载标准库,吃更多内存
luaL_dostring(L, "print('hello')");  // 解释执行,每一步都是开销
lua_close(L);

在 PC 上这完全 OK。但在单片机上,每一步分配都是奢侈。

TinyLua 的思路换了一个方向:不解释,直接编译。你的 Lua 代码:

local a = 10
local b = 20
local c = a + b

经过 TinyLua 的编译器后,变成了可以直接写入芯片的 AVR 机器码。没有 lua_State,没有 lua_CFunction,没有指令分派循环——CPU 直接跑编译后的指令。

打个比方:你本来每次说话都要通过翻译传话,现在直接学会了外语。翻译没了,对话速度自然上去了。

编译器怎么绕开 VM?

一个源码到机器码的编译器,分三步:前端(源码→AST)、中间处理(类型推导、优化)、后端(生成目标机器码)。

Lua 源码 → 词法分析 → Token 流
       → 语法分析 → AST
       → 语义分析 → IR(中间表示,带类型信息)
       → 代码生成 → AVR/ARM 机器指令

每一步都有坑,但最大的坑是:Lua 是动态类型的,而机器码要求强类型

考虑这段 Lua 代码:

function add(a, b)
    return a + b
end

在 Lua 解释器里,a + b 这行代码在运行时会检查:a 是数字吗?b 是数字吗?如果是字符串,要不要做隐式转换?如果是 table 且有 __add 元方法呢?——这些运行时判断就是解释器的核心工作,也是性能消耗的主要来源。

TinyLua 的编译器需要做的是 在编译期就确定类型,然后直接生成对应的机器指令。如果它推断出 a 和 b 都是整数,那 a + b 就变成了一条 ADD 指令。没有运行时类型检查,没有元方法分派——就是一条指令。

这其实就是 提前编译(AOT)和静态类型推导的结合:编译器在生成代码前尽可能多地"猜出"变量的类型,对于编译期能确定的,生成直接指令;对于运行期才能确定的,插入最小化的类型标记和分派代码。

为什么选 Rust 来写编译器?

编译器开发是一个典型的"适合用 Rust 解决的问题":

模式匹配 + 枚举:编译器就是在处理 AST 树和各种 IR 节点。Rust 的 enum 加上 match 让你可以优雅地遍历和转换这些树结构:

enum LuaValue {
    Nil,
    Boolean(bool),
    Integer(i64),
    Number(f64),
    String(String),
}

// 编译期类型推导
fn infer_type(expr: &Expr) -> Option<LuaType> {
    match expr {
        Expr::Integer(_) => Some(LuaType::Integer),
        Expr::BinaryOp { op: BinOp::Add, left, right } => {
            let lt = infer_type(left)?;
            let rt = infer_type(right)?;
            if lt == LuaType::Integer && rt == LuaType::Integer {
                Some(LuaType::Integer)
            } else {
                Some(LuaType::Number)
            }
        }
        _ => None,  // 编译期无法确定
    }
}

零成本抽象:写编译器需要大量中间数据结构(AST、IR、符号表、寄存器分配图)。如��你用 C++,要么手动管理内存冒着泄漏的风险,要么用智能指针带着运行时开销。Rust 的所有权系统让这些结构的内存管理既安全又高效——编译器本身的性能就很好。

交叉编译能力:Rust 通过 LLVM 后端支持和 #![no_std],天生就能生成不依赖标准库的代码。写一个不依赖操作系统、不依赖 libc 的编译器,Rust 比大多数语言都顺手。之前我们也聊过 Rust 编译管线的内部机制 ,有助于理解 Rust 编译器本身是怎么工作的。

内存管理:嵌入式编译器最棘手的问题

如果说类型推导是第一道坎,那内存管理就是第二道,也是最难的一道。

Lua 有 GC(垃圾回收),你在 Lua 里写 t = {} 创建一个 table,不用操心它什么时候释放——GC 会处理。但在裸机编译的场景下,你生成的代码里没有 GC 运行时,谁来回收内存?

TinyLua 的做法是:

  1. 静态分配优先:编译器尽可能在编译期计算出每个变量需要的空间,然后直接内嵌到生成的数据段里
  2. 区域分配(Arena):对于编译期无法确定大小的结构(如运行时才能知道长度的字符串),使用 bump allocator——一个大数组加一个指针,用完就往前移,不回收,程序结束统一释放
  3. 手动标记:对于确需动态分配的场景,生成的代码中包含轻量的引用计数

这三个策略加在一起,基本覆盖了单片机上的典型场景。当然,这意味着你写的 Lua 代码不能太"放飞自我"——不能无限递归、不能任意拼接巨大字符串、不能创建海量临时 table。但话说回来,在 2KB RAM 的设备上,这些限制本来就该有。

-- TinyLua 风格的代码:静态、可预测
local led_pin = 13
local delay_ms = 500

function blink()
    gpio_write(led_pin, 1)
    delay(delay_ms)
    gpio_write(led_pin, 0)
    delay(delay_ms)
end

实际跑起来效果如何?

虽然没有官方的完整 benchmark,但从 demo 视频和技术博客透露的信息来看:

  • Flash 占用:编译后的二进制远小于携带完整 Lua VM 的方案。去掉了解释器、GC、标准库的运行时支持代码,只留下实际用到的语言特性的编译结果
  • 执行速度:纯粹的原生指令执行,没有 VM 指令分派循环的开销。简单的算术和逻辑操作接近手写 C 的性能
  • 启动时间:没有 VM 初始化,程序启动即执行,对于需要快速响应的嵌入式场景极其重要
  • 开发体验:用户写 Lua(语法简单、学习成本低),实际跑的是原生机器码。两头的好处都占了

这个思路最大的意义可能不在性能数字本身,而在于它证明了一件事:动态脚本语言不一定非要带个 VM 才能跑在单片机上。提前编译这条路是走得通的。

这意味着什么?

对嵌入式开发者来说,TinyLua 这样的工具提供了一个新选择:

方案性能内存占用开发门槛灵活性
纯 C/C++最高最低低(需重新编译烧录)
MicroPython/eLua中等较高(需 VM)高(热更新)
TinyLua(原生编译)中(编译后烧录)

它不是一个"替代一切"的方案,而是在特定场景下提供了一个以前不存在的选项:想要脚本语言的开发效率,又不想牺牲原生性能

具体来说,这些场景特别适合:

  1. 教育场景:教学生嵌入式编程,Lua 比 C 友善太多,但又不想让他们接触 VM 的复杂性
  2. 简单控制逻辑:传感器读取、电机控制、LED 灯带——逻辑简单但对响应时间有要求
  3. 需要用户脚本的设备:智能家居网关、工业控制器——让用户写 Lua 配置逻辑,编译后下发到设备

当然,项目还在早期阶段。支持的语言特性有限,标准库不完整,编译器的错误提示可能不够友好。但方向和思路都非常有意思。Rust 社区一直在探索"用 Rust 写基础设施"的边界——数据库、操作系统、浏览器引擎,现在又多了一个编译器。

如果你想深入了解 Rust 在嵌入式方面的其他探索,可以看看我们之前写的 Rust FFI 包裹 C 代码库的实战思路Rust 编译时间优化的 8 个技巧


对嵌入式开发和 Rust 底层编程感兴趣?关注「全栈之巅-梦兽编程」公众号,每周更新 Rust 技术深度文章。

也欢迎了解 梦兽编程 AI 编程助手服务 ,帮你把 AI 编程工具用到生产环境。

常见问题

Q: TinyLua 支持完整的 Lua 5.x 语法吗?

目前不支持。它覆盖了 Lua 的一个子集——基础数据类型、变量、函数、控制流、基本的 table 操作。高级特性如 coroutine、metatable、动态 require 被有意省略了,因为在裸机场景下这些特性不必要且实现代价高。

Q: 生成的机器码能跨平台吗?

不能,这是原生编译。编译器需要知道目标芯片的指令集架构(AVR、ARM Cortex-M 等),生成对应平台的机器码。换个芯片就得重新编译。

Q: 和 MicroPython 比有什么区别?

MicroPython 依然是一个解释器,它有一个完整的 VM 和运行时在你的芯片上跑。TinyLua 在 PC 端就把代码编译好了,芯片上只有原生指令,没有运行时。这就像预制菜 vs 带厨房——前者轻量,后者灵活。

Q: 如果在 Lua 里写了死循环或者无限递归怎么办?

裸机上没有操作系统兜底,编译生成的代码真就会死循环。编译器可以做一些静态检查(比如检测没有退出条件的循环),但最终仍需要开发者负责。

Q: 设备上没有文件系统,怎么加载 Lua 代码?

你不需要在设备上加载。代码在开发机上编译成二进制,然后像普通固件一样通过 ISP/JTAG/USB 烧录到芯片里。整个过程和烧 C 代码完全一样。