Rust + WebAssembly 全链路解析
第2章 WebAssembly 规范:二进制格式与指令集
第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)是唯一可以出现多次的段,且可以出现在任何位置(只要不破坏其他段的递增顺序)。它的用途是携带不属于核心规范的数据:
- 名称段(
namesection):存储函数名、局部变量名,用于调试和错误信息。wasm-opt --strip-name可以删除这个段以减小体积。 - 源码映射段(
sourceMappingURL):存储 DWARF 调试信息的 URL,浏览器 DevTools 据此加载调试信息。 - Producers 段:记录编译器、语言、工具链的版本信息。类似 JavaScript 的
//@ sourceURL注释。
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 字符 \0、a、s、m),4 字节版本号 1(小端序 u32)。
魔数的设计有几个用意:
- 快速识别:文件管理器和 HTTP 服务器可以用魔数识别
.wasm文件,不需要依赖扩展名。RFC 中注册的 MIME 类型application/wasm也基于这个魔数。 - 安全性:
\0开头防止文件被误认为 HTML 或 JavaScript(HTML 以<开头,JavaScript 不以\0开头),降低了内容嗅探(content sniffing)攻击的风险。 - 版本协商:版本号让运行时可以拒绝不兼容的未来版本,而不是尝试解析后崩溃。
目前 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
注意几个关键点:
- Type 段和 Code 段是分离的。Type 段定义函数签名,Code 段定义函数体,Function 段把它们关联起来。这种分离让多个函数可以共享同一个签名——如果
add和mul都是(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 完全问题。
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 转为 .wat,wat2wasm 把 .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_s,u32::wrapping_div 对应 i32.div_u。
浮点运算遵循 IEEE 754 规范:f32.add 和 f64.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 的对齐 mov 比 movdqu 快)。但验证器不强制要求对齐——对齐只是提示,即使声明了 align=2 但实际地址不对齐,也不会导致验证失败,只是运行时可能较慢。
变量操作指令
WASM 的变量分为局部变量和全局变量:
- local.get/set/tee:操作函数的局部变量(包括参数)。参数从索引 0 开始,局部变量从参数数量之后开始。
local.tee是local.set+local.get的组合——设置值但不弹出栈顶。 - global.get/set:操作模块的全局变量。全局变量可以是
mut(可变)或const(不可变)。导入的全局变量也可以是mut或const,但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 的区别
block 和 loop 的区别在于 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 块的执行结果类型在 if 的 result 子句中声明——这里是 (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.eq、i32.lt_s 等比较指令返回 i32(0 或 1),if、br_if 接受 i32 作为条件。这与 C 语言的布尔模型一致,但比 Rust 的 bool 类型原始——Rust 的 bool 在编译到 WASM 后变成 i32,但 Rust 编译器保证它只持有 0 或 1。
WASM 没有字符串类型。字符串必须编码为线性内存中的字节序列,通过指针(i32 偏移量)+ 长度(i32)来引用。这是 wasm-bindgen 的 JsValue 机制存在的原因——它需要在 JS 侧和 WASM 侧之间传递字符串,而 WASM 没有原生的字符串表示。
WASM 没有结构体类型(在 MVP 中)。复合类型需要手动在内存中布局,与 C 的 ABI 模型一致。WasmGC 提案引入了 struct 和 array 类型,但这是 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-bindgen 的 JsValue 在 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!、alloc 在 wasm32-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> 等复杂数据类型。如果模块没有导出 memory,wasm-bindgen 生成的 JS 代码会直接报错。
2.11 函数体编码
每个函数体由三部分组成:
- 局部变量声明:类型 + 数量的列表
- 指令序列:栈式指令
- 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"
数据段有两种模式:
- active 数据段:在实例化时自动将数据写入线性内存的指定偏移。上面示例就是 active 的——
i32.const 0指定偏移量,"hello"在实例化时自动写入地址 0-4。 - passive 数据段:不在实例化时写入,而是由
memory.init指令在运行时按需写入。passive 数据段配合data.drop指令使用,写入后可以丢弃数据段以释放内存。这是体积优化的重要手段——第 9 章会详细讨论。
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 要求栈顶两个值都是 i32,i32.load 要求地址是 i32,call 的参数类型和数量必须与目标函数签名一致。
验证过程示例:
栈状态: [i32, i32]
执行 i32.add
消费: 2 个 i32
生产: 1 个 i32
栈状态: [i32]
✓ 类型一致
栈状态: [i32, f64]
执行 i32.add
消费: 2 个 i32
✗ 栈顶不是 i32 (是 f64)
验证失败
2. 控制流结构化:每个 block/loop/if 必须有对应的 end;br 的标签深度不能超出当前嵌套层数。结构化控制流保证了不存在跳转到函数体中间的可能。
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 运行时可以做出以下保证:
- 没有未定义行为:WASM 规范定义了每条指令在每种输入下的行为。即使触发 trap(如除以零、越界访问),行为也是确定的——trap 传播到调用者,直到被宿主捕获。
- 没有内存越界:所有内存访问都在线性内存的 [0, memory.size) 范围内。超出范围会触发 trap,而不是未定义行为。
- 没有非法控制流转移:所有跳转都指向
block/loop/if的边界,所有call都指向有效的函数索引。 - 栈不会下溢:类型栈的验证保证了每条指令执行时栈上有足够的操作数。
这些保证让 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_malloc 或 dlmalloc |
线性内存中的堆分配器 |
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 侧的 true 是 1(i32),不是 0x01(i8)。
graph TD
subgraph "Rust 类型系统"
R1["i32 / u32 / bool"]
R2["&T / &mut T"]
R3["String / Vec<T>"]
R4["struct / enum"]
R5["dyn Trait / fn pointer"]
R6["Result<T, E>"]
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
每个自定义段有:
- ID = 0 标识
- 段大小(payload)
- 段名(区分不同自定义段)
- 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
每个场景的工程价值:
- 调试支持:name + DWARF 让生产 trap 报错可读,问题定位时间从小时降到分钟
- 供应链验证:嵌入构建签名,下游验证未被篡改
- 元数据传播:组件模型把 WIT 接口存在自定义段,运行时反序列化
- 条件编译:运行时检测 target_features,决定走 SIMD 还是标量路径
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
每条注意点:
- 段名冲突:用
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 的设计哲学
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 的关键特性:
- 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 的实战用途
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 进入主流
每个阶段都有特定主题:
- 2015-2017:从 asm.js 提炼到 MVP,重点是"能跑"
- 2018-2020:扩展提案补足关键能力
- 2021-2024:高级类型系统(组件模型)
- 2025+: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
每个前辈的教训:
- NaCl:技术好但单家推动,没有跨厂商共识
- asm.js:跨平台但性能不够,文本格式 parse 慢
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
工具:
- wabt-wasm-decompile:实验性,输出 "wasm-decompile" 风格
- Ghidra decompiler:支持 WASM
- wasm2c:把 WASM 翻译为 C 代码(不是反编译,但对分析有帮助)
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 的物理基础。函数类型定义签名,引用类型扩展引入 funcref 和 externref,验证器通过类型栈实现 O(n) 的安全检查。
下一章深入线性内存和表——这是理解 WASM 内存模型以及 Rust 所有权系统如何映射到 WASM 的关键。线性内存是 WASM 的"物理世界",所有数据都生活在其中;表是间接调用的基础设施,dyn Trait 和函数指针都通过表实现。