从零编写现代编译器 28 - 用 WIT 封装组件接口
第 27 篇把 Sprout 的标量子集编译成了一个独立的 .wasm 核心模块。模块能在 Wasmtime 里跑起来,但它的边界只有导出函数的数字签名——调用者必须自己知道哪个参数是长度、哪个是指针偏移、返回值代表什么。换句话说,核心模块没有类型化的接口描述,就像 C 的 .o 文件没有头文件。
WIT(WebAssembly Interface Types)正是给 Wasm 组件补上的这层头文件。它用一种独立的接口定义语言描述组件的导入和导出:函数签名、参数类型、返回类型、记录、枚举、列表。两个组件通过 WIT 约定的接口互相调用,运行时在边界处自动完成类型验证和数据格式转换。组件之间不共享线性内存,也不需要知道对方的内部布局。
这篇要做的事情:给 Sprout 的统计函数写一份 WIT 接口定义,用工具把核心模块封装成组件,再把它和一个提供文件读取能力的宿主组件接到一起。
WIT 的基本结构
第 27 篇使用 wasm32-wasi(WASI preview 1)编译核心模块。本篇切换到 wasm32-wasip2(WASI preview 2),因为组件模型是 WASI preview 2 的核心特性——preview 1 只有核心模块,没有类型化的组件边界。
一份 WIT 文件由 package、interface 和 world 三层组成。
在使用列表接口之前,我们先用一个最简单的标量接口验证组件化流程:
1 | |
这个接口不涉及线性内存布局——两个 s64 参数直接映射为 Wasm 的 i64,返回值同理。走通这个最小例子后,再扩展到 list<s64> 时就能清楚地看到 Canonical ABI 为复杂类型增加了什么。
下面看一个使用列表类型的完整例子:
1 | |
package 声明命名空间和版本号,格式是 namespace:name@semver。interface 块定义一组带类型签名的函数。world 把多个 interface 组合在一起,用 import 和 export 区分组件需要什么、提供什么。
WIT 的类型系统比核心 Wasm 丰富得多。核心 Wasm 只认识 i32、i64、f32、f64;WIT 增加了 string、list<T>、option<T>、result<T, E>、record、enum、variant、tuple、flags 等。这些高级类型最终会被拆解成核心 Wasm 能处理的整数和内存操作——这个拆解过程叫 Canonical ABI。
WIT 的语法参考见 Component Model 官方文档。注意 WIT 中的标识符用 kebab-case(如 read-numbers),不用下划线或驼峰。
组件模型:核心模块加类型外壳
核心 Wasm 模块(.wasm)只有低级类型签名。Wasm 组件在核心模块外面包了一层类型信息,这层信息来自 WIT 定义。组件的结构大致是:
1 | |
关键区别:核心模块的 _sort 接受内存指针和长度,返回指针和长度;组件的 sort 接受 list<s64>,返回 list<s64>。Canonical ABI adapter 负责在两者之间转换——把宿主传来的列表数据复制到核心模块的线性内存,把核心模块输出的内存区域包装成列表交还宿主。
组件之间不共享线性内存。每个组件拥有自己的内存空间,数据通过 Canonical ABI 在边界处复制。这消除了一类安全问题:一个组件不能意外或恶意地修改另一个组件的内部状态。
这种隔离在传统的动态链接库中是做不到的。共享库(.so/.dll)和主程序共享地址空间,一个越界写入就能破坏另一个库的数据结构。组件模型从架构层面阻止了这种情况。代价是边界处的数据复制——每次跨组件调用传递列表时,数据会被序列化到调用者的线性内存中再反序列化到被调用者的线性内存中。对于大量数据的高频传递,这个开销不可忽略。但对 Sprout 的统计场景(一次性传入一个数组,计算后返回结果),复制成本远低于计算本身。
Canonical ABI 的转换规则是确定性的,不依赖运行时协商。编译时就能确定每种类型在线性内存中的布局:s64 占 8 字节,list<s64> 用一个指针加一个长度表示,string 是 UTF-8 字节序列加长度,record 的字段按声明顺序排列并遵循对齐规则。这些规则在组件模型规范中有完整定义,工具链(wit-bindgen、wasm-tools)替你处理所有细节。开发者需要理解的核心概念只有一个:WIT 类型是语义层面的类型,核心 Wasm 类型是机器层面的类型,Canonical ABI 是两者之间的确定性映射。掌握了这三层关系,组件模型的大部分行为就能预测了。后续如果需要调试边界处的数据不一致问题,也知道该从哪一层入手排查。
定义 Sprout 统计组件的接口
在项目中创建 wit/ 目录,放入上面的 WIT 定义:
1 | |
stats.wit 的内容就是前面给出的那份。world stats-app 说的是:这个组件需要外部提供 file-io 接口中的 read-numbers 函数,自身导出 statistics 接口中的四个统计函数。
这份 WIT 做了一个设计选择:统计函数接受 list<s64> 而不是文件路径。读文件的职责交给宿主通过 file-io 提供。这样 Sprout 组件本身是纯计算的——它不需要文件系统权限,只操作传入的数据。权限最小化是组件模型的一个核心设计目标。
从 WIT 生成 Rust 胶水代码
有了 WIT 定义,下一步是生成 Rust 代码,把 Sprout 编译出来的核心 Wasm 函数包装成符合 WIT 签名的组件导出。这里使用 wit-bindgen 工具。
在 Cargo.toml 中加入依赖:
1 | |
本篇示例使用 wit-bindgen = "0.36.0"(2024-11-27 发布)。wit-bindgen 的 API 在版本间变化较大,建议读者使用与所安装 wasmtime 版本匹配的 wit-bindgen 版本。最新版本号见 crates.io。
在 Rust 源码中使用 generate! 宏:
1 | |
generate! 宏读取 WIT 文件,生成 Guest trait,里面的方法签名直接对应 WIT 中 statistics 接口的函数。实现者填入具体逻辑,export! 宏生成 Canonical ABI 所需的低级导出函数。
生成的底层代码大致做这些事情:
- Lifting(提升):把核心模块的
(ptr, len)返回值读出线性内存,构造成list<s64>。 - Lowering(降低):把调用者传来的
list<s64>写入核心模块的线性内存,转换成(ptr, len)参数。 - 内存管理:调用核心模块导出的
cabi_realloc函数分配和释放中间缓冲区。
编译目标仍然是 wasm32-wasip2:
1 | |
产物是一个 Wasm 组件文件,不再是普通的核心模块。可以用 wasm-tools component wit 检查它的类型信息:
1 | |
输出应该能看到 statistics 接口的四个函数签名和 file-io 的导入声明。如果类型信息缺失,说明 generate! 宏没有正确处理,或者编译目标不对。
组合组件:宿主提供文件读取
Sprout 组件导入了 file-io,需要有人提供这个能力。在 Wasmtime 的 Rust 嵌入 API 中,宿主程序可以在组件链接阶段注入导入函数:
1 | |
注意 linker.instance("file-io") 和 func_wrap("read-numbers", ...) 的名字必须和 WIT 中的 interface 名、函数名完全匹配。类型也必须匹配——如果 WIT 写的是 result<list<s64>, string>,宿主函数的返回类型就必须是 Result<Vec<i64>, String>。Wasmtime 在实例化时验证这些类型,不匹配就报错。
类型安全的边界
组件模型在边界处做的类型检查值得展开说明。
假设 WIT 定义 sort 接受 list<s64>,但调用方试图传入一组浮点数。在核心 Wasm 的世界里,这种错误不会被拦截——内存里的字节按什么类型解释完全取决于调用约定。但在组件模型中,Canonical ABI 会在数据复制到目标组件之前验证类型。传入的每个元素必须是合法的 s64,否则实例化或调用阶段就会失败。
再看一个更微妙的场景。read-numbers 返回 result<list<s64>, string>。如果宿主实现返回了 Ok 变体但里面的列表包含无效数据(比如超出 s64 范围),Canonical ABI 同样会拦截。组件模型不信任任何一方——即使数据来自宿主,也要在进入目标组件前经过验证。
这种边界检查的代价是数据复制。每次跨组件调用,列表数据都要从源组件的线性内存复制到目标组件的线性内存。对于大数组来说,这是可以观察到的开销。未来的组件模型规范可能引入共享内存或引用传递的优化,但当前版本的设计选择是安全优先。
测试:用 mock 组件验证端到端流程
不依赖真实文件系统来测试 Sprout 组件。写一个 mock 宿主,它的 read-numbers 返回硬编码数据:
1 | |
如果 mock 故意返回 Err("not found"),Sprout 组件内部的逻辑需要处理这个错误变体。这也是 result 类型存在的意义——它把错误路径写进了接口契约。
再做一个故意类型不匹配的实验。把 WIT 改成 list<s32> 但宿主仍然传 Vec<i64>,Wasmtime 在链接阶段就会报类型不匹配的错误,不会进入运行时。
如果实例化组件时不提供必需的导入,Wasmtime 会明确报错:
1 | |
这正是组件模型的类型安全保证——与核心 Wasm 模块在运行时才发现缺少导入不同,组件在实例化阶段就拒绝不完整的组合。
工具链命令汇总
从核心模块到组件的完整流程:
1 | |
其中 wasm-tools 来自 bytecodealliance/wasm-tools,是检查和操作 Wasm 组件的标准工具。如果第 3 步输出中看不到 statistics 接口,说明组件封装没有正确完成。
练习
-
写一份 WIT 接口定义,为一个
calculator组件导出add、sub、mul、div四个函数,每个接受两个s64参数并返回s64。div的返回类型改为result<s64, string>以处理除零。用 Sprout 编译的算术运算实现这个组件,再写一个宿主程序调用它,打印add(10, 20)、div(10, 0)的结果。 -
在 Sprout 统计组件的 WIT 中增加一个
record:1
2
3
4
5
6
7
8record summary {
sorted: list<s64>,
min: s64,
max: s64,
mean: s64,
}
compute-all: func(data: list<s64>) -> summary;实现
compute-all,让宿主只调用一次就拿到全部统计结果。观察record在 Canonical ABI 中如何被展平成多个核心 Wasm 参数。 -
用
wasm-tools component wit对比第 27 篇的核心模块和本篇的组件文件。核心模块是否输出了任何 WIT 信息?这个差异说明了核心模块和组件的本质区别。
