从零编写现代编译器 27 - 编译到 WebAssembly
前 26 篇编译出来的都是本机二进制。它在 Linux x86-64 上跑得好好的,拿到 ARM macOS 就得重新编译,拿到浏览器里更是无从执行。WebAssembly 提供了另一条路:编译一次,生成一个 .wasm 模块,任何带 Wasm 运行时的平台都能直接运行。这篇给 sproutc 加一个 wasm32-wasi 目标,让同一份 Sprout 源码产出可移植的 Wasm 模块。
WebAssembly 是什么
WebAssembly(缩写 Wasm)是一种二进制指令格式,设计目标是安全、快速、可移植。它不绑定任何特定操作系统或处理器架构。一个 .wasm 文件是一个模块,包含函数定义、线性内存、类型签名和导入导出声明。
几个关键特征:
栈机器。Wasm 的执行模型是栈机器,不是寄存器机器。每条指令从栈上消费操作数,把结果压回栈。i64.add 弹出两个 i64,压入一个 i64。这和 x86 的寄存器模型不同,但 LLVM 后端会处理这层转换。
类型化函数。每个函数都有精确的参数和返回值类型签名。调用时参数数目和类型必须匹配。这在模块加载阶段就能验证,不需要等到运行时。
线性内存。Wasm 模块拥有一块连续的字节数组,叫线性内存。程序通过偏移量读写这块内存。没有硬件级的页保护和虚拟地址,但运行时会检查越界访问。
两种格式。文本格式 WAT(WebAssembly Text)用 S 表达式表示,方便人读;二进制格式 WASM 用于实际分发和加载。它们是同一种语义的两种编码。以下是一个把两个整数相加的函数的 WAT 表示:
1 | |
对应的二进制格式是紧凑的字节流,包含 Section header、类型段、函数段、代码段等结构。工具 wasm2wat 和 wat2wasm 在两种格式之间转换。Wasm 规范见 WebAssembly Specification。
WASI:Wasm 怎样做 I/O
纯粹的 Wasm 规范只定义计算。模块不能直接访问文件、网络或标准输出——这些能力需要宿主环境提供。浏览器里,JavaScript 充当宿主;在服务端和命令行场景,需要一套标准化的系统接口。
WASI(WebAssembly System Interface)就是这套接口。WASI preview 1 提供了文件描述符操作、时钟、随机数等基础系统调用。Sprout 的 print_i64 需要向标准输出写字节,对应的 WASI 函数是 fd_write。
在本机目标上,print_i64 的运行时实现调用 libc 的 write 或 printf。在 Wasm 目标上,运行时改为调用 WASI 的 fd_write。具体做法是在 Wasm 模块中声明一个导入:
1 | |
fd_write 接受文件描述符(stdout 是 1)、一个 iovec 数组的指针、iovec 数量、以及一个用来写入实际写出字节数的指针。运行时的 print_i64 实现需要先把整数转成十进制 ASCII 字符串,写入线性内存,再构造 iovec 结构并调用 fd_write。
这意味着运行时库需要为 Wasm 目标提供一个专门的实现。逻辑相同——整数转字符串、写输出——但底层调用从 POSIX write 换成了 WASI fd_write。WASI 规范见 WASI 官方仓库。
LLVM 的 wasm32-wasi 目标
好消息是,LLVM 已经有成熟的 WebAssembly 后端。我们不需要自己实现指令选择或栈机器转换。只需要做两件事:设置正确的目标三元组,提供对应的运行时。
把 target triple 设为 wasm32-wasi,data layout 设为 Wasm32 对应的字符串:
1 | |
这个 data layout 说明:小端序(e),指针 32 位,i64 对齐到 64 位,原生整数宽度 32 和 64。p10 是 externref(外部引用)、p20 是 funcref(函数引用)的地址空间,各 8 位。两者都是非整型指针(属于 ni:1:10:20 声明),不能与整数互相转换。
同一份 LLVM IR——前端生成的函数定义、控制流、算术运算——换上这个 triple 和 data layout 之后,LLVM 的 Wasm 后端就会生成 .wasm 文件。SSA 的 phi 节点、基本块结构、函数调用约定,全部由后端处理。
实现上,sproutc 新增一个 --target 参数。当目标是 wasm32-wasi 时:
- LLVM IR 的 target triple 和 data layout 切换到上述值。
i64类型保持不变。Wasm 原生支持i64运算,不需要拆成两个 32 位。- 运行时库替换为 Wasm 版本,
print_i64通过 WASIfd_write输出。 - 不调用系统链接器。LLVM 直接输出
.wasm文件,或者通过wasm-ld链接多个对象。
1 | |
结构化控制流
WebAssembly 与传统机器码的一个根本区别是:它只支持结构化控制流。Wasm 没有任意 goto,只有 block、loop、if、br、br_if、br_table 这些结构化的控制流指令。
这对编译器意味着什么?我们的 SSA IR(第 12 篇)使用的是 CFG(控制流图),其中的跳转可以指向任意基本块。大多数 CFG 是可归约的(reducible)——即可以用嵌套的 if/while 表达。但理论上 CFG 可以包含不可归约的控制流(irreducible control flow),例如两个循环共享入口。
LLVM 的 Wasm 后端内部使用一种叫做 stackifier 的算法,将 CFG 转换为 Wasm 的结构化形式。对于可归约 CFG,stackifier 直接将循环映射为 loop/br,将条件分支映射为 block/br_if。对于不可归约 CFG,stackifier 会插入辅助变量将其转换为可归约形式(代价是运行时多一次分支判断)。
Sprout 的控制流(if/else、while 循环)天然是结构化的,因此 stackifier 不会遇到不可归约的情况。但如果未来扩展语言支持 goto 或多入口循环,就需要关注这个问题。
符号除法与运行时错误映射
Wasm 的 i64.div_s(符号除法)在两种情况下会触发 trap:除零和溢出(INT64_MIN / -1,因为结果无法用 i64 表示)。这与本机 x86-64 的行为不同——x86 的 idiv 对 INT64_MIN / -1 会触发硬件异常,但某些平台可能有不同行为。
在第 14 篇的 LLVM 后端中,Sprout 在除法前插入显式检查(先检查除零,再 sdiv)。对 Wasm 目标,可以选择两种策略:
- 保留显式检查:与本机后端一致,在 LLVM IR 层面插入检查。好处是错误消息可以区分除零和溢出。
- 依赖 Wasm trap:去掉显式检查,让 Wasm 的内置 trap 处理。好处是代码更小,但 trap 消息不受编译器控制。
Sprout 选择保留显式检查,以获得一致的跨平台错误行为。同理,数组越界检查(第 15 篇)也保持显式,不依赖 Wasm 的 memory.size 边界检查。
内存模型差异
本机目标上,程序有硬件栈。函数调用时 call 指令压返回地址,rsp 自动移动,局部变量分配在栈帧里。Wasm 没有硬件栈。
Wasm 的执行栈是隐式的——运行时维护操作数栈和调用栈,程序不能直接访问它们的地址。但 LLVM 生成的代码经常需要取局部变量的地址(alloca 产生的指针)。为了处理这种情况,LLVM 的 Wasm 后端在线性内存里维护一个影子栈(shadow stack)。
影子栈的原理:线性内存的某段区域被当作栈使用。一个全局变量 __stack_pointer 记录当前栈顶。函数入口处减小栈指针来分配局部空间,出口处恢复。这和本机栈的行为类似,但完全在线性内存中进行,由 LLVM 生成的指令显式管理。
1 | |
对 sproutc 来说,这个差异是透明的。LLVM IR 层面的 alloca 和 load/store 不变,后端负责把它们映射到线性内存操作。但需要注意:Wasm 的线性内存默认从 0 开始,初始大小和最大大小在模块声明中指定。如果程序使用的栈加堆超过了线性内存的大小,运行时会触发 out-of-memory。
编译和运行
编译命令加上 --target 参数:
1 | |
sproutc 检测到目标是 wasm32-wasi,切换 IR 的 triple 和 data layout,链接 Wasm 版运行时,输出 .wasm 文件。可以用 wasm2wat 检查生成的文本格式:
1 | |
运行用 wasmtime。wasmtime 是 Bytecode Alliance 维护的 Wasm 运行时,支持 WASI preview 1。它接收 .wasm 文件,提供 WASI 导入函数,执行模块的 _start 入口:
1 | |
输出和本机编译版本一致。wasmtime 在执行前会验证模块的类型安全性——函数签名匹配、栈操作平衡、内存访问在边界内。如果模块格式有误,验证阶段就会报错,不会进入执行。
wasmtime 的安装和版本见 Wasmtime 官方文档。本篇使用 wasmtime 26.0 运行 Wasm 模块。LLVM 的 wasm32-wasi 目标从 LLVM 15 起稳定支持。
测试
测试策略和本机目标相同:运行程序,比较输出。差异在于执行方式——本机目标直接执行二进制,Wasm 目标通过 wasmtime 执行。
测试框架新增一个参数来选择执行后端:
1 | |
对每个测试用例,编译到 Wasm,用 wasmtime 运行,检查 stdout 和退出码。预期结果和本机版本一致——同一份语义规范,同样的输入,相同的输出。
需要注意退出码的映射。WASI 的 proc_exit 对应程序的退出状态。Sprout 的 main 返回 i64,运行时包装把它截断为退出码。wasmtime 会把 proc_exit 的参数传递给宿主进程的退出状态。
差分测试在这里特别有价值:同一份源码编译到本机和 Wasm 两个目标,输出必须一致。如果不一致,说明某个目标的代码生成或运行时有问题。第 21 篇建立的差分测试框架可以直接扩展到这个场景。
局限
Wasm 和 WASI preview 1 的能力边界比本机环境窄得多:
- 没有直接系统调用。所有 I/O 必须通过 WASI 导入函数。Sprout 的运行时只用到
fd_write和proc_exit,这两个在 preview 1 里都有。 - 没有线程。WASI preview 1 不提供线程创建。Sprout 主线不涉及多线程,所以这个限制不影响当前功能。
- 没有信号处理。本机程序可以注册 signal handler,Wasm 模块不行。Sprout 的运行时错误(除零、越界)在本机通过 trap 或显式检查处理;在 Wasm 中,越界内存访问会触发 Wasm trap,由运行时捕获并终止执行。
- 32 位地址空间。
wasm32的指针是 32 位,线性内存最大 4 GiB。对 Sprout 的教学程序来说绰绰有余。 - 性能差异。Wasm 运行时通常比本机执行慢。wasmtime 使用 Cranelift 做 JIT 编译,启动有额外开销。对正确性验证来说,这不是问题。
这些限制对 Sprout 当前的功能范围没有实际影响。Sprout 的标量计算、控制流、函数调用和基本 I/O 都能在 WASI preview 1 上正常工作。
从 IR 到 Wasm 的翻译路径
回顾完整的翻译路径。同一份 Sprout 源码经过词法、语法、类型检查、HIR、SSA,到达 LLVM IR 生成阶段。在这个阶段,唯一的分支点是 target triple 和运行时选择:
1 | |
从前端到 SSA,完全共享。LLVM IR 层面只有 triple、data layout 和运行时函数实现不同。LLVM 后端负责从同一套 IR 生成不同目标的代码。这正是使用 LLVM 的回报——新增一个目标平台的工作量集中在运行时适配和编译驱动,不需要重写代码生成。
用 --emit=llvm-ir 可以对比两个目标的 IR 差异:
1 | |
除了 triple、data layout 和运行时函数的声明,函数体应该完全一致。
练习
-
把第 25 篇的排序程序编译到
wasm32-wasi目标,用 wasmtime 运行,对比输出和本机版本是否一致。如果不一致,用--emit=llvm-ir检查两个目标的 IR 差异,定位问题所在。 -
用
wasm2wat查看生成的.wasm文件。找到sum_to函数的 WAT 表示,和 LLVM IR 版本对照。观察栈机器指令和 SSA 形式的区别:i64.add从哪里取操作数?循环怎样用block/loop/br表示? -
修改线性内存的初始大小为 1 页(64 KiB),运行一个分配大数组的程序,观察 wasmtime 报告的错误。把初始大小改回合理值,确认程序恢复正常。
