Appearance
第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 | 名称 | 内容 | 是否必须 |
|---|---|---|---|
| 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注释。
⚠️ 生产环境中,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 二进制格式的完整理解。
注意几个关键点:
- 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 完全问题。
二进制紧凑:栈式指令不需要指定操作数寄存器。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 函数:
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.。同一运算在不同类型上有不同的操作码:
| 运算 | 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 跳转的目标:
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
endWAT 文本格式允许使用命名标签($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 块的执行结果类型在 if 的 result 子句中声明——这里是 (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 类型 | 二进制编码 | 说明 |
|---|---|---|---|---|
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 侧的映射表。
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导入的常见模式:
| 模块名 | 函数名 | 用途 |
|---|---|---|
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 目标上都需要宿主提供实现。
导出
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> 等复杂数据类型。如果模块没有导出 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):
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 要求栈顶两个值都是 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 字节)。
验证的安全保证
验证通过后,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)。
2.15 自定义段:WASM 的元数据扩展机制
WASM 二进制由"段"(section)组成——前面章节介绍的 Type、Function、Code 等是已知段(known sections,section ID 1-12)。规范还预留了 ID=0 的自定义段(custom section),让工具链嵌入任意元数据而不影响执行。这是 WASM 生态扩展的核心机制。
2.15.1 自定义段的二进制布局
每个自定义段有:
- 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 检查自定义段
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 与其他二进制格式的对比
| 维度 | 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
每个前辈的教训:
- 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.watWAT 完整保留指令信息——但变量名通常没有(除非有 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 的物理基础。函数类型定义签名,引用类型扩展引入 funcref 和 externref,验证器通过类型栈实现 O(n) 的安全检查。
下一章深入线性内存和表——这是理解 WASM 内存模型以及 Rust 所有权系统如何映射到 WASM 的关键。线性内存是 WASM 的"物理世界",所有数据都生活在其中;表是间接调用的基础设施,dyn Trait 和函数指针都通过表实现。