Zig WASI 与组件模型:沙箱运行时与宿主嵌入

WASI 给 WebAssembly 补上了系统调用接口,组件模型又在其上定义了强类型、可组合的接口。本文讲清 wasm32-wasi 与 freestanding 的差别、preview1 的能力模型与 preopens、preview2 的 WIT 与 Canonical ABI,并演示在 Zig 宿主里嵌入 wasmtime 与做资源限制。

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 运行时选择

运行时语言组件模型特点
wasmtimeRust完整支持生产首选,C API 稳定
wasmerRust部分支持多后端(LLVM/Cranelift)
wasm3C不支持极小,解释执行
wazeroGo支持纯 Go,无 cgo
WAMRC支持面向嵌入式

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 的双重身份让它在两端都有用武之地:

  1. -target wasm32-wasi 产出沙箱模块,-fno-entry 产出反应堆插件。
  2. preview1 靠 preopens 做能力隔离,未映射的路径不可见。
  3. preview2 用 WIT 定义强类型接口,Canonical ABI 负责跨边界的内存转换。
  4. 宿主嵌入用 wasmtime C API,wasmtime_linker_define_wasi 一行注入 WASI。
  5. 内存上限 + fuel + epoch 三重限制缺一不可。

想进一步理解 wasm 的二进制格式与线性内存模型,参见 WASI 文件系统沙箱的实现 ;接口定义的完整语法可对照 组件模型与 WIT 接口定义 。

Zig 的价值在于:它既是编译到 wasm 的高效源语言,也是实现宿主的合适语言——两端都是显式内存、零隐藏分配。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「系统编程」更多文章

  1. Zig 终端 TUI 开发:终端控制、布局与交互
  2. Zig 打包与分发:容器镜像、系统包与 Homebrew
  3. Zig GPU 计算:Vulkan Compute 与着色器绑定