WebAssembly 后端与编译

WebAssembly 既是编译目标也是执行环境,它用栈机模型换取可验证性与跨平台一致性。本文讲解 WAT 与二进制格式、栈机执行模型、线性内存与函数表、wasm 专用优化,以及 JIT 与 AOT 两条执行路线的取舍。

1. 作为编译目标的 WebAssembly

一句话总结: WebAssembly 的设计目标不是「最快的执行格式」,而是「可验证、可移植、加载即安全」的编译目标,这个定位决定了它全部的语言特性取舍。

一个编译目标的设计总是从约束出发。WebAssembly 的约束有三条:产物必须在不可信环境中安全执行、同一份产物要在 x86、ARM、RISC-V 上行为一致、解码与验证必须足够快,快到能替代解释器。

这三条约束推导出了 wasm 的核心设计:

  1. 结构化控制流。没有任意跳转,只有 block、loop、if 三种结构化块。验证器只需一遍线性扫描就能确认栈高度与控制栈匹配,不需要构造控制流图。
  2. 静态类型化的栈机。每条指令的操作数类型在验证期就能确定,不需要运行期类型检查。
  3. 线性内存隔离。所有内存访问都经过显式的边界检查,宿主与模块的内存默认不共享。
  4. 不可变代码段。代码在加载后不可修改,这使得 JIT 可以安全地假设代码不被自修改。
;; 一个最小的 wasm 模块:加法函数与内存导出
(module
  (memory (export "mem") 1)          ;; 1 页 = 64KiB
  (func (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
)

与 LLVM IR 相比,wasm 的抽象层级更接近机器:它没有 SSA、没有 phi 节点、没有无限虚拟寄存器,但保留了结构化控制流。这个折中使得 wasm 既能被快速验证,又能被高效地再次编译到真实指令集。

维度LLVM IRWebAssembly
控制流任意 CFG 加 phi结构化块,无 phi
变量无限虚拟寄存器固定数量的局部变量
验证成本高(需数据流分析)低(单遍栈验证)
内存任意指针线性内存加边界检查
目标再编译到机器码再编译或直接解释

2. WAT 与二进制格式

一句话总结: 同一个模块有两种等价表示:给人读的文本格式 WAT 与给机器读的二进制格式,两者可以无损互转。

2.1 文本格式 WAT

一句话总结: WAT 是 S 表达式语法,它与二进制一一对应,是调试与手写测试用例的主要工具。

(module
  (func $fib (param $n i32) (result i32)
    (if (result i32) (i32.lt_s (local.get $n) (i32.const 2))
      (then (local.get $n))
      (else
        (i32.add
          (call $fib (i32.sub (local.get $n) (i32.const 1)))
          (call $fib (i32.sub (local.get $n) (i32.const 2)))))))
  (export "fib" (func $fib))
)

WAT 支持两种写法:折叠式(如上,指令带括号嵌套)与线性式(一条指令一行,按栈序排列)。线性式更接近二进制,调试时更容易与反汇编对照:

(func $add (param i32 i32) (result i32)
  local.get 0
  local.get 1
  i32.add)

工具链提供了两个方向的转换:

wat2wasm fib.wat -o fib.wasm        # 文本转二进制
wasm2wat fib.wasm -o fib.wat        # 二进制转文本
wasm-objdump -d fib.wasm            # 反汇编,看真实指令编码
wasm-validate fib.wasm              # 独立验证

2.2 二进制编码

一句话总结: wasm 二进制用 LEB128 变长整数压缩体积,用分段(section)组织内容,段的顺序固定且每种最多一个。

模块由若干段组成,顺序固定:类型段、导入段、函数段、表段、内存段、全局段、导出段、起始段、代码段、数据段。这个固定顺序让验证器可以流式处理。

魔数: 00 61 73 6D        ; "\0asm"
版本: 01 00 00 00        ; version 1
段:   01 06 01 60 01 7F 01 7F   ; 类型段:1 个类型 (i32) -> i32
      03 02 01 00                ; 函数段:1 个函数,类型索引 0
      0A 09 01 07 00 20 00 20 01 6A 0B  ; 代码段

整数用 LEB128 变长编码:小数值占更少字节。这是 wasm 体积优势的重要来源——一个常见的 i32.const 0 只占两个字节。

# LEB128 无符号编码:每字节低 7 位是数据,最高位表示是否继续
def encode_u32(n):
    out = bytearray()
    while True:
        b = n & 0x7F
        n >>= 7
        if n:
            out.append(b | 0x80)   # 还有后续字节
        else:
            out.append(b)
            return bytes(out)

3. 栈机执行模型

一句话总结: wasm 是栈机:指令从栈顶取操作数、把结果压回栈,没有寄存器概念,这让编码紧凑但执行时通常需要一次「栈到寄存器」的转换。

3.1 栈机与寄存器机

一句话总结: 栈机指令短、编码简单、易于验证,但会产生大量冗余的压栈弹栈;寄存器机指令长但执行直接。

;; 计算 (a + b) * (c + d):栈机的写法
local.get 0     ;; push a
local.get 1     ;; push b
i32.add         ;; pop 2, push a+b
local.get 2     ;; push c
local.get 3     ;; push d
i32.add         ;; pop 2, push c+d
i32.mul         ;; pop 2, push result

如果直接翻译成机器码,每条 local.get 都是一次访存,i32.add 是一次栈顶读改写。真实的 wasm 引擎都会先做一次栈到寄存器提升:把局部变量放进寄存器,把栈操作消解为寄存器操作,再复用常规的寄存器分配器。

# 栈提升的核心:跟踪「当前栈顶的值住在哪个虚拟寄存器」
def lift_stack(code):
    vreg, out = [], []
    for ins in code:
        if ins.op == "local.get":
            vreg.append(("local", ins.arg))      # 不产生指令,只记录来源
        elif ins.op in ("i32.add", "i32.mul"):
            b, a = vreg.pop(), vreg.pop()
            t = new_vreg()
            out.append((ins.op, t, a, b))        # 变成三地址形式
            vreg.append(t)
    return out

提升之后得到的中间表示就是普通的三地址码,可以喂给标准的优化流水线。

3.2 结构化控制流到 CFG

一句话总结: 结构化控制流要转换成基本块与分支才能做数据流分析,转换的关键是处理 br 指令的多目标跳转语义。

;; br 带标签索引:br 1 跳出外层 block,br 0 跳出当前 block
(block $outer
  (loop $inner
    (br_if $outer (i32.eqz (local.get 0)))   ;; 条件为真则跳到 outer 之后
    (local.set 0 (i32.sub (local.get 0) (i32.const 1)))
    (br $inner)))

br 的目标是相对深度而非绝对地址,且跳出 block 时会自动丢弃栈上多余的值。转换器需要为每个块计算入口栈高度,把「丢弃 N 个值」翻译成显式的栈调整。

结构语义转换后的 CFG
block顺序执行,br 跳到末尾单入口单出口区域
loopbr 跳到开头带回边的区域,天然是循环头
if / else条件二选一二分支基本块

4. 内存与表

一句话总结: wasm 把「数据」与「代码」分成两个互不干扰的地址空间:线性内存放数据,函数表放可间接调用的函数引用。

4.1 线性内存

一句话总结: 线性内存是一段可增长的字节数组,所有访存都带边界检查,这既是安全保证也是性能开销。

(memory (export "mem") 1 10)   ;; 初始 1 页,最多 10 页
(data (i32.const 0) "hello")   ;; 静态数据初始化
// 源:p[i] = p[i] + 1
// 编译后:地址计算加显式边界检查
//   addr = p_base + i * 4
//   if (addr + 4 > mem_size) trap
//   mem[addr] = mem[addr] + 1

边界检查是 wasm 相对原生代码的主要开销。引擎的优化手段有三类:

  1. 冗余检查消除。若编译器能证明 addr + 4 <= mem_size 恒成立(比如循环上界已知且与内存大小比较过),可以省掉检查。
  2. 信号处理。把内存映射到一段预留了保护页的虚拟地址空间,越界访问直接触发 SIGSEGV,由信号处理器转成 trap。这样热路径上完全没有检查指令。
  3. 内存 64 位扩展。memory64 提案允许 64 位地址,代价是地址计算与检查更复杂。
# V8 打开信号式边界检查(默认行为,这里只是示意)
d8 --wasm-bounds-checks=signal fib.js

4.2 函数表与间接调用

一句话总结: 函数表是一组函数引用的数组,call_indirect 通过索引加类型检查实现间接调用,这是虚函数与函数指针在 wasm 里的落地方式。

(type $sig (func (param i32) (result i32)))
(table 4 funcref)
(elem (i32.const 0) $f0 $f1 $f2)      ;; 填表
(func $dispatch (param $idx i32) (result i32)
  (call_indirect (type $sig) (local.get $idx)))

call_indirect 在运行时会做两次检查:索引是否在表范围内、目标函数的类型是否与 $sig 匹配。不匹配就 trap。这种设计让 wasm 的间接调用比 C++ 虚调用更安全但通常更慢,因为多了一次类型比较。

5. wasm 专用优化

一句话总结: 除了通用的中端优化,wasm 后端还要针对体积、栈提升与结构化控制流做专门处理,体积优化在 Web 场景下的重要性不亚于速度。

;; 优化前:反复读取同一个局部变量
local.get 0
i32.const 1
i32.add
local.get 0
i32.const 2
i32.add
i32.mul

;; 优化后:局部变量提升为寄存器,公共子表达式合并
;; 寄存器形式:r0 = local0; r1 = r0 + 1; r2 = r0 + 2; r3 = r1 * r2
优化目标手段
栈提升执行速度局部变量进寄存器,消除压栈弹栈
死代码消除体积删除不可达块与无用导出
内联两者小函数内联,减少 call 开销与体积
常量折叠两者折叠后可能触发更多 DCE
指令合并体积用短编码替换等价的长编码
内存访问合并速度把相邻的 load 合成一次更宽的访存
# 用 Binaryen 做体积与速度优化
wasm-opt -Oz fib.wasm -o fib.small.wasm     # 最小体积
wasm-opt -O3 fib.wasm -o fib.fast.wasm      # 最大速度
wasm-opt -O3 --strip-debug --strip-producers fib.wasm -o fib.min.wasm

一个实用的经验:-Oz 与 -O3 的体积差通常在 10%~30%,而速度差往往不到 10%。在 Web 场景下,加载与解析时间常常比执行时间更值得优化,因此 -Oz 是更常见的默认选择。

6. JIT 与 AOT 对比

一句话总结: 浏览器内需要快速启动因此偏 JIT 加分层编译,服务端与嵌入式更看重确定性因此偏 AOT,两者的核心分歧是「编译时间算不算运行时间」。

6.1 分层执行

一句话总结: 现代 wasm 引擎通常先做一次基线编译保证启动速度,再对热点函数做优化编译,形成与 JavaScript 引擎类似的分层结构。

wasm 字节码
   |
   +-- Liftoff 基线编译(快速,代码质量一般) --> 立即执行
   |
   +-- 热点计数超过阈值
   |
   +-- TurboFan 优化编译(较慢,代码质量高) --> 替换基线代码
引擎基线编译器优化编译器特点
V8LiftoffTurboFan分层,与 JS 共用优化后端
SpiderMonkey基线编译Ion类似分层
WasmtimeCranelift 单层无独立分层启动快,优化程度中等
WasmerSinglepassCranelift / LLVM三档可选,按场景切换

6.2 何时选 AOT

一句话总结: 当启动时间不重要、而吞吐与可预测性重要时(边缘计算、插件系统、区块链),AOT 编译 wasm 到原生代码是更好的选择。

# Wasmtime 预编译:把 wasm 提前编译成平台原生代码并缓存
wasmtime compile fib.wasm -o fib.cwasm
wasmtime run --allow-precompiled fib.cwasm

# 直接 AOT 到目标文件,脱离运行时
wasm2c fib.wasm -o fib.c && cc -O2 fib.c wasm-rt-impl.c -o fib

wasm2c 这条路值得注意:它把 wasm 翻译成可移植 C,再由系统编译器编译。这在嵌入式与需要审计的场景里很有价值——产物是可读的 C 代码,不依赖任何 wasm 运行时。

7. 实现要点与陷阱

一句话总结: 实现 wasm 后端最容易踩的坑集中在验证语义、浮点确定性与 trap 语义三处,它们都与「跨平台一致」这个核心承诺直接相关。

陷阱表现应对
忽略验证期类型检查生成的模块被引擎拒绝生成后用 wasm-validate 自查
浮点 NaN 位模式不同平台结果不一致遵循 NaN 传播规范,不假设位模式
整数溢出未包裹与源语言语义不符wasm 整数运算天然按位包裹,注意有符号比较
内存增长后指针失效悬垂指针增长内存后重新计算基址
表索引越界运行时 trap静态检查索引范围或加显式判断
trap 后状态不确定宿主无法恢复明确 trap 语义,宿主侧隔离实例
;; 陷阱:内存增长会改变内存基址,缓存的指针全部失效
;; 正确做法:每次访问都从 memory 指令重新取基址
(func $safe_store (param $off i32) (param $val i32)
  (i32.store (local.get $off) (local.get $val)))

浮点确定性的处理尤其值得强调。wasm 规定:非规格化数必须按 IEEE 754 正常处理(不允许 flush to zero),NaN 的位模式在传播中可以是任意的,但一旦被 reinterpret 观测就必须稳定。这意味着 wasm 编译器不能做那些改变浮点结果的优化,比如 x * 0.0 简化为 0.0(当 x 是 NaN 或无穷时结果不同)。

# 检查生成的模块是否符合预期
wasm-validate --enable-all fib.wasm && echo "验证通过"
wasm-objdump -x fib.wasm | head -30      # 看段结构与导出
wasm2wat --generate-names fib.wasm       # 保留名字,便于调试

8. 总结

环节要点
设计目标可验证、可移植、加载即安全,而非追求极致性能
文本与二进制WAT 与 wasm 一一对应,LEB128 压缩体积,段顺序固定
执行模型静态类型栈机,需要一次栈到寄存器的提升
控制流结构化块转 CFG,br 目标为相对深度
内存线性内存加边界检查,可用信号机制消除热路径检查
函数表call_indirect 带运行期类型检查,比虚调用多一次比较
专用优化栈提升与体积优化并重,Web 场景优先体积
执行路线浏览器偏分层 JIT,服务端与嵌入式偏 AOT
一致性陷阱浮点 NaN、整数包裹、内存增长后指针失效

WebAssembly 后端的价值不在于它比原生代码快,而在于它把「安全」与「可移植」变成了编译期可以保证的性质。代价是编译器必须在生成代码时处处遵守规范:不能随意重排浮点运算、不能假设内存大小固定、不能省略类型检查。理解了这些约束的来源,就能理解为什么 wasm 的性能通常落在原生代码的 60%~90% 之间——这个差距不是工程水平的差距,而是设计取舍的必然结果。下一篇我们会把视角从「目标格式」转回「循环本身」,讨论循环优化与依赖分析如何决定一个循环能不能被重排、分块与向量化。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类