Skip to content

第2章 WebAssembly 规范:二进制格式与指令集

"A language that doesn't affect the way you think about programming is not worth knowing." — Alan Perlis

W3C WebAssembly 规范定义了 WASM 的完整语义——从二进制编码到执行行为。这份规范不是抽象的理论文档,而是所有 WASM 运行时(V8、SpiderMonkey、JavaScriptCore、Wasmtime、Wasmer)必须严格遵守的契约。理解规范,才能理解工具链的输出为什么是这样的、运行时的行为为什么是那样的、体积优化和性能调优的空间在哪。

本章覆盖三个核心主题:二进制格式(.wasm 文件的结构和编码规则)、栈式指令集(为什么选择栈机、指令如何分类、控制流如何结构化)、类型系统(值类型、函数类型、验证规则)。这三个主题是后续章节的基础——第 3 章的线性内存和表机制是二进制格式中的 Memory 段和 Table 段的运行时呈现,第 5 章的 Rust 代码生成是类型系统和指令集的上游输入,第 9 章的体积优化是对二进制格式的每字节精打细算。

2.1 从源码到二进制:WASM 的编译流水线

Rust 代码变成浏览器执行的机器码,经过的路径比编译到原生平台多一层抽象:

关键区别在于第 6-10 步。原生编译是"源码 → 机器码"一步到位,WASM 插入了一个中间表示——.wasm 二进制。这个二进制不是任何真实 CPU 的机器码,而是一种虚拟指令集(virtual ISA)。浏览器(或 Wasmtime 等运行时)接收 .wasm,验证其合法性,再编译为当前平台的机器码。

为什么要插入一层虚拟 ISA?两个字的回答:可移植性 + 可验证性

原生机器码无法验证——x86 的 mov 指令本身不携带类型信息,你无法在加载时判断一段机器码是否安全。WASM 的每条指令都携带操作数类型,验证器能在执行前确保类型安全、控制流安全、内存访问安全。同时,同一个 .wasm 二进制可以在 x86-64、ARM64、RISC-V 上执行——因为虚拟 ISA 由运行时翻译为本地机器码。

这与《Rust 编译器与运行时揭秘》第 5 章讨论的 LLVM 后端形成对比:原生编译时,LLVM IR 直接翻译为 x86/ARM 机器码;WASM 编译时,LLVM IR 翻译为 wasm32 虚拟指令,再由浏览器/Wasmtime 翻译为本地机器码。多一层抽象意味着多一层开销,但也意味着多一层安全保证和多一份可移植性。

2.2 模块结构:WASM 的顶层组织

一个 .wasm 文件在逻辑上是一个模块(Module),由多个(Section)组成。W3C 规范定义了 12 种段(ID 0-11),加上组件模型扩展的 Component 段:

段 ID名称内容是否必须
0Custom自定义数据(名称、调试信息、源码映射等)
1Type函数签名(参数类型 + 返回值类型)
2Import导入项(函数、表、内存、全局变量)
3Function函数体索引(指向 Code 段中的函数体)
4Table表(间接调用的函数引用)
5Memory线性内存声明
6Global全局变量
7Export导出项
8Start启动函数(模块实例化后自动调用)
9Element表初始化数据
10Code函数体(局部变量 + 指令序列)
11Data内存初始化数据

段必须按 ID 递增顺序出现,每个段最多出现一次(Custom 段除外,可出现多次)。这种设计让解码器可以单遍(single-pass)解析——读一个段,处理一个段,不需要回头。单遍解析不仅简化了解码器实现,更重要的是让流式编译(streaming compilation)成为可能:V8 可以在 .wasm 文件还在下载时就开始解码和编译,等下载完成时编译也接近完成。

模块结构用伪代码表示:

Module ::=
  header  (\0asm + version)
  Section*  // 按 ID 递增排列,Custom 段可穿插

段的内部结构

每个段(除 Custom 段外)的编码格式是统一的:

Section ::=
  section_id    (1 字节, LEB128)
  section_size  (LEB128, 后续内容的字节数)
  content       (段的具体内容)

section_size 字段让解码器可以跳过不需要的段——比如只关心导出函数的解码器可以跳过 Code 段,直接读取 Export 段。这种设计在工具链中广泛使用:wasm-opt 在做优化时需要读取和修改 Code 段,但不关心 Data 段;wasm-bindgen 在生成 JS 胶水代码时需要读取 Type、Import、Export 段,但不关心 Code 段的具体指令。

Custom 段的特殊角色

Custom 段(ID 0)是唯一可以出现多次的段,且可以出现在任何位置(只要不破坏其他段的递增顺序)。它的用途是携带不属于核心规范的数据:

  • 名称段name section):存储函数名、局部变量名,用于调试和错误信息。wasm-opt --strip-name 可以删除这个段以减小体积。
  • 源码映射段sourceMappingURL):存储 DWARF 调试信息的 URL,浏览器 DevTools 据此加载调试信息。
  • Producers 段:记录编译器、语言、工具链的版本信息。类似 JavaScript 的 //@ sourceURL 注释。

⚠️ 生产环境中,wasm-pack --release 会自动用 wasm-opt 删除名称段。如果你发现生产环境的 .wasm 比 Debug 模式小 30-50%,很大程度上是因为名称段被删除了。

2.3 二进制编码基础

WASM 的二进制格式使用三种核心编码方式,理解它们是阅读后续二进制示例的前提。

LEB128 变长整数编码

LEB128(Little Endian Base 128)是一种变长编码,小数值用少量字节,大数值用更多字节。WASM 使用无符号 LEB128(u32/u64)和有符号 LEB128(s32/s64)两种变体。

编码规则:每个字节的最高位(bit 7)是延续标志——1 表示还有后续字节,0 表示这是最后一个字节。低 7 位承载实际数据。

数字 0    → 0x00           (1 字节)
数字 1    → 0x01           (1 字节)
数字 127  → 0x7F           (1 字节, LEB128 单字节上限)
数字 128  → 0x80 0x01      (2 字节: 0x80 的低 7 位是 0, 高位 1 表示有后续)
数字 300  → 0xAC 0x02      (2 字节: 300 = 0x12C, 低 7 位 0x2C | 0x80 = 0xAC, 高 7 位 0x02)
数字 624485 → 0xE5 0x8E 0x26  (3 字节)

LEB128 的选择不是随意的。WASM 模块中大量小整数(类型 ID、操作码、局部变量数量、函数参数数量)用 1 字节就够了。大整数(内存大小、函数偏移、数据段长度)自动扩展到需要的字节数。Binaryen 团队的测量表明,LEB128 比固定 4 字节编码节省 30-50% 的二进制体积——对于一个"体积即延迟"的格式来说,这个节省意义重大。

UTF-8 字符串编码

WASM 中的字符串(导出名、导入模块名、自定义段名)用 UTF-8 编码,前置 LEB128 长度:

03 61 64 64
│  └───────┘
│    "add"
长度 = 3

这意味着导出名越长,二进制越大。wasm-bindgen 默认用 __wbindgen_ 前缀的内部符号——这些名字的长度直接影响 .wasm 体积。一个有 20 个导出函数的模块,每个函数名平均 15 字节,名称段就占 300+ 字节。wasm-opt --strip-name 可以删掉所有名称段,但代价是失去调试信息和有意义的错误信息。

📐 设计权衡:名称段的体积 vs 调试友好性。生产环境通常删除名称段,开发环境保留。wasm-pack--release--dev 模式自动处理这个选择。

向量编码

WASM 中的列表(类型列表、函数列表、导出列表等)用向量编码:LEB128 长度 + 元素序列。

02 60 01 7F 00 60 02 7F 7F 01 7F
│  └──────────────────────────────┘
│   2 个函数类型
长度 = 2

向量编码和 LEB128 长度前缀的组合,让解码器可以先读取长度,再分配精确大小的缓冲区——不需要动态扩容,也不需要预知模块大小。

2.4 魔数与版本号:最小的合法性检查

最简单的 WASM 模块——什么都没有的空模块:

00 61 73 6D   ; magic: \0asm (0x00 0x61 0x73 0x6D)
01 00 00 00   ; version: 1 (小端序 u32)

只有 8 字节。4 字节魔数 \0asm(即 0x00 0x61 0x73 0x6D,ASCII 字符 \0asm),4 字节版本号 1(小端序 u32)。

魔数的设计有几个用意:

  1. 快速识别:文件管理器和 HTTP 服务器可以用魔数识别 .wasm 文件,不需要依赖扩展名。RFC 中注册的 MIME 类型 application/wasm 也基于这个魔数。
  2. 安全性\0 开头防止文件被误认为 HTML 或 JavaScript(HTML 以 < 开头,JavaScript 不以 \0 开头),降低了内容嗅探(content sniffing)攻击的风险。
  3. 版本协商:版本号让运行时可以拒绝不兼容的未来版本,而不是尝试解析后崩溃。

目前 W3C 规范只定义了版本 1。组件模型规范定义了新的二进制格式(以 0x00 0x61 0x73 0x6D + 版本号开头,但段 ID 和编码有扩展),但核心 WASM 的版本仍然是 1。

2.5 完整示例:一个 add 函数的二进制表示

加入一个导出函数 add(i32, i32) -> i32 后,模块的二进制如下:

00 61 73 6D 01 00 00 00  ; header (magic + version)

01 07                     ; Type section (ID=1), 7 bytes
  01                      ;   1 个类型
  60 02 7F 7F 01 7F       ;   func type: (i32, i32) -> i32
                          ;   60 = func type 标记
                          ;   02 = 2 个参数
                          ;   7F 7F = i32, i32
                          ;   01 = 1 个返回值
                          ;   7F = i32

03 02                     ; Function section (ID=3), 2 bytes
  01                      ;   1 个函数
  00                      ;   类型索引 0 (指向 Type section 的第 0 个类型)

07 07                     ; Export section (ID=7), 7 bytes
  01                      ;   1 个导出
  03 61 64 64             ;   name: "add" (长度 3 + UTF-8 编码)
  00 00                   ;   kind: func (0x00), index: 0

0A 09                     ; Code section (ID=10), 9 bytes
  01                      ;   1 个函数体
  07                      ;   body size: 7 bytes
  00                      ;   0 个局部变量声明
  20 00                   ;   local.get 0
  20 01                   ;   local.get 1
  6A                      ;   i32.add
  0B                      ;   end

总计 35 字节。逐段拆解这个二进制,可以建立对 WASM 二进制格式的完整理解。

注意几个关键点:

  • Type 段和 Code 段是分离的。Type 段定义函数签名,Code 段定义函数体,Function 段把它们关联起来。这种分离让多个函数可以共享同一个签名——如果 addmul 都是 (i32, i32) -> i32,它们共享 Type 段中的同一个类型条目,节省了重复编码签名的字节。
  • Export 段中的 00 00:第一个 00 是导出种类标记(0x00 = function,0x01 = table,0x02 = memory,0x03 = global),第二个 00 是函数索引。
  • Code 段中函数体的编码:先声明局部变量(这里 0 个),然后是指令序列,最后是 end(0x0B)。每个函数体必须以 end 结束——这是验证器的硬性要求。

2.6 栈式指令集:为什么不用寄存器

WASM 选择栈式(stack-machine)指令集而非寄存器式(register-machine),这是整个规范中最基础的设计决策。理解这个选择的原因,就理解了 WASM 二进制格式为什么是这样的。

什么是栈式指令集

栈式指令集的操作数隐式存储在一个值栈(value stack)上,而不是显式指定寄存器。对比同一个加法操作:

; 栈式(WASM)
local.get 0    ; 压入参数 0
local.get 1    ; 压入参数 1
i32.add        ; 弹出两个 i32,压入结果

; 寄存器式(x86-64)
add rax, rbx   ; rax = rax + rbx,需要指定两个寄存器

WASM 的 i32.add 不指定操作数从哪来、结果放哪去——操作数从栈顶弹出,结果压回栈顶。这和 x86 的 add rax, rbx 形成鲜明对比,后者必须用 ModR/M 字节编码两个寄存器号。

栈机的优势

编译简单:从 SSA 形式的 IR 生成栈式代码几乎是对应关系——每个值定义对应一次压栈,每个值使用对应一次弹栈。不需要做寄存器分配(register allocation)——那是编译器后端最复杂的优化之一,NP 完全问题。

二进制紧凑:栈式指令不需要指定操作数寄存器。x86-64 的 add rax, rbx 需要 ModR/M 字节编码两个寄存器,而 WASM 的 i32.add 只需要一个操作码 0x6A——操作数隐式从栈顶获取。Binaryen 团队的测量显示,相同逻辑的代码,WASM 栈式编码比假设的寄存器式编码平均小 15-25%。

验证简单:栈机的类型验证是结构化的——维护一个类型栈,每条指令的消费/生产规则是确定的。i32.add 要求栈顶两个值都是 i32,执行后弹出一个 i32(第二个操作数)、再弹出一个 i32(第一个操作数)、压入一个 i32(结果)。验证器只需跟踪栈深度和栈顶类型,O(n) 时间就能验证完整个模块。寄存器机的验证需要跟踪寄存器类型映射,复杂度显著更高。

栈机的代价

不是最优的执行模型:真实 CPU 是寄存器机,栈式代码在执行前需要经过"栈消除"(stack scheduling)——把虚拟栈位置映射到真实寄存器。这是浏览器 JIT 编译器(V8 的 TurboFan、SpiderMonkey 的 Warp)和独立运行时(Wasmtime 的 Cranelift)的工作,不是 WASM 设计者需要关心的。WASM 只负责定义语义,不负责规定执行方式。

指令序列更长:每个值的流动都要显式用 local.get/local.set 表达。一个需要临时保存中间结果的计算,在寄存器机中只需 mov rcx, rax,在 WASM 中需要 local.set $tmp + local.get $tmp——多条指令。但紧凑编码弥补了指令数量:local.get 的操作码只有 1 字节(0x20)+ LEB128 索引。

可读性差:人脑习惯"把 a 放入寄存器 1,把 b 放入寄存器 2,相加存入寄存器 3"——而不是"压 a,压 b,加"。但 WASM 的定位是编译目标,不是人手写的目标,可读性不是设计约束。WAT(WebAssembly Text Format)是可读的文本表示,可以双向转换——wasm2wat.wasm 转为 .watwat2wasm.wat 转为 .wasm

从 Rust 到栈式指令的映射

Rust 函数编译到 WASM 后,变量访问变成 local.get/local.set,运算变成栈式指令。一个 Rust 函数:

rust
fn multiply_add(a: i32, b: i32, c: i32) -> i32 {
    a * b + c
}

编译为 WAT(文本格式):

wasm
(func $multiply_add (param i32 i32 i32) (result i32)
  local.get 0    ;; a
  local.get 1    ;; b
  i32.mul        ;; a * b
  local.get 2    ;; c
  i32.add        ;; a * b + c
)

对应二进制:

20 00    ;; local.get 0
20 01    ;; local.get 1
6C       ;; i32.mul (0x6C)
20 02    ;; local.get 2
6A       ;; i32.add (0x0A)

这个映射几乎是机械的:每个 Rust 表达式的求值结果压栈,二元运算符弹出两个操作数、压入结果。不需要寄存器分配,不需要指令调度——LLVM 后端直接生成栈式代码。

2.7 指令分类与操作码

WASM 的指令集按功能分为五组。这不是规范的分类方式(规范按字母序排列),但按功能分组更有利于理解:

算术运算指令

WASM 的算术指令按操作数类型前缀分类:i32.i64.f32.f64.。同一运算在不同类型上有不同的操作码:

运算i32i64f32f64
add0x6A0x7C0x920xA0
sub0x6B0x7D0x930xA1
mul0x6C0x7E0x940xA2
div0x6D/0x6E (s/u)0x7F/0x80 (s/u)0x950xA3

注意整数除法区分有符号(div_s)和无符号(div_u)——WASM 的 i32 不携带符号信息,符号由指令语义决定。这与 Rust 的行为一致:i32::wrapping_div 对应 i32.div_su32::wrapping_div 对应 i32.div_u

浮点运算遵循 IEEE 754 规范:f32.addf64.add 的结果与硬件浮点运算一致(在默认舍入模式下)。NaN 传播规则也遵循 IEEE 754——WASM 不引入额外的 NaN 规范化,这和 JavaScript 的 NaN 行为不同。

内存访问指令

内存访问指令的编码格式统一:

i32.load offset=alignment  ;; 从线性内存加载 32 位整数
i32.store offset=alignment ;; 向线性内存存储 32 位整数

操作数从栈上获取:i32.load 弹出一个 i32 作为地址,计算 address + offset,从线性内存读取 4 字节,压入结果。i32.store 弹出一个 i32 作为值,再弹出一个 i32 作为地址,计算 address + offset,将值写入线性内存。

alignment 字段是一个优化提示:i32.load align=2 表示访问是 4 字节对齐的(2^2 = 4)。运行时可以利用对齐信息生成更高效的机器码(比如 x86 的对齐 movmovdqu 快)。但验证器不强制要求对齐——对齐只是提示,即使声明了 align=2 但实际地址不对齐,也不会导致验证失败,只是运行时可能较慢。

变量操作指令

WASM 的变量分为局部变量和全局变量:

  • local.get/set/tee:操作函数的局部变量(包括参数)。参数从索引 0 开始,局部变量从参数数量之后开始。local.teelocal.set + local.get 的组合——设置值但不弹出栈顶。
  • global.get/set:操作模块的全局变量。全局变量可以是 mut(可变)或 const(不可变)。导入的全局变量也可以是 mutconst,但 wasm-bindgen 生成的模块通常只使用 const 全局变量。

⚠️ global.set 只能修改声明为 mut 的全局变量。如果尝试 global.set 一个 const 全局变量,验证器会拒绝。这在 Rust 中对应 static mut(允许修改)和 static(不允许修改)的区别。

2.8 结构化控制流

WASM 的控制流是结构化的——没有任意跳转(goto),只有结构化的块。这是 WASM 与 x86/ARM 的本质区别,也是其安全模型的核心基础。

结构化控制流的指令

WASM语义对应 x86 的等价结构
block ... end顺序执行块,br 跳到 end 之后标签 + jmp
loop ... end循环块,br 跳回 loop 开头标签 + jmp
if ... else ... end条件分支cmp + je/jne
br跳出当前块(到指定 label 层级)jmp
br_if条件跳出cmp + 条件 jmp
br_table按索引跳转到不同目标跳转表
call直接函数调用call
call_indirect通过表索引间接调用call [rax + offset]
return从函数返回ret
unreachable陷阱(表示不可达代码)ud2

block 与 loop 的区别

blockloop 的区别在于 br 跳转的目标:

wasm
;; block: br 跳到 end 之后(向前跳)
block $exit
  br $exit       ;; → 跳到 block 的 end 之后
  i32.const 42   ;; 这行永远不会执行
end               ;; br $exit 跳到这里

;; loop: br 跳回 loop 开头(向后跳)
loop $continue
  i32.const 1
  br_if $continue  ;; 如果栈顶为真 → 跳回 loop 开头
end

这个区别是 WASM 控制流设计中最容易混淆的点。记住:block 是"跳出"的出口,loop 是"跳回"的入口。

分支标签深度

WASM 的 br 指令使用相对深度而非命名标签来指定目标。深度 0 指当前最内层的块,深度 1 指外一层,以此类推:

wasm
block $outer        ;; 深度 1 (相对于内层 block)
  block $inner      ;; 深度 0 (相对于 br 0)
    br 0            ;; 跳出 $inner → 到 $inner 的 end 之后
    br 1            ;; 跳出 $outer → 到 $outer 的 end 之后
  end
end

WAT 文本格式允许使用命名标签($outer$inner),但二进制格式中只编码相对深度(LEB128 整数)。这再次体现了"二进制紧凑"的设计目标——命名标签只在 WAT 中存在,.wasm 中没有名称段给控制流块使用。

if-else 的完整示例

wasm
;; Rust: fn abs(x: i32) -> i32 { if x >= 0 { x } else { -x } }
(func $abs (param i32) (result i32)
  local.get 0       ;; 压入 x
  i32.const 0       ;; 压入 0
  i32.ge_s          ;; x >= 0 ? (有符号比较)
  if (result i32)   ;; 如果栈顶为真
    local.get 0     ;;   返回 x
  else
    local.get 0     ;;   压入 x
    i32.const 0     ;;   压入 0
    i32.sub         ;;   0 - x = -x
  end
)

if 指令消耗栈顶的一个 i32 作为条件。整个 if-else-end 块的执行结果类型在 ifresult 子句中声明——这里是 (result i32),意味着 if 分支和 else 分支都必须在栈顶留下恰好一个 i32。验证器会检查这一点。

结构化控制流的安全意义

结构化控制流排除了 return-oriented programming(ROP)攻击的基础条件。ROP 攻击的核心是跳转到代码中间的 gadget——一段本来不是函数入口的指令序列。WASM 的 br 只能跳到 block/loop/if 的边界,call 只能调用函数索引表中的函数——无法跳转到函数体中间的任意位置。

2.9 值类型与函数类型

WASM MVP 定义了四种基本值类型,后期的提案扩展了类型系统(WasmGC 的 externref/anyref,SIMD 的 v128),但 MVP 的四种类型仍然是核心。

基本值类型

类型位数对应 Rust 类型二进制编码说明
i3232i32, u320x7F有符号/无符号整数,指令区分符号
i6464i64, u640x7E64 位整数
f3232f320x7D32 位 IEEE 754 浮点
f6464f640x7C64 位 IEEE 754 浮点

关键设计决策:

WASM 没有布尔类型i32 充当布尔值(0 = false,非 0 = true)。i32.eqi32.lt_s 等比较指令返回 i32(0 或 1),ifbr_if 接受 i32 作为条件。这与 C 语言的布尔模型一致,但比 Rust 的 bool 类型原始——Rust 的 bool 在编译到 WASM 后变成 i32,但 Rust 编译器保证它只持有 0 或 1。

WASM 没有字符串类型。字符串必须编码为线性内存中的字节序列,通过指针(i32 偏移量)+ 长度(i32)来引用。这是 wasm-bindgenJsValue 机制存在的原因——它需要在 JS 侧和 WASM 侧之间传递字符串,而 WASM 没有原生的字符串表示。

WASM 没有结构体类型(在 MVP 中)。复合类型需要手动在内存中布局,与 C 的 ABI 模型一致。WasmGC 提案引入了 structarray 类型,但这是 2024 年后才在主要浏览器中启用的扩展。Rust 的 struct 在编译到 WASM 后变成内存中的字段序列——和 C ABI 完全一致。

函数类型

函数类型(functype)定义函数的签名——参数类型列表和返回值类型列表:

functype ::= 0x60 paramtype* resulttype*

一个函数类型 (i32, i32) -> i32 的编码:

60 02 7F 7F 01 7F
│  │  └──┘  │  └──┘
│  │   参数   │   返回值
│  参数数  返回值数
func type 标记

WASM MVP 规定函数最多返回一个值。多值返回提案(Multi-value proposal,2022 年进入 Phase 4)允许函数返回多个值,但 Rust 目前不支持编译返回多值的 WASM 函数。

引用类型扩展

WASM 的引用类型提案(Reference Types,2020 年进入 Phase 4)引入了两种新的值类型:

类型二进制编码说明
funcref0x70函数引用,存储在表中用于间接调用
externref0x6F宿主侧的不透明引用,WASM 不能解引用

externref 对 Rust + WASM 特别重要:它允许将 JS 对象(DOM 元素、Promise、闭包等)作为不透明引用传入 WASM 模块,WASM 不能直接操作它,但可以存储和传递。wasm-bindgenJsValue 在 0.2.x 后期版本开始使用 externref,减少了一层 JS 侧的映射表。

2.10 导入与导出

WASM 模块不是自包含的——它通过导入(import)从宿主获取函数、表、内存和全局变量,通过导出(export)向宿主暴露自己的功能。这是 WASM 与外界交互的唯一通道。

导入

wasm
(import "env" "log" (func (param i32)))

这行声明:"我需要一个名为 env.log 的函数,签名为 (i32) -> ()。" 模块实例化时,宿主必须提供这个函数,否则实例化失败。

导入在二进制中的编码:

02                  ; Import section (ID=2)
  ...               ; section size
  01                ;   1 个导入
  03 65 6E 76       ;   模块名: "env"
  03 6C 6F 67       ;   函数名: "log"
  00                ;   导入种类: func
  00                ;   类型索引: 0

导入的常见模式:

模块名函数名用途
envabortRust 的 panic 处理
env__wbindgen_mallocwasm-bindgen 的内存分配
env__wbindgen_freewasm-bindgen 的内存释放
env__wbindgen_exn_storewasm-bindgen 的异常存储
wasi_snapshot_preview1fd_writeWASI preview 1 的文件写入
wasi_snapshot_preview1random_getWASI preview 1 的随机数

wasm-bindgen 生成的模块几乎总是有 env 命名空间下的导入——这些是 Rust 标准库在 WASM 环境下的桩函数。Rust 的 println!panic!allocwasm32-unknown-unknown 目标上都需要宿主提供实现。

导出

wasm
(export "add" (func 0))
(export "memory" (memory 0))

导出把模块内部的函数/内存/表/全局变量暴露给宿主。JavaScript 侧通过 WebAssembly.Instance.exports 访问:

javascript
const { add, memory } = instance.exports;
console.log(add(3, 4)); // 7

// 读取线性内存
const view = new Int32Array(memory.buffer);
console.log(view[0]); // 读取地址 0 处的 i32

一个常见的 Rust→WASM 编译产物导出列表:

⚠️ memory 的导出是 wasm-bindgen 的硬性要求。JS 侧的胶水代码需要通过 memory.buffer 访问线性内存,才能传递字符串、Vec<u8> 等复杂数据类型。如果模块没有导出 memorywasm-bindgen 生成的 JS 代码会直接报错。

2.11 函数体编码

每个函数体由三部分组成:

  1. 局部变量声明:类型 + 数量的列表
  2. 指令序列:栈式指令
  3. end 操作码0x0B
function_body ::=
  local_decl*    ;; 局部变量声明
  instruction*   ;; 指令序列
  0x0B           ;; end

局部变量声明的编码有一个微妙优化:相同类型的连续局部变量合并为一个声明。比如 3 个 i32 局部变量编码为 01 03 7F(1 个声明,数量 3,类型 i32),而非 03 7F 7F 7F(3 个独立的 i32)。这个优化在大函数中节省可观的字节数——一个有 10 个 i32 局部变量的函数,合并编码只需要 3 字节,不合并需要 30 字节。

一个更复杂的函数体示例——factorial(n)

wasm
(func $factorial (param $n i32) (result i32)
  (local $result i32)
  (local.set $result (i32.const 1))
  block $break
    loop $continue
      ;; if n == 0, break
      local.get $n
      i32.eqz
      br_if $break
      ;; result = result * n
      local.get $result
      local.get $n
      i32.mul
      local.set $result
      ;; n = n - 1
      local.get $n
      i32.const 1
      i32.sub
      local.set $n
      ;; continue loop
      br $continue
    end
  end
  local.get $result
)

对应二进制(简化表示):

;; 局部变量声明
01 01 7F        ;; 1 个声明: 1 个 i32 ($result)

;; 指令序列
41 01           ;; i32.const 1
21 01           ;; local.set 1 ($result)
02 40           ;; block (void)
03 40           ;; loop (void)
20 00           ;; local.get 0 ($n)
45              ;; i32.eqz
0D 01           ;; br_if 1 (跳出 block)
20 01           ;; local.get 1 ($result)
20 00           ;; local.get 0 ($n)
6C              ;; i32.mul
21 01           ;; local.set 1 ($result)
20 00           ;; local.get 0 ($n)
41 01           ;; i32.const 1
6B              ;; i32.sub
21 00           ;; local.set 0 ($n)
0C 00           ;; br 0 (跳回 loop)
0B              ;; end (loop)
0B              ;; end (block)
20 01           ;; local.get 1 ($result)
0B              ;; end (function)

这个示例展示了 block + loop 组合实现 while 循环的标准模式:loop 定义循环入口,br 0 跳回循环开头,block 定义循环出口,br_if 1 跳出循环。

2.12 数据段与元素段

数据段

数据段用于在模块实例化时初始化线性内存:

wasm
(data (i32.const 0) "hello")

编码为:

0B                    ; Data section (ID=11)
  01                  ;   1 个数据段
  00                  ;   active, memory 0
  41 00               ;   i32.const 0 (偏移量)
  0B                  ;   end
  05                  ;   数据长度 = 5
  68 65 6C 6C 6F      ;   "hello"

数据段有两种模式:

  • active 数据段:在实例化时自动将数据写入线性内存的指定偏移。上面示例就是 active 的——i32.const 0 指定偏移量,"hello" 在实例化时自动写入地址 0-4。
  • passive 数据段:不在实例化时写入,而是由 memory.init 指令在运行时按需写入。passive 数据段配合 data.drop 指令使用,写入后可以丢弃数据段以释放内存。这是体积优化的重要手段——第 9 章会详细讨论。

Rust 编译器会用数据段存放字符串字面量、静态变量的初始值、const 常量。wasm-bindgen 的 JavaScript 胶水代码通过数据段预初始化一些宿主侧的元数据。

元素段

元素段用于初始化表——在实例化时将函数引用写入表的指定位置:

wasm
(table 2 funcref)
(elem (i32.const 0) $func_a $func_b)

这声明了一个大小为 2 的 funcref 表,在实例化时将 $func_a$func_b 的引用写入表索引 0 和 1。call_indirect 指令可以通过表索引间接调用这些函数。

元素段是 dyn Trait 在 WASM 中的实现基础——第 3 章会详细讲解表机制和间接调用。

2.13 验证规则

WASM 验证器在模块加载时执行一组静态检查,保证执行安全。这些检查是线性的——O(n) 时间,n 是模块大小。这是 WASM 可以安全加载不受信任代码的基础:验证通过 = 执行安全,不需要运行时检查。

核心验证规则

1. 类型一致性:每条指令的操作数类型必须匹配。验证器维护一个抽象类型栈(abstract type stack),模拟指令执行时的栈状态。i32.add 要求栈顶两个值都是 i32i32.load 要求地址是 i32call 的参数类型和数量必须与目标函数签名一致。

验证过程示例:
  栈状态: [i32, i32]
  执行 i32.add
  消费: 2 个 i32
  生产: 1 个 i32
  栈状态: [i32]
  ✓ 类型一致

  栈状态: [i32, f64]
  执行 i32.add
  消费: 2 个 i32
  ✗ 栈顶不是 i32 (是 f64)
  验证失败

2. 控制流结构化:每个 block/loop/if 必须有对应的 endbr 的标签深度不能超出当前嵌套层数。结构化控制流保证了不存在跳转到函数体中间的可能。

3. 内存访问在界内load/store 指令的静态偏移加上操作数大小不能超出线性内存的声明大小。注意验证器只做静态偏移检查——运行时的动态边界检查由线性内存的大小限制保证。如果 i32.load offset=100 访问的地址是 ptr + 100,验证器检查的是 100 + 4 <= memory.max_size * 64KB,不是 ptr + 100 + 4 <= memory.current_size

4. 函数签名匹配call 的参数数量和类型必须与目标函数签名一致;call_indirect 的表元素类型必须是 funcref,且调用时的类型索引必须在 Type 段中存在。

5. 表和内存的上限:表的大小不能超过 2^32 - 1;内存的页数不能超过声明中的 max(如果指定了的话)。每页大小固定为 64KB(65536 字节)。

验证的安全保证

验证通过后,WASM 运行时可以做出以下保证:

  1. 没有未定义行为:WASM 规范定义了每条指令在每种输入下的行为。即使触发 trap(如除以零、越界访问),行为也是确定的——trap 传播到调用者,直到被宿主捕获。
  2. 没有内存越界:所有内存访问都在线性内存的 [0, memory.size) 范围内。超出范围会触发 trap,而不是未定义行为。
  3. 没有非法控制流转移:所有跳转都指向 block/loop/if 的边界,所有 call 都指向有效的函数索引。
  4. 栈不会下溢:类型栈的验证保证了每条指令执行时栈上有足够的操作数。

这些保证让 WASM 可以安全地执行不受信任的代码——这是插件系统、边缘计算、区块链智能合约等场景的基本前提。与 Docker 容器的隔离机制不同(依赖操作系统 namespace + cgroup),WASM 的隔离是语言级别的——由验证器在加载时保证,不需要操作系统支持。

2.14 与 Rust 编译的对应关系

Rust 编译到 wasm32-unknown-unknown 时,语言构造到 WASM 的映射关系是理解 Rust + WASM 全链路的关键。以下是核心映射:

Rust 构造WASM 表示说明
i32 类型i32 值类型直接映射
f64 类型f64 值类型直接映射
booli320 或 1,占 4 字节(WASM 没有 1 字节值类型)
&[u8] 切片指针(i32) + 长度(i32) 两个参数胖指针拆成两个 i32
String线性内存中的字节序列 + 指针(i32)/长度(i32)不能直接跨边界传递
struct线性内存中的字段布局(和 C ABI 一致)无 WASM 原生结构体类型
enum标签(i32) + 联合体,编译为内存中的字节序列无 WASM 原生枚举类型
dyn Trait表(funcref) + vtablecall_indirect 间接调用
fn 指针表索引(funcref)通过 call_indirect 调用
panic!调用导入的 abort / __rust_start_panicwasm-bindgen 提供桩函数
alloc调用导入的 __wbindgen_mallocdlmalloc线性内存中的堆分配器
static 变量全局变量或线性内存中的固定偏移取决于是否可变
Result<T, E>内存中的标签 + 值不是 WASM 原生类型

几个关键映射的深入分析:

Rust 的引用类型 &T&mut T 在 WASM 层面就是 i32——一个线性内存偏移量。所有权规则在编译期执行完毕,运行时只有裸地址。这是 Rust + WASM 内存安全的基石:编译器保证引用始终有效,WASM 保证线性内存内的操作不会越界到内存之外。

dyn Trait 的动态分发通过 WASM 的 call_indirect 实现——函数指针存储在表中,通过索引间接调用。这和 vtable 在原生平台上的实现本质相同,但有一个关键差异:WASM 的 call_indirect 需要指定类型索引,运行时会检查表中的函数签名是否匹配——这比 C++ 的虚函数调用多了一层类型安全检查。

String 不能直接跨 Rust-JS 边界传递——它不是 WASM 的值类型。wasm-bindgen 会在 JS 侧分配线性内存、复制 UTF-8 字节、传递指针和长度——这是第 6-7 章的主题。理解这个限制的根本原因在于 WASM 的类型系统只有 i32/i64/f32/f64 四种值类型,字符串必须编码为线性内存中的字节序列。

bool 在 WASM 中占 4 字节——因为 WASM 没有 8 位值类型。Rust 编译器会在内存布局中将 bool 打包(#[repr(C)] 结构体中 bool 字段仍占 1 字节),但在函数参数和返回值中,bool 被提升为 i32。这个差异在 FFI 边界上尤其值得注意——JS 侧的 true1(i32),不是 0x01(i8)。

2.15 自定义段:WASM 的元数据扩展机制

WASM 二进制由"段"(section)组成——前面章节介绍的 Type、Function、Code 等是已知段(known sections,section ID 1-12)。规范还预留了 ID=0 的自定义段(custom section),让工具链嵌入任意元数据而不影响执行。这是 WASM 生态扩展的核心机制。

2.15.1 自定义段的二进制布局

每个自定义段有:

  1. ID = 0 标识
  2. 段大小(payload)
  3. 段名(区分不同自定义段)
  4. payload(任意二进制内容)

WASM 引擎遇到自定义段时——完全忽略,但保留在内存中可被工具访问。这让自定义段成为零开销的元数据载体。

2.15.2 标准化的自定义段

虽然 WASM 规范不规定 payload 内容,社区约定了几个标准段:

段名内容用途
name函数/局部变量名调试和反汇编可读
producers工具链信息(Rust 1.86, LLVM 18 等)追溯产物来源
target_features启用的 WASM 提案验证目标兼容性
dylink动态链接元数据Emscripten 动态库
.debug_info, .debug_lineDWARF 调试信息源码级调试
core 段(Component Model)组件元数据组件实例化

name 段最重要——wasm-bindgen 生成的 .wasm 默认带 name 段,让浏览器 DevTools 显示函数名而不是 wasm-function[42]。剥离 name 段(wasm-opt --strip-debug)可减 5-15% 体积,但失去可读调用栈。

2.15.3 用 wasm-tools 检查自定义段

bash
# 列出所有段
wasm-tools dump my.wasm

# 输出(节选)
0x0000000a | 03 70 72 6f 64 75 63 65 72 73 | section "producers"
0x0000004a | 04 6e 61 6d 65                | section "name"
0x00001234 | 06 74 61 72 67 65 74 5f 66    | section "target_features"

每个段都是结构化二进制——但通常用工具读,不直接看字节。

2.15.4 嵌入自定义段:实战

业务可以嵌入自己的自定义段——例如版本信息、签名、license:

bash
# 用 wasm-tools 添加自定义段
wasm-tools custom-section add \
    --name "mycompany.version" \
    --content "v1.2.3" \
    input.wasm \
    -o output.wasm

或在 build.rs 用 walrus crate 编程式添加:

rust
use walrus::Module;

fn embed_metadata(wasm_path: &str, metadata: &[u8]) {
    let mut module = Module::from_file(wasm_path).unwrap();
    module.customs.add(walrus::RawCustomSection {
        name: "mycompany.metadata".to_string(),
        data: metadata.to_vec(),
    });
    module.emit_wasm_file(wasm_path).unwrap();
}

2.15.5 自定义段的工程应用

每个场景的工程价值:

  • 调试支持:name + DWARF 让生产 trap 报错可读,问题定位时间从小时降到分钟
  • 供应链验证:嵌入构建签名,下游验证未被篡改
  • 元数据传播:组件模型把 WIT 接口存在自定义段,运行时反序列化
  • 条件编译:运行时检测 target_features,决定走 SIMD 还是标量路径

2.15.6 注意事项

每条注意点:

  • 段名冲突:用 mycompany.feature 这种反向域名格式,避免与标准段重名
  • 体积影响:开发阶段保留所有段(调试需要),release 用 wasm-opt --strip-custom 移除非必需段
  • 不能影响执行:自定义段是元数据,业务逻辑不能依赖它存在——发布时可能被 strip
  • 跨工具兼容:Wasmtime 可能不识别浏览器特有的段,生产部署测试覆盖

自定义段是 WASM 生态扩展的关键机制——理解它有助于看穿"WASM 二进制不只是代码"的本质。

2.16 WAT 文本格式:人类可读的 WASM

二进制格式紧凑但不可读——WAT(WebAssembly Text Format)是 WASM 的官方文本格式,用 S-expression 语法。理解 WAT 让调试、教学、文档都更直观——也是手写测试用例的唯一选择。

2.16.1 WAT 的设计哲学

WAT 的关键特性:

  • 1:1 映射:每个 WAT 程序对应一个 .wasm,无歧义
  • 可逆wasm2wat input.wasm | wat2wasm > output.wasm,得到字节相同的产物
  • 嵌套或扁平:S-expression 嵌套写更直观,平展写更接近实际指令流

2.16.2 完整的 WAT 模块示例

(module
  ;; 类型定义
  (type $add_t (func (param i32 i32) (result i32)))

  ;; 导入
  (import "console" "log" (func $log (param i32)))

  ;; 内存声明(1 页 = 64KB)
  (memory $mem 1)

  ;; 函数定义
  (func $add (type $add_t)
    local.get 0
    local.get 1
    i32.add)

  ;; 数据段
  (data (i32.const 0) "Hello, WASM!\00")

  ;; 全局变量
  (global $counter (mut i32) (i32.const 0))

  ;; 表(间接调用)
  (table $funcs 1 funcref)
  (elem (i32.const 0) $add)

  ;; 导出
  (export "add" (func $add))
  (export "memory" (memory $mem)))

每个段都对应一种语法元素——这种"段-语法"对应关系让 WAT 直接反映二进制结构。

2.16.3 嵌套 vs 平展两种风格

WAT 同一段代码可以两种风格写:

;; 嵌套风格(更直观)
(func $square (param i32) (result i32)
  (i32.mul
    (local.get 0)
    (local.get 0)))

;; 平展风格(更接近实际栈机执行)
(func $square (param i32) (result i32)
  local.get 0
  local.get 0
  i32.mul)

两种生成的字节码完全一样。新手用嵌套风格更容易理解——但成熟工具(wasm2wat 输出)都用平展,因为更接近 WASM 栈机的真实语义。

2.16.4 WAT 的实战用途

2.16.5 调试场景:核对编译器输出

Rust 编译为 WASM 后,开发者经常需要确认"编译器是否生成了我期待的指令":

rust
// Rust 代码
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

编译后用 wasm2wat 反汇编:

(func $add (type $t0) (param $a i32) (param $b i32) (result i32)
  local.get $a
  local.get $b
  i32.add)

完全符合预期——3 条指令。如果反汇编看到 10 条指令,说明编译器没做预期的优化——值得调查。

2.16.6 手写 WAT 的场景

rust
// 1. 测试 WASM 引擎的特定指令行为
fn write_test_module() {
    let wat = r#"
        (module
          (func (export "test_overflow") (result i32)
            i32.const 2147483647  ;; i32::MAX
            i32.const 1
            i32.add))               ;; 应该回绕到 i32::MIN
    "#;
    let wasm = wat::parse_str(wat).unwrap();
    // 加载 wasm,调用 test_overflow,验证返回 i32::MIN
}

// 2. 生成边界用例
// 比如测试 memory.grow 失败情况

wat crate 让 Rust 代码可以内联 WAT——测试场景非常有用。

2.16.7 WAT 的限制

WAT 的最佳定位:调试 + 教学 + 测试,不是编程语言。生产代码应该用 Rust/C++ 等高级语言写,编译到 WASM。

2.16.8 学习路径

WebAssembly 规范的测试套件(github.com/WebAssembly/spec/tree/main/test)全部用 WAT 写——是学习 WAT 的最佳教材。每个 .wast 文件都是一个测试用例,覆盖规范的特定方面。

2.16.9 与现代工具链的协作

主流编辑器都有 WAT 语法高亮 + 语法检查——开发体验已经接近主流编程语言。Bytecode Alliance 的 wasm-tools 是 2026 年最完整的 WAT 工具集——比早期的 wabt 更新更频繁。

掌握 WAT 是 WASM 工程师的"能力深度"标志——不是日常工作的工具,但理解后让你能透过编译产物看到本质。

2.17 WASM 二进制规范的演进史

WASM 不是从零设计——它继承了 asm.js 等前代技术的经验。理解规范演进史有助于理解"为什么 WASM 是这个样子",避免对设计决策的误解。

2.17.1 关键里程碑

每个阶段都有特定主题:

  • 2015-2017:从 asm.js 提炼到 MVP,重点是"能跑"
  • 2018-2020:扩展提案补足关键能力
  • 2021-2024:高级类型系统(组件模型)
  • 2025+:GC、异步等深度特性

2.17.2 设计决策的历史背景

WASM 的"最小核心"哲学:MVP 故意不内置 String、Array、GC——通过提案逐步加。这避免了 JVM "一次设计、永远兼容"的累赘。

2.17.3 与其他二进制格式的对比

维度WASMJVM bytecode.NET CILLLVM IR
设计目标Web + 通用Java.NET编译器中间表示
类型系统极简(4 种)OOP 类型OOP 类型类型化 SSA
内存模型线性内存GC 堆GC 堆抽象
字节码紧凑栈式栈式栈式寄存器式
跨平台是(JVM)是(CLR)否(IR)
安全模型严格沙箱沙箱沙箱不适用

WASM 借鉴了多家——但取舍上更激进:JVM 的字节码 + LLVM 的简洁 + 严格的沙箱。

2.17.4 失败的前辈:NaCl 和 asm.js

每个前辈的教训:

  • NaCl:技术好但单家推动,没有跨厂商共识
  • asm.js:跨平台但性能不够,文本格式 parse 慢

WASM 吸取教训:四家浏览器联合 + 二进制紧凑 + 标准化路线。

2.17.5 提案流程的演进

WASM 的提案流程分 5 阶段——任何提案都要走完全流程才能进入主流规范。这套流程让 WASM 演进比 JS(TC39)和 HTML(W3C)更严谨。

跟踪渠道:github.com/WebAssembly/proposals 是中央索引。

2.17.6 演进的工程影响

每次规范变化都牵动整个生态——工具链、浏览器、生产部署。这是 WASM 工程团队需要持续关注的维度。

2.17.7 历史教训

WASM 演进的几个关键教训:

这些教训也在指导其他标准——例如 WebGPU 的设计就明显受 WASM 演进影响。

2.17.8 未来 5-10 年的演进预期

不要把 WASM 当"已经完成的技术"——它还在快速演进。工程团队应该跟随而非追新——等成熟提案进入稳定阶段再采纳。

2.17.9 阅读规范的工程价值

不是所有工程师都需要读规范——但项目中至少有 1 人深入懂规范。否则遇到工具链 bug 或新提案时无法独立判断。

WASM 规范的官方文档:webassembly.github.io/spec ——结构清晰,比想象的可读。一周时间能通读核心规范,受益时间是几年的工程判断力。

2.18 WASM 二进制的反向工程

正向开发是从源码到 .wasm——反向工程是从 .wasm 推回逻辑。这在安全审计、第三方组件分析、调试无源码库等场景必备。理解反向工程能力也帮助评估 .wasm 的"可保密性"。

2.18.1 反向工程的层次

每层难度递增——层 4 接近编译器逆向,工具链不完善。

2.18.2 层 1:反汇编

最基础——任何 .wasm 都能反汇编为 WAT:

bash
wasm2wat my.wasm > my.wat

WAT 完整保留指令信息——但变量名通常没有(除非有 name section)。能看到:

  • 函数签名
  • 控制流结构
  • 内存访问模式

2.18.3 层 2:函数识别

bash
# 看函数列表
wasm-objdump -h my.wasm

# 反汇编特定函数
wasm-objdump -d my.wasm | grep -A 50 "func_name"

如果有 name section,函数名直接显示。否则只能看到 func[42] 这种索引。

2.18.4 层 3:数据流分析

工具:

工具功能
Ghidra(NSA)通用反向工程,支持 WASM
IDA Pro商业,WASM 插件
Binary Ninja商业,WASM 支持
wasmer-wabt开源辅助

Ghidra 是免费选择——能做:

  • 函数边界识别
  • 变量追踪
  • 调用图分析
  • 常量字符串提取

2.18.5 层 4:反编译到伪代码

最难层级——把 WASM 字节码转回 C/Rust 风格代码:

工具:

  • wabt-wasm-decompile:实验性,输出 "wasm-decompile" 风格
  • Ghidra decompiler:支持 WASM
  • wasm2c:把 WASM 翻译为 C 代码(不是反编译,但对分析有帮助)

2.18.6 反向工程的实战场景

每个场景都需要不同深度的反向能力——安全审计可能只需层 1-2,技术分析需要层 3-4。

2.18.7 防止反向的策略

每条都增加反向难度——但都不能完全防止。WASM 字节码本质是公开的,只能"提高门槛"而非"禁止反向"。

商业秘密不应放在客户端 WASM——应该在服务端。

2.18.8 反向工程的伦理

反向工程的合法性视情况——做之前必须确认法律边界。本书介绍技术,但提醒读者承担法律责任。

2.18.9 学习反向工程的路径

学习路径渐进——从工具操作到实战分析。CTF 比赛的 WASM 题是练习的好材料。

2.18.10 与传统二进制反向的对比

维度原生二进制(ELF/Mach-O)WASM
工具成熟度极高(Ghidra/IDA)中等
反汇编质量极好(结构化指令)
反编译质量中等早期
控制流恢复难(goto)简单(结构化)
数据布局推断中等

WASM 的反汇编比原生二进制简单——结构化控制流让分析容易。但反编译工具仍在发展。

2.18.11 给开发者的启示

每条都对应实际场景——WASM 不是黑盒,开发者应该有这种心智模型。把"不可读"作为安全前提是危险的——必须假设 .wasm 内容可被分析。

理解 WASM 反向工程的能力和边界,让你既能在需要时分析 .wasm,也能在保护代码时做出合理的工程决策。

2.19 本章小结

本章从三个维度拆解了 WASM 规范的核心:

二进制格式:模块由 header + 段组成,段按 ID 递增排列,LEB128 编码实现紧凑性,Custom 段携带调试和元数据信息。理解二进制格式是体积优化(第 9 章)和工具链调试(第 6-8 章)的基础。

栈式指令集:WASM 选择栈机而非寄存器机,是为了编译简单、二进制紧凑、验证简单。真实执行时由 JIT 编译器做栈消除和寄存器分配。结构化控制流排除了 ROP 攻击,是 WASM 安全模型的核心。

类型系统:MVP 的四种值类型(i32/i64/f32/f64)是 Rust 类型映射到 WASM 的物理基础。函数类型定义签名,引用类型扩展引入 funcrefexternref,验证器通过类型栈实现 O(n) 的安全检查。

下一章深入线性内存和表——这是理解 WASM 内存模型以及 Rust 所有权系统如何映射到 WASM 的关键。线性内存是 WASM 的"物理世界",所有数据都生活在其中;表是间接调用的基础设施,dyn Trait 和函数指针都通过表实现。

基于 VitePress 构建