Rust + WebAssembly 全链路解析

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

作者 杨艺韬 · 14,414 字

第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 代码变成浏览器执行的机器码,经过的路径比编译到原生平台多一层抽象:

flowchart LR
    A[Rust 源码] --> B[HIR]
    B --> C[MIR]
    C --> D[LLVM IR]
    D --> E[wasm32 目标代码]
    E --> F[".wasm 二进制"]
    F --> G[浏览器/Wasmtime 解码]
    G --> H[验证]
    H --> I[编译为机器码]
    I --> J[执行]

    style F fill:#6366f1,color:#fff
    style H fill:#f59e0b,color:#fff
    style J fill:#10b981,color:#fff

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

段必须按 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)是唯一可以出现多次的段,且可以出现在任何位置(只要不破坏其他段的递增顺序)。它的用途是携带不属于核心规范的数据:

graph TD
    subgraph "Custom 段的用途"
        C1["name 段: 函数名/变量名"]
        C2["sourceMappingURL: DWARF 调试信息"]
        C3["producers 段: 编译器版本"]
        C4["自定义元数据: 工具链扩展"]
    end

    subgraph "对体积的影响"
        V1["保留: 调试友好, 体积增大"]
        V2["删除: 体积减小, 失去调试信息"]
    end

    C1 --> V1
    C1 --> V2

    style V1 fill:#f59e0b,color:#fff
    style V2 fill:#10b981,color:#fff

⚠️ 生产环境中,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 二进制格式的完整理解。

graph TD
    subgraph "35 字节的 .wasm 模块"
        H["Header: 8 bytes (magic + version)"]
        T["Type Section: 9 bytes (函数签名)"]
        F["Function Section: 4 bytes (函数索引)"]
        E["Export Section: 9 bytes (导出声明)"]
        C["Code Section: 11 bytes (函数体)"]
    end

    H --> T --> F --> E --> C

    T -.->|"定义: (i32,i32)->i32"| C
    F -.->|"索引: 函数0用类型0"| T
    E -.->|"导出: 函数0名为add"| F

    style H fill:#6366f1,color:#fff
    style T fill:#22d3ee,color:#fff
    style C fill:#f59e0b,color:#fff

注意几个关键点:

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 完全问题。

flowchart LR
    subgraph "SSA IR"
        A["v1 = arg0"]
        B["v2 = arg1"]
        C["v3 = add v1, v2"]
        D["return v3"]
    end

    subgraph "栈式 WASM"
        E["local.get 0"]
        F["local.get 1"]
        G["i32.add"]
        H["return"]
    end

    A --> E
    B --> F
    C --> G
    D --> H

    style C fill:#6366f1,color:#fff
    style G fill:#10b981,color:#fff

二进制紧凑:栈式指令不需要指定操作数寄存器。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 函数:

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

编译为 WAT(文本格式):

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

graph TD
    A[WASM 指令集] --> B[控制流]
    A --> C[算术运算]
    A --> D[内存访问]
    A --> E[变量操作]
    A --> F[类型转换]

    B --> B1["block / loop / if-else"]
    B --> B2["br / br_if / br_table"]
    B --> B3["call / call_indirect"]
    B --> B4["return / unreachable"]

    C --> C1["i32: add sub mul div_s/u rem_s/u"]
    C --> C2["i64: add sub mul div_s/u rem_s/u"]
    C --> C3["f32/f64: add sub mul div sqrt"]
    C --> C4["位运算: and or xor shl shr rotl rotr"]

    D --> D1["i32.load / i32.store"]
    D --> D2["i64.load / i64.store"]
    D --> D3["f32.load / f32.store / f64.load / f64.store"]
    D --> D4["load8_s / load8_u / store8 等"]

    E --> E1["local.get / local.set"]
    E --> E2["local.tee"]
    E --> E3["global.get / global.set"]

    F --> F1["i32.wrap_i64"]
    F --> F2["i64.extend_i32_s / i64.extend_i32_u"]
    F --> F3["f32.demote_f64 / f64.promote_f32"]
    F --> F4["i32.trunc_f32_s / f32.convert_i32_s 等"]

    style A fill:#6366f1,color:#fff

算术运算指令

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

运算 i32 i64 f32 f64
add 0x6A 0x7C 0x92 0xA0
sub 0x6B 0x7D 0x93 0xA1
mul 0x6C 0x7E 0x94 0xA2
div 0x6D/0x6E (s/u) 0x7F/0x80 (s/u) 0x95 0xA3

注意整数除法区分有符号(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 的变量分为局部变量和全局变量:

⚠️ 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 跳转的目标:

;; 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
flowchart TD
    subgraph "block $exit"
        B1["br $exit"] --> B2["i32.const 42 (不可达)"]
        B2 --> B3["end"]
        B1 -.->|"br 跳到 end 之后"| B4["end 之后的指令"]
    end

    subgraph "loop $continue"
        L1["i32.const 1"] --> L2["br_if $continue"]
        L2 -->|"条件为真"| L1
        L2 -->|"条件为假"| L3["end"]
    end

    style B4 fill:#10b981,color:#fff
    style L1 fill:#6366f1,color:#fff

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

分支标签深度

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

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 的完整示例

;; 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 只能调用函数索引表中的函数——无法跳转到函数体中间的任意位置。

flowchart LR
    subgraph "x86: 任意跳转(ROP 可行)"
        X1[函数 A 的入口] --> X2["jmp 任意地址"]
        X3[函数 B 的中间] --> X4["jmp 任意地址"]
    end

    subgraph "WASM: 结构化跳转(ROP 不可行)"
        W1["call 函数索引"] --> W2["函数边界"]
        W3["br 标签深度"] --> W4["block/loop/if 边界"]
    end

    style X2 fill:#ef4444,color:#fff
    style X4 fill:#ef4444,color:#fff
    style W2 fill:#10b981,color:#fff
    style W4 fill:#10b981,color:#fff

2.9 值类型与函数类型

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

基本值类型

类型 位数 对应 Rust 类型 二进制编码 说明
i32 32 i32, u32 0x7F 有符号/无符号整数,指令区分符号
i64 64 i64, u64 0x7E 64 位整数
f32 32 f32 0x7D 32 位 IEEE 754 浮点
f64 64 f64 0x7C 64 位 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)引入了两种新的值类型:

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

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

graph LR
    subgraph "externref 之前"
        A1["JS 对象"] -->|"存入 idx 表"| A2["JS 侧全局映射表"]
        A2 -->|"传 i32 索引"| A3["WASM 模块"]
        A3 -->|"返回 i32 索引"| A2
        A2 -->|"查出对象"| A4["JS 侧使用"]
    end

    subgraph "externref 之后"
        B1["JS 对象"] -->|"直接传 externref"| B2["WASM 模块"]
        B2 -->|"直接返回 externref"| B3["JS 侧使用"]
    end

    style A2 fill:#ef4444,color:#fff
    style B2 fill:#10b981,color:#fff

2.10 导入与导出

WASM 模块不是自包含的——它通过导入(import)从宿主获取函数、表、内存和全局变量,通过导出(export)向宿主暴露自己的功能。这是 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

导入的常见模式:

模块名 函数名 用途
env abort Rust 的 panic 处理
env __wbindgen_malloc wasm-bindgen 的内存分配
env __wbindgen_free wasm-bindgen 的内存释放
env __wbindgen_exn_store wasm-bindgen 的异常存储
wasi_snapshot_preview1 fd_write WASI preview 1 的文件写入
wasi_snapshot_preview1 random_get WASI preview 1 的随机数

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

导出

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

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

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 编译产物导出列表:

graph LR
    subgraph "WASM 模块导出"
        A["用户函数: add, process"]
        B["memory: 线性内存"]
        C["__wbindgen_malloc: 分配器"]
        D["__wbindgen_free: 释放器"]
        E["__wbindgen_exn_store: 异常存储"]
    end

    subgraph "JS 侧使用"
        F["直接调用用户函数"]
        G["通过 memory 访问复杂数据"]
        H["wasm-bindgen 内部使用"]
    end

    A --> F
    B --> G
    C --> H
    D --> H
    E --> H

⚠️ 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)

(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 数据段与元素段

数据段

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

(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"

数据段有两种模式:

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

元素段

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

(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 字节)。

flowchart TD
    A[".wasm 二进制"] --> B{解码}
    B -->|格式错误| C[加载失败: 无效的 LEB128 / 段顺序错误]
    B -->|成功| D{验证}
    D -->|类型不一致| E[加载失败: type mismatch]
    D -->|控制流非法| F[加载失败: malformed control flow]
    D -->|内存越界| G[加载失败: out of bounds]
    D -->|通过| H{编译}
    H --> I[机器码]
    I --> J{实例化}
    J -->|缺少导入| K[实例化失败: missing import]
    J -->|成功| L[执行]

    style D fill:#f59e0b,color:#fff
    style I fill:#10b981,color:#fff
    style L fill:#10b981,color:#fff
    style C fill:#ef4444,color:#fff
    style E fill:#ef4444,color:#fff
    style F fill:#ef4444,color:#fff
    style G fill:#ef4444,color:#fff
    style K fill:#ef4444,color:#fff

验证的安全保证

验证通过后,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 值类型 直接映射
bool i32 0 或 1,占 4 字节(WASM 没有 1 字节值类型)
&[u8] 切片 指针(i32) + 长度(i32) 两个参数 胖指针拆成两个 i32
String 线性内存中的字节序列 + 指针(i32)/长度(i32) 不能直接跨边界传递
struct 线性内存中的字段布局(和 C ABI 一致) 无 WASM 原生结构体类型
enum 标签(i32) + 联合体,编译为内存中的字节序列 无 WASM 原生枚举类型
dyn Trait 表(funcref) + vtable call_indirect 间接调用
fn 指针 表索引(funcref) 通过 call_indirect 调用
panic! 调用导入的 abort / __rust_start_panic wasm-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)。

graph TD
    subgraph "Rust 类型系统"
        R1["i32 / u32 / bool"]
        R2["&T / &mut T"]
        R3["String / Vec&lt;T&gt;"]
        R4["struct / enum"]
        R5["dyn Trait / fn pointer"]
        R6["Result&lt;T, E&gt;"]
    end

    subgraph "WASM 表示"
        W1["i32 值类型"]
        W2["i32 (线性内存偏移)"]
        W3["i32 指针 + i32 长度 (线性内存中)"]
        W4["线性内存中的字节布局"]
        W5["funcref (表索引) + call_indirect"]
        W6["标签 + 值 (线性内存中)"]
    end

    R1 --> W1
    R2 --> W2
    R3 --> W3
    R4 --> W4
    R5 --> W5
    R6 --> W6

    style R3 fill:#f59e0b,color:#fff
    style W3 fill:#f59e0b,color:#fff

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

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

2.15.1 自定义段的二进制布局

graph LR
  A["WASM 二进制"] --> B["已知段<br/>ID 1-12"]
  A --> C["自定义段<br/>ID 0"]

  C --> C1["section_id = 0"]
  C --> C2["section_size: u32 leb128"]
  C --> C3["name_length: u32 leb128"]
  C --> C4["name: bytes(UTF-8)"]
  C --> C5["payload: bytes"]

  style C fill:#10b981,color:#fff

每个自定义段有:

  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_line DWARF 调试信息 源码级调试
core 段(Component Model) 组件元数据 组件实例化

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

2.15.3 用 wasm-tools 检查自定义段

# 列出所有段
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:

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

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

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 自定义段的工程应用

graph TD
  A["自定义段使用场景"] --> B["调试支持"]
  A --> C["供应链验证"]
  A --> D["元数据传播"]
  A --> E["条件编译"]

  B --> B1["DWARF 调试信息<br/>name 段函数名"]
  C --> C1["签名段<br/>构建时间戳<br/>git commit"]
  D --> D1["组件模型 WIT<br/>类型信息"]
  E --> E1["target_features<br/>运行时验证"]

  style B fill:#10b981,color:#fff
  style C fill:#6366f1,color:#fff

每个场景的工程价值:

2.15.6 注意事项

graph TD
  A["自定义段使用注意"] --> B["1. 段名冲突<br/>用反向域名规范"]
  A --> C["2. 体积影响<br/>每段都增加 .wasm 大小"]
  A --> D["3. 不能影响执行<br/>引擎可能优化掉"]
  A --> E["4. 跨工具兼容<br/>Wasmtime 与浏览器可能行为不同"]

  style A fill:#f59e0b,color:#fff

每条注意点:

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

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

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

2.16.1 WAT 的设计哲学

graph TD
  A["WAT 设计目标"] --> B["人类可读"]
  A --> C["1:1 映射二进制"]
  A --> D["S-expression 语法"]
  A --> E["编辑器友好"]

  B --> B1["每条指令一行"]
  C --> C1["wasm2wat / wat2wasm 双向无损"]
  D --> D1["LISP 风格<br/>嵌套清晰"]
  E --> E1["语法高亮 + LSP 支持"]

  style A fill:#10b981,color:#fff

WAT 的关键特性:

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 的实战用途

graph TD
  A["WAT 用途"] --> B["调试"]
  A --> C["手写测试"]
  A --> D["性能分析"]
  A --> E["教学"]

  B --> B1["看编译产物是否符合预期"]
  C --> C1["精确控制 .wasm 字节内容"]
  D --> D1["WAT 看哪些指令被生成"]
  E --> E1["教学时展示具体字节码"]

  style A fill:#6366f1,color:#fff

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

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

// 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 的场景

// 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 的限制

graph TD
  A["WAT 不擅长"] --> B["1. 大型项目<br/>太冗长"]
  A --> C["2. 高级语言特性<br/>循环用 br_table 不直观"]
  A --> D["3. 库依赖<br/>没有包管理"]
  A --> E["4. 运行时性能<br/>没意义,跟二进制一样"]

  style A fill:#ef4444,color:#fff

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

2.16.8 学习路径

flowchart LR
  A["从 WAT 入手"] --> B["1. 跑 wasm2wat 看 hello world"]
  B --> C["2. 阅读 wat-style 章节"]
  C --> D["3. 对比 Rust 源码与 WAT 输出"]
  D --> E["4. 手写小型 WAT 测试"]
  E --> F["5. 高级:阅读规范测试套件"]

  style A fill:#10b981,color:#fff
  style F fill:#6366f1,color:#fff

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

2.16.9 与现代工具链的协作

graph LR
  A["WAT 生态"] --> B["wat2wasm(wabt)"]
  A --> C["wasm2wat(wabt)"]
  A --> D["wasm-tools print(Bytecode Alliance)"]
  A --> E["VSCode WAT 扩展"]
  A --> F["Vim / Emacs 语法高亮"]

  style A fill:#6366f1,color:#fff

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

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

2.17 WASM 二进制规范的演进史

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

2.17.1 关键里程碑

timeline
    title WASM 规范演进
    2015 : WebAssembly CG 成立<br/>四大浏览器联合
    2017 : MVP 1.0 发布<br/>四浏览器同步支持
    2018 : 多个提案并行(SIMD/threads/exception-handling)
    2019 : sign-extension 等小提案合并
    2020 : multi-value/bulk-memory 进入主流
    2022 : SIMD 进入 W3C 推荐<br/>所有浏览器默认启用
    2023 : 组件模型规范初版<br/>WASI Preview 2 工作组成立
    2024 : 组件模型 Phase 1 + WASI P2 发布
    2025 : GC types 候选稳定
    2026 : Component Model + GC 进入主流

每个阶段都有特定主题:

2.17.2 设计决策的历史背景

graph TD
  A["WASM 设计决策"] --> B["栈式指令"]
  A --> C["模块化"]
  A --> D["类型安全"]
  A --> E["最小化"]

  B --> B1["从 JVM/CLR 借鉴<br/>但更简单"]
  C --> C1["从 ELF/Mach-O 借鉴<br/>但抽象层更高"]
  D --> D1["从 .NET 借鉴<br/>+ 更严格的验证"]
  E --> E1["asm.js 失败的教训<br/>不内置高级类型"]

  style A fill:#10b981,color:#fff

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

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

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

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

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

graph TD
  A["WASM 的前辈"] --> B["NaCl (2008-2017)"]
  A --> C["asm.js (2013-2017)"]

  B --> B1["✓ 性能强<br/>✗ 仅 Chrome<br/>✗ 不跨平台"]
  C --> C1["✓ 跨浏览器<br/>✗ 字节码大<br/>✗ 解析慢"]

  D["WASM"] --> D1["综合两者优势<br/>+ 标准化"]

  style B fill:#ef4444,color:#fff
  style C fill:#f59e0b,color:#fff
  style D fill:#10b981,color:#fff

每个前辈的教训:

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

2.17.5 提案流程的演进

flowchart LR
  A["阶段 0<br/>idea"] --> B["阶段 1<br/>proposal"]
  B --> C["阶段 2<br/>spec"]
  C --> D["阶段 3<br/>impl"]
  D --> E["阶段 4<br/>standardize"]

  style E fill:#10b981,color:#fff

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

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

2.17.6 演进的工程影响

graph TD
  A["规范演进对工程的影响"] --> B["新提案落地<br/>工具链跟进"]
  A --> C["浏览器支持<br/>影响最低支持版本"]
  A --> D["生产升级路径<br/>需要规划"]

  B --> B1["wasm-pack / Wasmtime 几月跟新"]
  C --> C1["最低 Chrome 91+ 还是 113+?"]
  D --> D1["旧产物兼容期"]

  style A fill:#6366f1,color:#fff

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

2.17.7 历史教训

WASM 演进的几个关键教训:

graph TD
  A["WASM 规范演进的教训"] --> B["1. 跨厂商共识必备"]
  A --> C["2. MVP 越小越好"]
  A --> D["3. 提案要严谨流程"]
  A --> E["4. 不被旧选择绑架"]

  B --> B1["四浏览器+四家公司<br/>vs NaCl 单家"]
  C --> C1["MVP 仅 4 类型 + 必要指令<br/>vs JVM 一次塞太多"]
  D --> D1["5 阶段制度化<br/>vs HTML 早期混乱"]
  E --> E1["MVP 没有 String/Array<br/>留空间给后续提案"]

  style A fill:#10b981,color:#fff

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

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

flowchart LR
  A["2026"] --> B["GC types"]
  A --> C["组件模型主流"]

  D["2028"] --> E["WASM 2.0?"]
  D --> F["更多 WASI 标准接口"]

  G["2030+"] --> H["WASM 在云原生事实标准"]
  G --> I["与 GPU/AI 深度集成"]

  style A fill:#10b981,color:#fff
  style G fill:#6366f1,color:#fff

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

2.17.9 阅读规范的工程价值

flowchart TD
  A["读 WASM 规范的价值"] --> B["1. 理解工具链行为"]
  A --> C["2. 调试难题"]
  A --> D["3. 评估新提案"]
  A --> E["4. 自己写工具/runtime"]

  style A fill:#10b981,color:#fff

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

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

2.18 WASM 二进制的反向工程

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

2.18.1 反向工程的层次

graph TD
  A["WASM 反向工程层次"] --> B["层 1:反汇编"]
  A --> C["层 2:函数识别"]
  A --> D["层 3:数据流分析"]
  A --> E["层 4:源语言重建"]

  B --> B1["wasm2wat<br/>看每条指令"]
  C --> C1["边界识别函数<br/>命名段还原"]
  D --> D1["IR 分析<br/>变量追踪"]
  E --> E1["反编译到 C-like 伪代码"]

  style B fill:#10b981,color:#fff
  style E fill:#f59e0b,color:#fff

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

2.18.2 层 1:反汇编

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

wasm2wat my.wasm > my.wat

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

2.18.3 层 2:函数识别

# 看函数列表
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 风格代码:

flowchart LR
  A[".wasm"] --> B["反汇编 WAT"]
  B --> C["IR 提升"]
  C --> D["控制流恢复"]
  D --> E["类型恢复"]
  E --> F["伪代码输出"]

  style F fill:#f59e0b,color:#fff

工具:

2.18.6 反向工程的实战场景

graph TD
  A["反向工程场景"] --> B["安全审计"]
  A --> C["第三方分析"]
  A --> D["性能调试"]
  A --> E["合规检查"]

  B --> B1["看 .wasm 是否含恶意逻辑"]
  C --> C1["分析竞品 WASM 实现"]
  D --> D1["性能瓶颈逆向定位"]
  E --> E1["确认许可证 / 隐私"]

  style A fill:#6366f1,color:#fff

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

2.18.7 防止反向的策略

flowchart TD
  A["如果想保护代码"] --> B["1. 移除 name section"]
  A --> C["2. 移除 producers"]
  A --> D["3. wasm-opt 优化(混淆副作用)"]
  A --> E["4. 控制流混淆"]
  A --> F["5. 加密关键数据"]

  style A fill:#f59e0b,color:#fff

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

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

2.18.8 反向工程的伦理

flowchart TD
  A["反向工程的法律边界"] --> B["合法"]
  A --> C["灰色"]
  A --> D["违法"]

  B --> B1["开源代码<br/>合规审计<br/>互操作研究"]
  C --> C1["竞品分析<br/>视协议条款"]
  D --> D1["盗用商业代码<br/>破解付费功能"]

  style B fill:#10b981,color:#fff
  style D fill:#ef4444,color:#fff

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

2.18.9 学习反向工程的路径

flowchart LR
  A["从基础学起"] --> B["1. wasm2wat 反汇编"]
  B --> C["2. 学习 WAT 语法"]
  C --> D["3. Ghidra 入门"]
  D --> E["4. CTF / WASM 挑战赛"]
  E --> F["5. 真实样本分析"]

  style A fill:#10b981,color:#fff
  style F fill:#6366f1,color:#fff

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

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

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

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

2.18.11 给开发者的启示

flowchart TD
  A["WASM 反向工程的启示"] --> B["1. 不要假设 .wasm 不可读"]
  A --> C["2. 商业秘密放服务端"]
  A --> D["3. 客户端代码要假设公开"]
  A --> E["4. License 通过签名验证"]
  A --> F["5. 反作弊用混淆 + 后端校验"]

  style A fill:#6366f1,color:#fff

每条都对应实际场景——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 和函数指针都通过表实现。