1. 作为编译目标的 WebAssembly
一句话总结: WebAssembly 的设计目标不是「最快的执行格式」,而是「可验证、可移植、加载即安全」的编译目标,这个定位决定了它全部的语言特性取舍。
一个编译目标的设计总是从约束出发。WebAssembly 的约束有三条:产物必须在不可信环境中安全执行、同一份产物要在 x86、ARM、RISC-V 上行为一致、解码与验证必须足够快,快到能替代解释器。
这三条约束推导出了 wasm 的核心设计:
- 结构化控制流。没有任意跳转,只有
block、loop、if三种结构化块。验证器只需一遍线性扫描就能确认栈高度与控制栈匹配,不需要构造控制流图。 - 静态类型化的栈机。每条指令的操作数类型在验证期就能确定,不需要运行期类型检查。
- 线性内存隔离。所有内存访问都经过显式的边界检查,宿主与模块的内存默认不共享。
- 不可变代码段。代码在加载后不可修改,这使得 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 IR | WebAssembly |
|---|---|---|
| 控制流 | 任意 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 跳到末尾 | 单入口单出口区域 |
| loop | br 跳到开头 | 带回边的区域,天然是循环头 |
| 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 相对原生代码的主要开销。引擎的优化手段有三类:
- 冗余检查消除。若编译器能证明
addr + 4 <= mem_size恒成立(比如循环上界已知且与内存大小比较过),可以省掉检查。 - 信号处理。把内存映射到一段预留了保护页的虚拟地址空间,越界访问直接触发 SIGSEGV,由信号处理器转成 trap。这样热路径上完全没有检查指令。
- 内存 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 优化编译(较慢,代码质量高) --> 替换基线代码
| 引擎 | 基线编译器 | 优化编译器 | 特点 |
|---|---|---|---|
| V8 | Liftoff | TurboFan | 分层,与 JS 共用优化后端 |
| SpiderMonkey | 基线编译 | Ion | 类似分层 |
| Wasmtime | Cranelift 单层 | 无独立分层 | 启动快,优化程度中等 |
| Wasmer | Singlepass | Cranelift / 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% 之间——这个差距不是工程水平的差距,而是设计取舍的必然结果。下一篇我们会把视角从「目标格式」转回「循环本身」,讨论循环优化与依赖分析如何决定一个循环能不能被重排、分块与向量化。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。