1. 从 WebAssembly 到 WASI
WebAssembly 最初的宿主是浏览器,它只有纯计算能力:没有文件、没有网络、没有时钟。要把 wasm 用在服务端、插件系统或边缘计算上,必须给它一套系统调用接口——这就是 WASI(WebAssembly System Interface)。
WASI 的设计目标与 POSIX 不同。POSIX 默认进程拥有一大片能力(能打开任意路径、能发信号、能 fork),靠用户/组权限做粗粒度限制。WASI 走能力安全(capability-based security)路线:模块默认什么都不能做,宿主显式地把「一个目录」「一个 socket」「一个时钟」这类能力句柄传进去,模块只能操作拿到的句柄。
Zig 在这条路线上的位置很特殊:它既能编译成 WASI 模块(-target wasm32-wasi),也能作为宿主编排运行时(通过 C API 嵌入 wasmtime/wasmer)。前一条路径让 Zig 代码跑在沙箱里,后一条让 Zig 程序成为沙箱的宿主。
如果你还没接触过 Zig 的 wasm 基础(导出函数、内存模型、与 JS 互操作),先看 /zig-webassembly/;本文从 WASI 的接口层讲起。
2. 编译目标:wasi 与 freestanding
Zig 的 wasm 目标有两类,混淆它们是新手最常见的错误:
# 1. freestanding:无操作系统,无 WASI。只有 @import("builtin") 与裸内存
zig build-exe src/main.zig -target wasm32-freestanding -fno-entry --export=add
# 2. WASI:带 std.fs / std.io / std.time 等系统调用能力
zig build-exe src/main.zig -target wasm32-wasi -O ReleaseSmall
# 3. WASI + 反应堆模式(无 _start,由宿主调用导出函数)
zig build-exe src/plugin.zig -target wasm32-wasi -fno-entry --export=process
| 目标 | 有 main | 有文件/网络 | 典型用途 |
|---|---|---|---|
wasm32-freestanding | 否 | 否 | 浏览器纯计算、内核模块 |
wasm32-wasi | 是(_start) | 受能力限制 | CLI 工具、边缘函数、插件 |
wasm32-wasi + -fno-entry | 否 | 受能力限制 | 宿主驱动的插件 |
2.1 -fno-entry 与反应堆模式
WASI 默认产出**命令(command)模块,入口是 _start,跑完就退出。但插件场景需要反应堆(reactor)**模式:模块加载后常驻,宿主按需调用导出函数。Zig 用 -fno-entry 关掉 _start 生成:
// plugin.zig —— 反应堆模式,由宿主反复调用
const std = @import("std");
var buf: [4096]u8 = undefined;
export fn alloc(len: u32) [*]u8 {
// 简化示例:真实实现需要真正的分配器与长度校验
_ = len;
return &buf;
}
export fn process(ptr: [*]const u8, len: u32) u32 {
const input = ptr[0..len];
var sum: u32 = 0;
for (input) |b| sum +%= b;
return sum;
}
导出的函数名必须用 export 关键字显式声明,且参数/返回值只能是 wasm 原生类型(i32/i64/f32/f64)。字符串与结构体要自己约定指针 + 长度的 ABI。
3. WASI preview1 的能力模型
preview1(即 wasi_snapshot_preview1)是当前最广泛支持的版本,接口近似 POSIX 但只有约 40 个函数。
3.1 文件描述符与 preopens
模块启动时,宿主传入一组预打开目录(preopens)。它们占据 fd 3、4、5……(0/1/2 是标准输入输出错误)。模块只能用相对路径访问这些目录下的文件,无法 .. 逃逸。
const std = @import("std");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const alloc = arena.allocator();
// 列出宿主预打开的所有目录
var dir = try std.fs.cwd().openDir(".", .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
std.debug.print("{s} {s}\n", .{ @tagName(entry.kind), entry.name });
}
}
在宿主侧(以 wasmtime CLI 为例),preopen 通过 --dir 指定:
wasmtime run --dir /tmp/data::/data app.wasm
# 把宿主 /tmp/data 映射为模块内 /data,模块只能看到 /data
未映射的路径一律不可见,这是 WASI 沙箱的核心。openat 之外没有 open,所有路径解析都相对于某个已授权的目录 fd。
3.2 时钟、随机数与环境变量
preview1 把这些能力也做成显式接口:
| 能力 | preview1 函数 | 是否默认开启 |
|---|---|---|
| 单调时钟 | clock_time_get(MONOTONIC) | 是 |
| 墙钟 | clock_time_get(REALTIME) | 是(宿主可禁) |
| 随机数 | random_get | 是 |
| 环境变量 | environ_get / environ_sizes_get | 由宿主决定 |
| 命令行参数 | args_get / args_sizes_get | 由宿主决定 |
| 退出码 | proc_exit | 是 |
在 Zig 里 std.time.Instant.now()、std.crypto.random、std.process.argsAlloc() 都会走这些接口。如果宿主没提供环境变量能力,argsAlloc 会返回空切片而非报错——这是容易踩的坑。
3.3 errno 映射与 Zig 错误集
preview1 的所有函数返回 wasi_errno_t(一个 u16),而不是 POSIX 的 -1 + errno。Zig 标准库在 std.os.wasi 里做了一层映射,把 __WASI_ERRNO_NOENT 转成 error.FileNotFound 这类 Zig 错误:
const wasi = std.os.wasi;
pub fn mapErrno(e: wasi.errno_t) !void {
return switch (e) {
.SUCCESS => {},
.NOENT => error.FileNotFound,
.ACCES => error.AccessDenied,
.EXIST => error.PathAlreadyExists,
.NOTDIR => error.NotDir,
.ISDIR => error.IsDir,
.NOSPC => error.NoSpaceLeft,
.BADF => error.NotOpenForReading,
else => |x| {
std.log.err("unmapped wasi errno: {d}", .{@intFromEnum(x)});
return error.Unexpected;
},
};
}
调试 WASI 程序时,如果看到 error.Unexpected 而非具体的 FileNotFound,往往是 errno 表里缺了这一项——Zig 只映射了它自己会用到的子集。此时直接在 std.os.wasi.errno_t 上打印枚举值即可定位。
另一个坑是 fd_read 的部分读语义:它可能返回比请求更少的字节且不报错。Zig 的 std.fs.File.read 会循环调用直到读满或遇到 EOF,但你自己直接调 wasi.fd_read 时务必自己处理循环。
4. preview2 与组件模型
preview1 有两个硬伤:接口是扁平的 C 风格函数(传指针 + 长度),且无法组合(两个 wasm 模块之间不能直接调用,必须绕宿主)。**组件模型(Component Model)**就是为解决这两点设计的。
4.1 WIT:接口定义语言
组件模型用 WIT(WebAssembly Interface Types) 描述接口,有强类型、有 record/variant/list/option/result:
// 一个文件处理组件的接口定义
package example:fileproc@0.1.0;
interface types {
record stat {
size: u64,
modified-ns: u64,
}
variant error {
not-found,
permission-denied,
io(string),
}
}
interface processor {
use types.{stat, error};
// 读取并统计行数
count-lines: func(path: string) -> result<u64, error>;
stat-file: func(path: string) -> result<stat, error>;
}
world file-plugin {
export processor;
import wasi:filesystem/preopens@0.2.0;
import wasi:clocks/monotonic-clock@0.2.0;
}
world 描述一个组件的完整依赖:导入什么、导出什么。
4.2 模块(Module)与组件(Component)的区别
| 维度 | 模块(Module) | 组件(Component) |
|---|---|---|
| 类型系统 | 只有 i32/i64/f32/f64 | 强类型 + 复合类型 |
| 接口 | 裸函数索引 | 命名接口 + 版本 |
| 组合 | 不可直接组合 | 可链接(wasm-tools compose) |
| 格式 | \0asm + 版本 1 | 分层的组件二进制格式 |
| 加载 | 所有运行时 | 需支持组件模型的运行时 |
4.3 Canonical ABI
组件之间传的是「字符串」「列表」这类高层值,但底层 wasm 只能传 i32。Canonical ABI 定义了它们之间的转换规则:字符串在内存里表示为一个 8 字节的 (ptr, len) 对,通过一个被称为**线性内存重分配(realloc)**的回调在组件边界传递所有权。
caller 侧(lower):
string "hello" → (ptr=1024, len=5) 写入 caller 内存
→ 调用被调组件的 realloc(len=5) 得到目标内存 ptr'
→ 把字节拷过去
→ 传 (ptr', 5) 给被调组件
callee 侧(lift):
收到 (ptr, len) → 从自己的内存读出来 → 构造 Zig 的 []const u8
这套机制的收益是:跨语言无需手写序列化。用 WIT 生成绑定后,Rust 组件导出 count-lines(string) -> result<u64, error>,Zig 宿主就能直接调用,反之亦然。
5. 宿主嵌入:在 Zig 里跑 wasm
5.1 运行时选择
| 运行时 | 语言 | 组件模型 | 特点 |
|---|---|---|---|
| wasmtime | Rust | 完整支持 | 生产首选,C API 稳定 |
| wasmer | Rust | 部分支持 | 多后端(LLVM/Cranelift) |
| wasm3 | C | 不支持 | 极小,解释执行 |
| wazero | Go | 支持 | 纯 Go,无 cgo |
| WAMR | C | 支持 | 面向嵌入式 |
Zig 宿主最省事的路径是 wasmtime 的 C API——@cImport 直接吃头文件,链接静态库。
5.2 用 C API 嵌入 wasmtime
const std = @import("std");
const c = @cImport({
@cInclude("wasmtime.h");
});
pub fn runModule(path: []const u8) !void {
var engine = c.wasm_engine_new();
defer c.wasm_engine_delete(engine);
var store = c.wasmtime_store_new(engine, null, null);
defer c.wasmtime_store_delete(store);
const ctx = c.wasmtime_store_context(store);
// 从磁盘读取 wasm 模块
const bytes = try std.fs.cwd().readFileAlloc(std.heap.page_allocator, path, 64 << 20);
defer std.heap.page_allocator.free(bytes);
var module = c.wasmtime_module_new(
engine,
bytes.ptr,
bytes.len,
@ptrCast(&@as(?*c.wasmtime_module_t, null).?),
) orelse return error.ModuleCompileFailed;
defer c.wasmtime_module_delete(module);
// 定义 WASI 环境
var wasi = c.wasi_config_new();
defer c.wasi_config_delete(wasi);
c.wasi_config_inherit_argv(wasi);
c.wasi_config_inherit_stdout(wasi);
c.wasi_config_inherit_stderr(wasi);
_ = c.wasi_config_preopen_dir(wasi, "/tmp/sandbox", "/data");
var linker = c.wasmtime_linker_new(engine);
defer c.wasmtime_linker_delete(linker);
_ = c.wasmtime_linker_define_wasi(linker);
var trap: ?*c.wasm_trap_t = null;
var instance = c.wasmtime_linker_instantiate(linker, ctx, module, &trap) orelse {
return error.InstantiateFailed;
};
defer c.wasmtime_instance_delete(instance);
// 调用 _start
const start = c.wasmtime_instance_export_get(ctx, instance, "_start", 6) orelse
return error.NoStartExport;
var results: [1]c.wasmtime_val_t = undefined;
var caught: ?*c.wasm_trap_t = null;
if (c.wasmtime_func_call(ctx, @ptrCast(start), null, 0, &results, 0, &caught) == 0) {
return error.Trap;
}
}
wasmtime_linker_define_wasi 一行就把整套 WASI 接口注入进去——这就是 preview1 的「一键能力包」。
5.3 资源限制
沙箱不限制资源就只是摆设。wasmtime 提供三类限制:
// 1. 线性内存上限(模块申请超过即失败)
var limits = c.wasmtime_store_limiter;
_ = c.wasmtime_store_limiter(
store,
c.WASMTIME_STORE_LIMITER_MEMORY_SIZE,
64 * 1024 * 1024,
);
- 内存上限:
wasmtime_store_limiter设MEMORY_SIZE,超限时内存增长指令失败。 - CPU 配额:开启 fuel 计量,每执行一条指令扣 1,耗尽即 trap。
const config = c.wasmtime_config_new();
c.wasmtime_config_consume_fuel_set(config, true);
// 调用前注入配额
_ = c.wasmtime_context_set_fuel(ctx, 10_000_000);
// 执行后查询剩余
var remaining: u64 = 0;
_ = c.wasmtime_context_get_fuel(ctx, &remaining);
- 墙钟超时:用 epoch interruption,宿主在另一个线程周期性
wasmtime_context_set_epoch_deadline,模块执行到检查点即中断。
// 每隔 10ms 递增 epoch,模块最多活 100 个 epoch(约 1 秒)
c.wasmtime_context_set_epoch_deadline(ctx, 100);
三种机制互补:fuel 精确但拖慢执行(约 20~50% 开销),epoch 便宜但精度到毫秒级,内存上限是硬约束。
5.4 自定义宿主函数
真实插件系统不可能只靠 WASI。宿主需要向模块暴露自己的 API,比如「查询当前租户 ID」「写审计日志」。做法是定义一个 wasmtime_func_t,把它注册到 linker 上:
const HostCtx = struct {
tenant_id: u64,
log_count: u64 = 0,
};
fn hostLog(caller: ?*c.wasmtime_caller_t, args: [*]const c.wasmtime_val_t, nargs: usize, results: [*]c.wasmtime_val_t, nresults: usize) callconv(.c) ?*c.wasm_trap_t {
_ = results;
_ = nresults;
if (nargs < 2) return null;
const ctx = c.wasmtime_caller_context(caller.?).?;
const hc: *HostCtx = @ptrCast(@alignCast(ctx));
hc.log_count += 1;
// args[0] = (ptr, len) 指向模块线性内存中的 UTF-8 字符串
const ptr: u32 = @intCast(args[0].of.i32);
const len: u32 = @intCast(args[1].of.i32);
std.log.info("plugin[{d}] log @{d}+{d}", .{ hc.tenant_id, ptr, len });
return null; // 返回 null 表示无 trap
}
pub fn registerHost(linker: *c.wasmtime_linker_t) !void {
var ftype = c.wasm_functype_new_2_0(
c.wasm_valtype_new(c.WASM_I32),
c.wasm_valtype_new(c.WASM_I32),
);
defer c.wasm_functype_delete(ftype);
var func = c.wasmtime_func_new_unchecked(null, ftype, hostLog, null, null);
defer c.wasmtime_func_delete(func);
_ = c.wasmtime_linker_define(linker, "host", 4, "log", 3, func);
}
注意 wasmtime_func_new_unchecked 传 null 作为 context——宿主上下文通过 wasmtime_caller_context 从 caller 取,而不是在创建函数时绑定,这样同一个函数实例可以服务多个 store。
如果模块要调用宿主函数,Zig 侧要声明对应的 extern:
extern "host" fn log(ptr: [*]const u8, len: u32) void;
pub fn audit(msg: []const u8) void {
log(msg.ptr, @intCast(msg.len));
}
extern "host" 里的模块名必须与 wasmtime_linker_define 的模块名逐字一致,否则实例化时直接报 unknown import。
6. 沙箱安全实践
- 最小 preopen:只映射必需的目录,且尽量只读。wasmtime 支持
--dir host::guest与只读标志。 - 禁用网络:preview1 本身没有 socket 接口,除非宿主显式提供,否则模块无法联网。preview2 有
wasi:sockets,要显式导入。 - 限制 stdout 体积:模块可以向 stdout 写无限数据耗尽宿主磁盘,宿主应包一层限流 writer。
- 校验模块:wasm 字节码是自校验的,但组件模型允许导入自定义段,加载前应检查导入列表是否在白名单内。
- 隔离实例:每次调用用全新的
store,避免跨请求的状态泄漏。
Zig 的 std.heap.GeneralPurposeAllocator 在宿主侧配合 .safety = true,能在宿主与模块边界的内存管理出错时立即报错——这在开发沙箱宿主时价值极高。
7. 典型场景
7.1 插件系统
把用户插件编译成 wasm,宿主用 wasmtime 加载。相比动态库(.so),wasm 插件天然内存隔离、无符号冲突、跨平台。如果你已经在用 dlopen 做插件,/zig-plugin-dynamic-loading/ 里讨论了它的 ABI 稳定性问题,wasm 正好绕开这些坑。
7.2 边缘函数
Cloudflare Workers、Fastly Compute 都用 wasm 做多租户隔离。宿主按请求起一个实例,用 fuel/epoch 限制执行时间,preopen 只给只读的静态资源目录。
7.3 数据管道 UDF
把用户自定义函数编译成组件,宿主(可能是 Rust 或 Zig)通过 WIT 生成的绑定直接调用。组件模型让 UDF 可以用任何语言写,宿主不必为每种语言做绑定。
小结
WASI 与组件模型给 wasm 补上了「系统能力」与「类型化组合」两块拼图。Zig 的双重身份让它在两端都有用武之地:
-target wasm32-wasi产出沙箱模块,-fno-entry产出反应堆插件。- preview1 靠 preopens 做能力隔离,未映射的路径不可见。
- preview2 用 WIT 定义强类型接口,Canonical ABI 负责跨边界的内存转换。
- 宿主嵌入用 wasmtime C API,
wasmtime_linker_define_wasi一行注入 WASI。 - 内存上限 + fuel + epoch 三重限制缺一不可。
想进一步理解 wasm 的二进制格式与线性内存模型,参见 WASI 文件系统沙箱的实现 ;接口定义的完整语法可对照 组件模型与 WIT 接口定义 。
Zig 的价值在于:它既是编译到 wasm 的高效源语言,也是实现宿主的合适语言——两端都是显式内存、零隐藏分配。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。