Lua 与 WebAssembly 互操作

讲解 Lua 与 WebAssembly 的互操作实践:用 Emscripten 把 Lua 编译成 wasm、借助 Wasmoon 在浏览器运行 Lua、通过 WASI 在服务端沙箱执行脚本,并剖析内存模型、JS 数据桥接、性能取舍与插件沙箱的工程落地。

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 编译为 wasmLua 代码在 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 二进制格式与内存模型 。

典型的字符串传递过程:

  1. 宿主把 JS 字符串编码为 UTF-8 字节,写入线性内存的某段空闲区;
  2. 调用 lua_pushlstring(L, ptr, len),Lua 从这段内存复制字节建立字符串对象;
  3. Lua 侧处理完,把结果字符串的指针与长度通过 C API 取出;
  4. 宿主从线性内存读回字节,解码为 JS 字符串。

每一趟都有一次内存拷贝,这是跨边界的固有成本。减少拷贝的手段包括:批量传数据(一次传一个数组而非逐元素传)、复用缓冲区、用整数传递简单值。

对于大块数据(如二进制图片、音频),更高效的做法是共享内存:宿主把数据写入线性内存,Lua 通过 string 或 FFI 直接读指针,避免二次拷贝。但要注意 wasm 内存增长后基址会变,缓存的指针会失效,必须在每次可能触发内存增长的操作后重新取址。

性能取舍与适用边界

把 Lua 编译成 wasm 不是免费的午餐,性能特征与原生 Lua 有差异:

维度原生 LuaLua-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 用作插件系统,是它最有价值的落地形态。核心设计有三点:

  1. 能力注入:宿主只暴露必要的 API(如 log、http_get),不暴露文件与进程;
  2. 资源限额:限制内存、执行时间与输出大小;
  3. 状态隔离:每个插件一个独立的 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 报错,堆栈信息默认很晦涩。三个手段提升可调试性:

  1. 编译时保留调试信息:emcc -g 生成 source map,浏览器 DevTools 能映射回 Lua/C 源码;
  2. 捕获 Lua 错误并回传:用 lua_pcall 而非 lua_call,把错误消息与 debug.traceback 一并返回宿主;
  3. 宿主侧记录脚本原文:出错时连同源码行号一起落日志。
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() 归还,避免频繁实例化的启动开销。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 时间日期处理与时区
  2. Lua 在嵌入式与 IoT 中的开发实践
  3. Lua 数值计算与位运算技巧