WebAssembly(简称 wasm)是一种可移植的二进制指令格式,能在浏览器、Node.js、边缘网关和各类运行时中以接近原生的速度执行。把 Lua 编译到 wasm,意味着同一份 Lua 脚本既能跑在服务端,也能跑在浏览器里,还能被宿主以沙箱方式隔离执行——这对插件系统、用户自定义脚本、边缘计算等场景极具吸引力。
Lua 与 wasm 的结合有两条截然不同的路线:一条是把 Lua 虚拟机本身编译成 wasm 模块,让宿主(JS 或 Rust)调用它执行脚本;另一条是让 Lua 通过 FFI 调用已被编译为 wasm 的模块。本文聚焦前者(更常见、更实用),同时说明后者的适用边界。Lua 调用原生代码的基础机制见 https://plumephp.com/lua-c-integration-guide/,因为 Lua 编译成 wasm 本质就是把 C 实现的虚拟机搬到另一套运行时。
两条路线:宿主 vs 被宿主
先把概念理清,避免混淆:
| 路线 | 谁在跑 | 典型场景 | 工具 |
|---|---|---|---|
| Lua VM 编译为 wasm | Lua 代码在 wasm 中执行 | 浏览器脚本引擎、插件沙箱 | Emscripten、Wasmoon |
| Lua 调用 wasm 模块 | wasm 被 Lua 调用 | 计算加速、复用 Rust/C 模块 | Wasmtime、wasm3 |
本文主要讲第一条:把 lua.c、lapi.c 等 C 源码用 Emscripten 编译成 .wasm,再暴露一个 execute(code) 之类的接口给宿主。这样宿主无需理解 Lua 语法,只把用户脚本当字符串喂进去,Lua 虚拟机在 wasm 沙箱里跑完把结果传回来。
用 Emscripten 编译 Lua
最直接的方案是用 Emscripten 把官方 Lua 源码编译成 wasm。Emscripten 提供了完整的 libc,Lua 的 C 实现几乎无需改动即可编译:
# 1. 获取 Lua 源码
curl -LO https://www.lua.org/ftp/lua-5.4.6.tar.gz
tar xf lua-5.4.6.tar.gz && cd lua-5.4.6/src
# 2. 用 emcc 编译为 wasm 模块
emcc -O2 -o lua.js \
-s WASM=1 \
-s EXPORTED_FUNCTIONS='["_luaL_newstate","_luaL_loadstring","_lua_pcall"]' \
-s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
-s ALLOW_MEMORY_GROWTH=1 \
-s MODULARIZE=1 \
-s EXPORT_NAME='createLuaModule' \
lapi.c lcode.c lctype.c ldebug.c ldo.c ldump.c lfunc.c lgc.c \
llex.c lmem.c lobject.c lopcodes.c lparser.c lstate.c lstring.c \
ltable.c ltm.c lundump.c lvm.c lzio.c lauxlib.c lbaselib.c \
lcorolib.c ldblib.c liolib.c lmathlib.c loadlib.c loslib.c \
lstrlib.c ltablib.c lutf8lib.c linit.c
编译产物 lua.js + lua.wasm 可在浏览器或 Node.js 中加载。关键是 -s ALLOW_MEMORY_GROWTH=1,否则 wasm 线性内存固定,Lua 堆增长时会失败。
编译时还可以裁剪标准库:去掉 liolib.c(文件 IO)和 loslib.c(os.execute 等)能显著减小体积,同时提升沙箱安全性——这相当于在编译期做了一次"白名单"。
在 JavaScript 中驱动 Lua
加载模块后,用 cwrap 把 C 函数包装成 JS 函数:
const createLuaModule = require('./lua.js');
createLuaModule().then((Module) => {
const luaL_newstate = Module.cwrap('luaL_newstate', 'number', []);
const luaL_loadstring = Module.cwrap('luaL_loadstring', 'number', ['number', 'string']);
const lua_pcall = Module.cwrap('lua_pcall', 'number', ['number', 'number', 'number', 'number']);
const L = luaL_newstate();
// 注意:还需调用 luaL_openlibs(L) 才能用标准库
const code = 'return 1 + 2';
if (luaL_loadstring(L, code) !== 0) { throw new Error('load failed'); }
if (lua_pcall(L, 0, 1, 0) !== 0) { throw new Error('run failed'); }
// 从栈顶取返回值(此处省略 lua_tointeger 的包装)
});
这段代码揭示了 wasm 互操作的核心难点:所有值都要经过 Lua 栈传递。JS 与 wasm 之间只能传数字和线性内存中的字节,字符串、表这些 Lua 值必须通过 C API(lua_pushstring、lua_tolstring)序列化到线性内存再拷贝出来。这条边界是性能与复杂度的主要来源。
Wasmoon:开箱即用的方案
手工包装 C API 繁琐且易错。Wasmoon 把上述工作封装好,直接用 lua 字符串和 JS 值:
import { LuaFactory } from 'wasmoon';
const factory = new LuaFactory();
const lua = await factory.createEngine();
// 执行 Lua 代码
await lua.doString('x = 10 + 20');
console.log(await lua.global.get('x')); // 30
// JS 函数注入到 Lua
lua.global.set('log', (msg) => console.log('[lua]', msg));
await lua.doString('log("hello from lua")');
// Lua 函数被 JS 调用
const fn = await lua.doString('return function(a, b) return a * b end');
console.log(await fn(6, 7)); // 42
Wasmoon 内部用 Lua 5.4 的 wasm 构建,自动处理栈操作与类型转换。它适合"在 JS 里嵌一个 Lua 引擎"的需求,代价是体积(wasm 模块约 200~300 KB,gzip 后更小)和少量性能开销。
Wasmoon 的一个关键限制是全局环境隔离:每个 createEngine 得到独立的 Lua state,互不影响。这天然适合多租户脚本沙箱——每个用户一个引擎,互不干扰。
WASI:在服务端沙箱运行 Lua
浏览器之外,WASI(WebAssembly System Interface)让 wasm 模块能以受控方式访问文件、环境变量等系统资源。用 Emscripten 的 -sSTANDALONE_WASM 或官方 wasi-sdk 编译 Lua,就能在 Wasmtime、WasmEdge、Node.js 中运行:
# 用 wasi-sdk 编译(注意:需要 WASI 版的 libc)
$WASI_SDK/bin/clang --sysroot=$WASI_SDK/share/wasi-sysroot \
-O2 -o lua.wasm lapi.c lcode.c ... lua.c
# 用 Wasmtime 运行
wasmtime run --dir=/tmp lua.wasm -- -e 'print("hello from wasi")'
WASI 的价值在于能力安全(capability-based security):默认情况下 wasm 模块无法访问任何文件,只有通过 --dir 显式映射目录才能读写。这比在进程内用 load + 沙箱环境隔离 Lua 脚本更彻底——脚本连 C 层的 open() 都调不到。
# 只允许访问 ./scripts 目录,且限制内存 64MB、超时 1 秒
wasmtime run --dir=./scripts --max-memory-size=67108864 \
--timeout=1s lua.wasm -- ./scripts/plugin.lua
这组参数构成了一个实用的"用户脚本执行沙箱":即使脚本里写了 while true do end,也会被超时终止;即使尝试读 /etc/passwd,也会因未映射而失败。相比在宿主进程里直接跑 Lua,安全性提升明显。
内存模型与数据桥接
wasm 的线性内存(linear memory)是一段连续的字节数组,Lua 与宿主的所有复杂数据都要经过它。理解这一点能解释很多性能现象。wasm 线性内存与二进制布局的细节可参考 WebAssembly 二进制格式与内存模型 。
典型的字符串传递过程:
- 宿主把 JS 字符串编码为 UTF-8 字节,写入线性内存的某段空闲区;
- 调用
lua_pushlstring(L, ptr, len),Lua 从这段内存复制字节建立字符串对象; - Lua 侧处理完,把结果字符串的指针与长度通过 C API 取出;
- 宿主从线性内存读回字节,解码为 JS 字符串。
每一趟都有一次内存拷贝,这是跨边界的固有成本。减少拷贝的手段包括:批量传数据(一次传一个数组而非逐元素传)、复用缓冲区、用整数传递简单值。
对于大块数据(如二进制图片、音频),更高效的做法是共享内存:宿主把数据写入线性内存,Lua 通过 string 或 FFI 直接读指针,避免二次拷贝。但要注意 wasm 内存增长后基址会变,缓存的指针会失效,必须在每次可能触发内存增长的操作后重新取址。
性能取舍与适用边界
把 Lua 编译成 wasm 不是免费的午餐,性能特征与原生 Lua 有差异:
| 维度 | 原生 Lua | Lua-on-wasm |
|---|---|---|
| 纯计算 | 快 | 约为原生的 50%~80% |
| 字符串处理 | 快 | 边界拷贝开销明显 |
| 启动开销 | 低 | 需实例化 wasm 模块(数十毫秒) |
| 隔离性 | 弱(进程内) | 强(线性内存 + 能力模型) |
| 可移植性 | 需按平台编译 | 一份 wasm 到处跑 |
结论是:计算密集且数据交换少的场景,wasm 版 Lua 性能可接受;频繁跨边界传字符串/表的场景,桥接开销会吃掉收益。选型时要先压测真实负载,而不是只看微基准。
另一个常被忽视的点是 wasm 版的 Lua 没有 JIT。浏览器里的 Lua 是解释执行的(Wasmoon 用的是标准 Lua 5.4),不像 LuaJIT 那样有即时编译。因此对性能极度敏感的场景,LuaJIT 仍是首选;wasm 版 Lua 的价值在于可移植与隔离,而非绝对速度。涉及 LuaJIT 与原生扩展的细节可参考 https://plumephp.com/lua-ffi-foreign-function-interface/。
用 Rust 宿主驱动 Lua
Rust 是驱动 wasm 版 Lua 的另一常见宿主,适合服务端与 CLI 工具。用 wasmtime crate 加载模块:
use wasmtime::*;
fn main() -> anyhow::Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "lua.wasm")?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
// 取出导出的 C 函数
let new_state = instance.get_typed_func::<(), i32>(&mut store, "luaL_newstate")?;
let L = new_state.call(&mut store, ())?;
// 后续通过 luaL_loadstring / lua_pcall 执行脚本
println!("lua state = {L}");
Ok(())
}
Rust 宿主的优势是能用 wasmtime-wasi 精确控制能力:时钟、随机数、环境变量、文件系统都需显式注入。相比 JS 宿主,Rust 侧的类型系统与错误处理更严谨,适合把"执行用户 Lua 脚本"作为服务端能力暴露出去。代价是开发迭代慢,调试不如浏览器方便。
如果宿主是 Go,可以用 wasmtime-go 或 wazero(纯 Go 实现,无 CGO 依赖),后者在容器化部署时更省心。
插件系统实战
把 Lua-on-wasm 用作插件系统,是它最有价值的落地形态。核心设计有三点:
- 能力注入:宿主只暴露必要的 API(如
log、http_get),不暴露文件与进程; - 资源限额:限制内存、执行时间与输出大小;
- 状态隔离:每个插件一个独立的 Lua state,互不可见。
一个典型的宿主侧 API 表:
| 注入名 | 作用 | 风险 |
|---|---|---|
log(msg) | 写日志 | 低 |
fetch(url) | 发起 HTTP | 中(需域名白名单) |
kv_get(k) / kv_set(k, v) | 读写宿主 KV | 中(需配额) |
now() | 取当前时间 | 低 |
宿主在创建引擎后逐一 set 这些函数,插件脚本只能调用它们。任何未注入的能力,脚本都无从触及——这是"默认拒绝"的安全模型,比事后审计脚本内容可靠得多。
一个最小插件执行流程:
async function runPlugin(source, timeoutMs = 1000) {
const lua = await factory.createEngine();
lua.global.set('log', (m) => console.log('[plugin]', m));
const timer = setTimeout(() => lua.global.close(), timeoutMs);
try {
await lua.doString(source);
} finally {
clearTimeout(timer);
lua.global.close(); // 释放 wasm 实例与内存
}
}
lua.global.close() 是关键:不关闭会导致 wasm 实例与线性内存泄漏,长期运行的服务会累积大量僵尸引擎。
构建优化与体积裁剪
wasm 模块体积直接影响首屏加载与冷启动。几个可量化的优化手段:
# 1. 裁剪标准库:只保留必要模块
emcc -O2 -o lua.js \
-s EXPORTED_FUNCTIONS='["_luaL_newstate","_luaL_loadstring","_lua_pcall"]' \
lapi.c lcode.c ... lstrlib.c ltablib.c linit.c
# 去掉 liolib.c / loslib.c / ldblib.c
# 2. 开启压缩
emcc -O3 -o lua.js --closure 1 -s WASM=1 ...
# 3. 单独压缩 wasm(gzip / brotli)
brotli -q 11 lua.wasm -o lua.wasm.br
体积对照(以 Lua 5.4 为例,量级参考):
| 构建配置 | wasm 大小 | gzip 后 |
|---|---|---|
| 全标准库 | ~350 KB | ~120 KB |
| 去掉 io/os/debug | ~220 KB | ~80 KB |
| 最小核心 + 字符串库 | ~150 KB | ~60 KB |
--closure 1 会用 Google Closure Compiler 压缩 JS 胶水代码,但可能破坏某些动态调用,需回归测试。-Os 优化体积、-O3 优化速度,按场景取舍。
调试与错误定位
wasm 里的 Lua 报错,堆栈信息默认很晦涩。三个手段提升可调试性:
- 编译时保留调试信息:
emcc -g生成 source map,浏览器 DevTools 能映射回 Lua/C 源码; - 捕获 Lua 错误并回传:用
lua_pcall而非lua_call,把错误消息与debug.traceback一并返回宿主; - 宿主侧记录脚本原文:出错时连同源码行号一起落日志。
try {
await lua.doString(source);
} catch (e) {
console.error('Lua 执行失败:', e.message);
// Wasmoon 会在 message 中附带 Lua 的 traceback
}
在 wasm 中,pcall/xpcall 的语义与原生 Lua 完全一致,因此脚本内部的错误处理代码可以原样复用。真正不同的是崩溃的后果:原生 Lua 里 C 扩展的段错误会拖垮整个进程,而 wasm 的越界访问只会触发 trap,宿主能捕获并优雅降级。这正是 wasm 沙箱在"执行不可信脚本"场景的核心价值。
常见问题(FAQ)
浏览器里跑 Lua 一定要用 WebAssembly 吗?
不一定。也有纯 JS 实现的 Lua 解释器(如 fengari),体积更小、无需 wasm 支持。但纯 JS 实现速度明显慢于 wasm 版,且不支持 Lua 5.4 的全部特性。对性能敏感或需要完整标准库的场景,wasm 版更合适。
编译 Lua 到 wasm 会显著增大体积吗?
会。完整标准库的 Lua wasm 模块约 200~400 KB,gzip 后 100 KB 左右。裁剪掉 io、os 库可以减小几十 KB。若只做纯计算,还能进一步精简。
wasm 沙箱真的安全吗?
比进程内沙箱安全得多。wasm 默认无法访问文件、网络、环境变量,只能通过宿主显式注入的能力(WASI 的 --dir、导入函数)与外界交互。但要警惕宿主注入的 API:如果给 Lua 暴露了一个能执行任意命令的 JS 函数,沙箱就形同虚设。安全边界取决于注入面,而非 wasm 本身。
Lua 脚本能调用浏览器的 DOM 吗?
可以,但要通过宿主桥接。Wasmoon 允许把 JS 函数(如 document.querySelector 的包装)注入到 Lua 全局环境,Lua 侧调用这些函数即间接操作 DOM。不能直接访问,因为 wasm 没有 DOM 的概念。
为什么 wasm 版 Lua 的字符串操作比原生慢?
因为每次字符串跨越 Lua 与宿主的边界都要拷贝到线性内存。原生的 Lua 字符串在进程内直接传递,无此开销。减少跨界字符串操作、改用整数或批量传输能缓解这个问题。
生产环境里一个进程该跑多少个 Lua 引擎?
建议按并发量控制在数百个以内,并设置闲置回收。每个 wasm 实例都有独立的线性内存与 Lua state,常驻过多会显著抬高 RSS。更好的做法是用对象池复用引擎,用完 close() 归还,避免频繁实例化的启动开销。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。