Rust + 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:#333
style J fill:#10b981,color:#333
关键区别在于第 6-10 步。原生编译是"源码 → 机器码"一步到位,WASM 插入了一个中间表示——.wasm 二进制。这个二进制不是任何真实 CPU 的机器码,而是一种虚拟指令集(virtual ISA)。浏览器(或 Wasmtime 等运行时)接收 .wasm,验证其合法性,再编译为当前平台的机器码。
为什么要插入一层虚拟 ISA?两个字的回答:可移植性 + 可验证性。
原生机器码无法验证——x86 的 mov 指令本身不携带类型信息,你无法在加载时判断一段机器码是否安全。WASM 每条指令的操作数类型都能在加载时静态确定,验证器能在执行前确保类型安全和控制流安全,内存访问则由运行时的边界检查兜底。同时,同一个 .wasm 二进制可以在 x86-64、ARM64、RISC-V 上执行——因为虚拟 ISA 由运行时翻译为本地机器码。
这与《Rust 编译器与运行时揭秘》专栏第 16 章讨论的 LLVM 代码生成形成对比:原生编译时,LLVM IR 直接翻译为 x86/ARM 机器码;WASM 编译时,LLVM IR 翻译为 wasm32 虚拟指令,再由浏览器/Wasmtime 翻译为本地机器码。多一层抽象意味着多一层开销,但也意味着多一层安全保证和多一份可移植性。
2.2 模块结构:WASM 的顶层组织
一个 .wasm 文件在逻辑上是一个模块(Module),由多个段(Section)组成。WebAssembly 2.0 核心规范定义了 13 种段(ID 0-12):
| 段 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 | 内存初始化数据 | 否 |
| 12 | DataCount | Data 段的条目数(bulk memory 提案引入,用于让 Code 段单遍验证 data.drop) |
否 |
⚠️ 这张表停在 ID 12,是因为它对应 2.0。异常处理提案 2025 年 7 月在工作组表决完成标准化(finished-proposals 表,2026-09-24 核对),同年 9 月随 Wasm 3.0 并入核心规范,带进来第 14 种段——Tag 段(ID 13),用来声明可抛出的标签类型;它在段序里也不按 ID 排,而是插在 Memory(5) 和 Global(6) 之间(3.0 规范的模块二进制格式)。本章后面讨论的都是 Rust 工具链今天实际产出的那部分,Tag 段只有开了 Wasm EH 才会出现。
除 DataCount 和 Tag 外,段按 ID 递增顺序出现,每个段最多出现一次(Custom 段除外,可出现多次)。DataCount 是 2.0 里唯一的例外——它虽然 ID 最大,位置却排在 Element(9) 和 Code(10) 之间,正是为了让验证器在读 Code 段之前就知道 Data 段有多少条目。这种设计让解码器可以单遍(single-pass)解析——读一个段,处理一个段,不需要回头。单遍解析不仅简化了解码器实现,更重要的是让流式编译(streaming compilation)成为可能:V8 可以在 .wasm 文件还在下载时就开始解码和编译,等下载完成时编译也接近完成。
模块结构用伪代码表示:
Module ::=
header (\0asm + version)
Section* // 按 ID 递增排列(DataCount、Tag 例外),Custom 段可穿插
段的内部结构
所有段(包括 Custom 段)的外层编码格式是统一的:
Section ::=
section_id (1 字节,普通字节,不是 LEB128)
section_size (LEB128, 后续内容的字节数)
content (段的具体内容)
section_size 字段让解码器可以跳过不需要的段——比如只关心导出列表的工具读到 Code 段时可以按长度整段跳过,引擎也靠它跳过不认识的 Custom 段。Wasmtime 44 所用的 wasmparser(0.246.1)就在 Parser 上提供了 skip_section(),遇到 CodeSectionStart 时调用它即可不解析函数体。
Custom 段的特殊角色
Custom 段(ID 0)是唯一可以出现多次的段,且可以出现在任何位置(只要不破坏其他段的递增顺序)。它的用途是携带不属于核心规范的数据:
- 名称段(
namesection):存储模块名、函数名、局部变量名,用于调试和错误信息。wasm-opt默认就不会把它写进输出(要保留得显式加-g/--debuginfo),也可以用--strip-debug明确剥离。 - 源码映射段(
sourceMappingURL):存储一个 source map 的 URL,供 DevTools 把 WASM 位置映射回源码。注意它和 DWARF 是两条独立的路径——DWARF 调试信息走的是.debug_info、.debug_line等一组以.debug_开头的 Custom 段。 - Producers 段:记录编译器、语言、工具链的版本信息,用于生态统计和问题溯源。
graph TD
subgraph "Custom 段的用途"
C1["name 段: 函数名/变量名"]
C2["sourceMappingURL: source map 地址"]
C3["producers 段: 编译器版本"]
C4["自定义元数据: 工具链扩展"]
end
subgraph "对体积的影响"
V1["保留: 调试友好, 体积增大"]
V2["删除: 体积减小, 失去调试信息"]
end
C1 --> V1
C1 --> V2
style V1 fill:#f59e0b,color:#333
style V2 fill:#10b981,color:#333
⚠️ wasm-pack build --release 默认会跑一遍 wasm-opt -O(📄 wasm-pack src/manifest/mod.rs:295-306 的 default_release():wasm_opt: Some(CargoWasmPackProfileWasmOpt::Enabled(true)),而同文件 416 行把 Enabled(true) 翻译成参数 vec!["-O"];default_dev() 里 wasm_opt: None,dev 构建根本不跑 wasm-opt)。由于 wasm-opt 默认不带 -g,名称段在这一步就被丢掉了——这是 release 产物比 dev 小的原因之一,但远不是全部,真正的大头是 --release 的编译优化本身。
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 模块中大量小整数(类型索引、函数索引、局部变量数量、函数参数数量)用 1 字节就够了(操作码本身是固定的单字节,不走 LEB128)。大整数(内存大小、函数偏移、数据段长度)自动扩展到需要的字节数。由于模块里绝大多数整数都落在 1 字节能表示的范围内,相比固定 4 字节编码,LEB128 能省下可观的体积——对于一个"体积即延迟"的格式来说,这个节省意义重大。
UTF-8 字符串编码
WASM 中的字符串(导出名、导入模块名、自定义段名)用 UTF-8 编码,前置 LEB128 长度:
03 61 64 64
│ └───────┘
│ "add"
长度 = 3
这意味着导出名越长,二进制越大。wasm-bindgen 默认用 __wbindgen_ 前缀的内部符号——这些名字的长度直接影响 .wasm 体积。一个有 20 个导出函数的模块,每个函数名平均 15 字节,光是这些名字就占 300+ 字节。注意导出名、导入名属于模块接口,任何 strip 都删不掉;wasm-opt --strip-debug 删掉的是名称段里的内部函数名,代价是失去调试信息和可读的调用栈。
📐 设计权衡:名称段的体积 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文件,不需要依赖扩展名。IANA 注册的 MIME 类型application/wasm也配合这个魔数一起用。 - 安全性:
\0开头防止文件被误认为 HTML 或 JavaScript(HTML 以<开头,JavaScript 不以\0开头),降低了内容嗅探(content sniffing)攻击的风险。 - 版本协商:版本号让运行时可以拒绝不兼容的未来版本,而不是尝试解析后崩溃。
目前 W3C 规范只定义了版本 1。组件模型规范定义了新的二进制格式(同样以 0x00 0x61 0x73 0x6D 开头,后面 4 字节拆成 2 字节版本 0x0d 0x00 加 2 字节 layer 0x01 0x00,layer=1 表示组件、核心模块相当于 layer=0;段 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
总计 41 字节(8 + 9 + 4 + 9 + 11)——注意每段前面那两个字节是段 ID 和段长度,段长度只统计其后的内容。逐段拆解这个二进制,可以建立对 WASM 二进制格式的完整理解。
graph TD
subgraph "41 字节的 .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:#333
style C fill:#f59e0b,color:#333
注意几个关键点:
- 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 完全问题;放不进栈的值落到数量不设上限的 local 上即可。
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:#333
二进制紧凑:栈式指令不需要指定操作数寄存器。x86-64 的 add rax, rbx 需要 ModR/M 字节编码两个寄存器,而 WASM 的 i32.add 只需要一个操作码 0x6A——操作数隐式从栈顶获取。算术指令因此普遍是单字节操作码,这是 WASM 二进制能做到紧凑的直接原因之一。
验证简单:栈机的类型验证是结构化的——维护一个类型栈,每条指令的消费/生产规则是确定的。i32.add 要求栈顶两个值都是 i32,执行后弹出一个 i32(第二个操作数)、再弹出一个 i32(第一个操作数)、压入一个 i32(结果)。验证器只需跟踪栈深度和栈顶类型,O(n) 时间就能验证完整个模块。寄存器机的验证需要跟踪寄存器类型映射,复杂度显著更高。
栈机的代价
不是最优的执行模型:真实 CPU 是寄存器机,栈式代码在执行前需要经过"栈消除"(stack scheduling)——把虚拟栈位置映射到真实寄存器。这是浏览器 JIT 编译器(V8 的 TurboFan、SpiderMonkey 的 Ion)和独立运行时(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 函数:
#[unsafe(no_mangle)]
pub extern "C" fn multiply_add(a: i32, b: i32, c: i32) -> i32 {
a * b + c
}
用 Rust 1.96 以 --release 编译到 wasm32-unknown-unknown,再用 wasm-tools print 看到的 WAT(文本格式):
(func $multiply_add (param i32 i32 i32) (result i32)
local.get 1 ;; b
local.get 0 ;; a
i32.mul ;; a * b
local.get 2 ;; c
i32.add ;; a * b + c
)
a、b 的压栈顺序被 LLVM 对调了——乘法可交换,结果不变。对应二进制:
20 01 ;; local.get 1
20 00 ;; local.get 0
6C ;; i32.mul (0x6C)
20 02 ;; local.get 2
6A ;; i32.add (0x6A)
这个映射几乎是机械的:每个 Rust 表达式的求值结果压栈,二元运算符弹出两个操作数、压入结果。LLVM 的 WebAssembly 后端先按普通虚拟寄存器生成代码,再由 RegStackify 这个 pass 把"定义后紧接着只用一次"的值改成直接留在值栈上,其余的落到 local——不需要为有限的物理寄存器做分配。
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 除法最终落到 i32.div_s,u32 除法落到 i32.div_u。但两边的边界语义不同:i32.div_s 遇到除零或 i32::MIN / -1 会直接 trap,而 Rust 要求除零 panic、wrapping_div(i32::MIN, -1) 返回 i32::MIN,所以 rustc 会在除法指令前插入相应的检查。
浮点运算遵循 IEEE 754 规范:f32.add 和 f64.add 在默认舍入模式(round-to-nearest-ties-to-even)下的结果与硬件浮点运算一致。唯一的不确定性在 NaN 的位模式——规范只约束结果属于哪一类 NaN(规范 NaN 或算术 NaN),符号位和具体 payload 允许因实现而异,所以同一段代码在不同引擎上产生的 NaN 位模式可能不同。依赖 NaN 具体位模式的代码是不可移植的。
内存访问指令
内存访问指令的编码格式统一:
i32.load offset=O align=A ;; 从线性内存加载 32 位整数
i32.store offset=O align=A ;; 向线性内存存储 32 位整数
这两个立即数合称 memarg。WAT 里必须先写 offset 再写 align(反过来 wasm-tools parse 直接报语法错误);二进制里的顺序则反过来,先 align 后 offset,都用 LEB128 编码。
操作数从栈上获取:i32.load 弹出一个 i32 作为地址,计算 address + offset,从线性内存读取 4 字节,压入结果。i32.store 弹出一个 i32 作为值,再弹出一个 i32 作为地址,计算 address + offset,将值写入线性内存。
alignment 字段是一个优化提示。二进制里存的是以 2 为底的指数:i32.load 的自然对齐 4 字节编码为 2(2^2 = 4);WAT 里写的却是字节数,i32.load align=4 才对应二进制的 2,写 align=2 表示只保证 2 字节对齐(编码为 1)。运行时可以利用对齐信息生成更高效的机器码(比如在非对齐访问代价高的架构上)。但对齐只是提示:验证器只检查声明的 align 不超过自然对齐,不检查实际地址——声明了 4 字节对齐而实际地址不对齐,也不会导致验证失败或 trap,只是运行时可能较慢。
变量操作指令
WASM 的变量分为局部变量和全局变量:
- local.get/set/tee:操作函数的局部变量(包括参数)。参数从索引 0 开始,局部变量从参数数量之后开始。
local.tee是local.set+local.get的组合——设置值但不弹出栈顶。 - global.get/set:操作模块的全局变量。全局变量可以是
mut(可变)或const(不可变)。导入的全局变量也可以是mut或const。Rust 产物里的全局变量很少:最典型的是可变的__stack_pointer(影子栈指针),外加__data_end、__heap_base这类不可变的地址常量。
⚠️ global.set 只能修改声明为 mut 的全局变量。如果尝试 global.set 一个 const 全局变量,验证器会拒绝。别把它和 Rust 的 static mut / static 对应起来——Rust 的 static 无论可变与否都放在线性内存的固定地址上,用 i32.load/i32.store 读写,不是 WASM 全局变量。
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 ;; 这行永远不会执行
drop ;; 不可达代码也要过类型检查:块类型为空,值必须丢掉
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 / drop (不可达)"]
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:#333
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
i32.const 0 ;; 压入 0
local.get 0 ;; 压入 x
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:#dc2626,color:#fff
style X4 fill:#dc2626,color:#fff
style W2 fill:#10b981,color:#333
style W4 fill:#10b981,color:#333
2.9 值类型与函数类型
WASM MVP 定义了四种基本值类型,后期的提案扩展了类型系统(引用类型提案的 funcref/externref、SIMD 提案的 v128、WasmGC 的 anyref 家族),但 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 传字符串时要在 JS 侧和线性内存之间来回拷贝 UTF-8 字节的原因(第 6–7 章)——WASM 没有原生的字符串表示。
WASM 没有结构体类型(在 MVP 中)。复合类型需要手动在内存中布局,与 C 的 ABI 模型一致。WasmGC 提案引入了 struct 和 array 类型,但那是为有 GC 的语言准备的,Rust 用不上(见前言"这个专栏不讲什么")。Rust 的 struct 在编译到 WASM 后变成内存中的字段序列——和 C ABI 完全一致。
函数类型
函数类型(functype)定义函数的签名——参数类型列表和返回值类型列表:
functype ::= 0x60 vec(valtype) vec(valtype)
;; 前一个向量是参数类型,后一个是结果类型,各自带 LEB128 长度前缀
一个函数类型 (i32, i32) -> i32 的编码:
60 02 7F 7F 01 7F
│ │ └──┘ │ └──┘
│ │ 参数 │ 返回值
│ 参数数 返回值数
func type 标记
WASM MVP 规定函数最多返回一个值。多值返回(multi-value)后来被并入 WebAssembly 2.0 核心规范,函数可以返回多个值。
⚠️ 这里有个广为流传的过时说法:"Rust 侧要显式加 -Ctarget-feature=+multivalue 才用得上多值"。两头都不对。一头是默认值:multivalue 自 Rust 1.82 起就在 wasm32-unknown-unknown 的默认 feature 集里(LLVM 术语中的 generic CPU,与 mutable-globals、reference-types、sign-ext 同批;bulk-memory、nontrapping-fptoint 从 1.87 起加入——见 rustc book 的 wasm32-unknown-unknown 页),根本不需要你手动开。另一头是这个 flag 的作用:LLVM 19 之后它只负责在 target_features 自定义段里声明这项能力,并不会让 rustc 给 extern "C" 函数生成多返回值签名。真正把"写 retptr"改写成"多值返回"的是 wasm-bindgen CLI——它读模块的 target feature,发现 multivalue 就自动把 multi_value 打开(📄 crates/cli-support/src/lib.rs:349-351),最后在 crates/cli-support/src/lib.rs:460-462 跑 multivalue::run 做这次改写。
引用类型扩展
引用类型(reference types)同样已并入 WebAssembly 2.0 核心规范,它引入了两种新的值类型:
| 类型 | 二进制编码 | 说明 |
|---|---|---|
funcref |
0x70 | 函数引用,存储在表中用于间接调用 |
externref |
0x6F | 宿主侧的不透明引用,WASM 不能解引用 |
externref 对 Rust + WASM 特别重要:它允许将 JS 对象(DOM 元素、Promise、闭包等)作为不透明引用传入 WASM 模块,WASM 不能直接操作它,但可以存储和传递。这条路径今天是默认打开的:reference-types 自 Rust 1.82 起在 wasm32-unknown-unknown 上默认启用(📄 wasm-bindgen 仓库 guide/src/reference/reference-types.md:45 明确写了这一点),wasm-bindgen 会读模块的 target feature 自动打开 externref 变换(📄 crates/cli-support/src/lib.rs:344-347)。开启前,#[wasm_bindgen] pub fn takes_js_value(a: &JsValue) 生成的 JS 里有一整套 heap 数组加 stack_pointer 的借用管理;开启后只剩 wasm.takes_js_value(a) 一行。
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:#dc2626,color:#fff
style B2 fill:#10b981,color:#333
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
导入的常见模式(前四行是 rustc 直接产出、尚未经 wasm-bindgen CLI 处理的 .wasm 里的样子):
| 模块名 | 函数名 | 用途 |
|---|---|---|
__wbindgen_placeholder__ |
__wbindgen_object_drop_ref |
释放 JS 侧映射表里的一个对象槽位 |
__wbindgen_placeholder__ |
__wbindgen_object_clone_ref |
克隆一个 JS 对象引用 |
__wbindgen_placeholder__ |
__wbg___wbindgen_throw_<哈希> |
把 Rust 侧的错误抛成 JS 异常(在 #[wasm_bindgen] extern 块里声明,导入名同样带 __wbg_ 前缀和哈希) |
__wbindgen_placeholder__ |
__wbg_* |
每个 #[wasm_bindgen] extern "C" 导入函数生成一个 |
wasi_snapshot_preview1 |
fd_write |
WASI preview 1 的文件写入 |
wasi_snapshot_preview1 |
random_get |
WASI preview 1 的随机数 |
⚠️ 一个高频误解:__wbindgen_malloc、__wbindgen_realloc、__wbindgen_free、__wbindgen_exn_store 不是导入,而是导出——它们在 wasm-bindgen 的 src/rt/mod.rs 里是 #[no_mangle] pub extern "C" 函数,由 WASM 模块提供给 JS 调用,而不是反过来。方向搞反会让你在读胶水代码时完全找不到北。
__wbindgen_placeholder__ 是 rustc 输出里的占位模块名(📄 src/lib.rs、src/rt/mod.rs 里的 raw_module = "__wbindgen_placeholder__"),wasm-bindgen CLI 在后处理时会把它改写成实际的 JS 模块路径(如 ./foo_bg.js),所以你在最终产物里看到的导入模块名是那个 JS 文件。开了 reference-types(Rust 1.82 起的默认)时,CLI 还会把 __wbindgen_object_drop_ref/__wbindgen_object_clone_ref 改写成对 externref 表的操作,最终产物里就看不到这两个导入了(0.2.118 实测)。
导出
(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
⚠️ 线性内存是 wasm-bindgen 的硬性要求。JS 侧的胶水代码需要通过 memory.buffer 访问线性内存,才能传递字符串、Vec<u8> 等复杂数据类型。模块里必须有一块内存(自己定义或导入均可),否则 CLI 直接报 failed to find memory declaration in module(📄 crates/cli-support/src/wit/mod.rs:1698-1701);至于导出,rustc 默认就导出 memory,万一没导出,CLI 生成胶水时会自己补一个导出(📄 crates/cli-support/src/js/mod.rs:5438-5463 的 export_name_of)。
2.11 函数体编码
每个函数体由三部分组成:
- 局部变量声明:类型 + 数量的列表
- 指令序列:栈式指令
- end 操作码:
0x0B
function_body ::=
local_decl* ;; 局部变量声明
instruction* ;; 指令序列
0x0B ;; end
局部变量声明的编码有一个微妙优化:相同类型的连续局部变量合并为一个声明。比如 3 个 i32 局部变量编码为 01 03 7F(1 个声明,数量 3,类型 i32),而非 03 01 7F 01 7F 01 7F(3 组各 1 个 i32)。这个优化在大函数中节省可观的字节数——一个有 10 个 i32 局部变量的函数,合并编码是 01 0A 7F 共 3 字节;不合并则要 1 字节向量长度加 10 组 (数量, 类型),共 21 字节。
一个更复杂的函数体示例——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 0B ; Data section (ID=11), 内容 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指令使用,写入后可以丢弃数据段以释放内存。它不会让.wasm变小,典型用途是多线程:共享内存只能初始化一次,开启共享内存时数据段全部改成 passive,由启动代码只执行一次memory.init——wasm-bindgen的线程变换就断言此时内存上已没有 active 段(📄crates/cli-support/src/transforms/threads/mod.rs:75)。
Rust 编译器会用数据段存放字符串字面量、静态变量的初始值、const 常量。wasm-bindgen 的过程宏则把接口描述放在 __wasm_bindgen_unstable 自定义段里(不是数据段),CLI 读完就把它剥掉。
元素段
元素段用于初始化表——在实例化时将函数引用写入表的指定位置:
(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 可以安全加载不受信任代码的基础:验证通过后,剩下的只有少数必须在运行时做的检查(内存越界、call_indirect 的签名匹配、整数除零等),不满足就 trap。
核心验证规则
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 要求模块确实声明或导入了一块线性内存,且 memarg 的 align 不超过该指令的自然对齐(比如 i32.load 的 align 最大为 2,即 4 字节)。⚠️ 注意验证器不做任何越界检查——offset 可以是任意 u32,地址是运行时才算出来的。所有越界都在执行时以 trap 的形式暴露,而不是加载时。这是理解第 3 章边界检查开销的前提:检查发生在运行时,只是实现上常常被虚拟内存的保护页均摊掉。
4. 函数签名匹配:call 的参数数量和类型必须与目标函数签名一致;call_indirect 的表元素类型必须是 funcref,且调用时的类型索引必须在 Type 段中存在。
5. limits 自洽:表和内存的 min 不能大于 max;wasm32 下内存类型的页数上限是 2^16 页(每页固定 64KB,合计 4GiB)。运行时能否真的增长到 max 是另一回事——memory.grow 失败时返回 -1,而不是 trap。
flowchart TD
A[".wasm 二进制"] --> B{解码}
B -->|格式错误| C[加载失败: 无效的 LEB128 / 段顺序错误]
B -->|成功| D{验证}
D -->|类型不一致| E[加载失败: type mismatch]
D -->|控制流非法| F[加载失败: malformed control flow]
D -->|索引越界| G[加载失败: unknown function/local 等]
D -->|通过| H{编译}
H --> I[机器码]
I --> J{实例化}
J -->|缺少导入| K[实例化失败: missing import]
J -->|成功| L[执行]
style D fill:#f59e0b,color:#333
style I fill:#10b981,color:#333
style L fill:#10b981,color:#333
style C fill:#dc2626,color:#fff
style E fill:#dc2626,color:#fff
style F fill:#dc2626,color:#fff
style G fill:#dc2626,color:#fff
style K fill:#dc2626,color:#fff
验证的安全保证
验证通过后,WASM 运行时可以做出以下保证:
- 没有未定义行为:WASM 规范定义了每条指令在每种输入下的行为。即使触发 trap(如除以零、越界访问),行为也是确定的——trap 传播到调用者,直到被宿主捕获。
- 没有内存越界:所有内存访问都落在线性内存 [0, 当前字节数) 的范围内(
memory.size返回的是页数,要乘 64KiB)。超出范围会触发 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) |
内存布局里仍占 1 字节;WASM 没有 1 位/8 位值类型,所以跨函数边界时提升为 i32 |
&[u8] 切片 |
指针(i32) + 长度(i32) 两个参数 | 胖指针拆成两个 i32 |
String |
线性内存中的字节序列 + 指针(i32)/长度(i32) | 不能直接跨边界传递 |
struct |
线性内存中的字段布局(和 C ABI 一致) | 无 WASM 原生结构体类型 |
enum |
标签 + 负载,编译为内存中的字节序列 | 无 WASM 原生枚举类型 |
dyn Trait |
表(funcref) + vtable | call_indirect 间接调用 |
fn 指针 |
表索引(funcref) | 通过 call_indirect 调用 |
panic! |
默认 panic=abort,最终落到 unreachable 指令 → trap |
装上 console_error_panic_hook 后,panic 信息先经 console.error 打到控制台,随后照样以 unreachable trap 结束(JS 侧看到 RuntimeError: unreachable) |
alloc |
调用链接进模块的全局分配器(wasm32-unknown-unknown 默认是 dlmalloc) |
堆就在线性内存里;__wbindgen_malloc 是模块导出给 JS 用的同一分配器的入口 |
static 变量 |
线性内存中的固定偏移 | 可变与否都一样,用 load/store 访问;WASM 全局变量只留给 __stack_pointer 等少数链接器符号 |
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 跨函数边界时被提升为 i32——因为 WASM 的值类型里没有 8 位类型。但这只影响参数和返回值:内存布局中 bool 依然是 1 字节,#[repr(C)] 结构体里的 bool 字段也是 1 字节。写胶水代码时要分清这两个位置——从线性内存里读一个 bool 字段用的是 1 字节读,从函数返回值里读是一个 i32。
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:#333
style W3 fill:#f59e0b,color:#333
2.15 自定义段:WASM 的元数据扩展机制
WASM 二进制由"段"(section)组成——前面章节介绍的 Type、Function、Code 等是已知段(known sections,section ID 1-12,Wasm 3.0 起再加上 ID 13 的 Tag 段)。规范还预留了 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:#333
每个自定义段有:
- ID = 0 标识
- 段大小(段名加 payload 的总字节数)
- 段名(区分不同自定义段)
- payload(任意二进制内容)
自定义段不影响执行语义——引擎可以忽略它,JS 侧可以用 WebAssembly.Module.customSections() 按名字取出来。这让自定义段在执行上是零开销的元数据载体,但下载体积照算。
2.15.2 标准化的自定义段
除 name 段的格式写在核心规范附录里之外,其余都是工具链之间的约定:
| 段名 | 内容 | 用途 |
|---|---|---|
name |
函数/局部变量名 | 调试和反汇编可读 |
producers |
工具链信息(语言、编译器、处理工具及各自版本) | 追溯产物来源 |
target_features |
启用的 WASM 提案 | 验证目标兼容性 |
dylink.0 |
动态链接元数据 | Emscripten / LLVM 的动态库 |
.debug_info, .debug_line 等 |
DWARF 调试信息 | 源码级调试 |
component-type(组件模型) |
编码后的 WIT 世界/接口类型 | 供 wasm-tools component new 把核心模块封装成组件 |
name 段最重要——wasm-bindgen 生成的 .wasm 默认带 name 段,让浏览器 DevTools 显示函数名而不是 wasm-function[42]。剥离 name 段(wasm-opt --strip-debug)能省下这些名字占的字节,代价是失去可读调用栈。
2.15.3 用 wasm-tools 检查自定义段
# 列出所有段(位置、大小,自定义段带名字)
wasm-tools objdump my.wasm
# 输出(节选;一个 wasm-bindgen 0.2.118 产物)
code | 0x263 - 0x3e3f | 15324 bytes | 93 count
data | 0x3e42 - 0x4275 | 1075 bytes | 2 count
custom "name" | 0x427d - 0x5afb | 6270 bytes | 1 count
custom "producers" | 0x5b07 - 0x5b79 | 114 bytes | 1 count
custom "target_features" | 0x5b8c - 0x5c10 | 132 bytes | 1 count
wasm-tools dump 则逐字节列出全部内容。每个段都是结构化二进制——但通常用工具读,不直接看字节。
2.15.4 嵌入自定义段:实战
业务可以嵌入自己的自定义段——例如版本信息、签名、license:
wasm-tools 自带的是标准段的读写(比如 wasm-tools metadata add 写 producers、wasm-tools strip 剥离段);要塞进自己的段,最直接的办法是在 build 脚本里用 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:#333 style C fill:#6366f1,color:#fff
每个场景的工程价值:
- 调试支持:name + DWARF 让生产环境的 trap 调用栈可读,否则你只能看到
wasm-function[42] - 供应链验证:嵌入构建签名,下游验证未被篡改
- 元数据传播:wit-bindgen 把 WIT 世界编码进核心模块的
component-type自定义段,构建期由wasm-tools component new读取 - 特性声明:
target_features记录产物用到了哪些提案,工具链据此决定做哪些变换(如前面wasm-bindgen据此打开 externref、多值变换)
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:#333
每条注意点:
- 段名冲突:用
mycompany.feature这种反向域名格式,避免与标准段重名 - 体积影响:开发阶段保留所有段(调试需要),release 用
wasm-opt --strip-debug/--strip-producers等按类剥离,或用wasm-tools strip按段名剥离 - 不能影响执行:自定义段是元数据,业务逻辑不能依赖它存在——发布时可能被 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:#333
WAT 的关键特性:
- 1:1 映射:每个 WAT 程序对应一个 .wasm,无歧义
- 可逆:
wasm2wat input.wasm -o tmp.wat && wat2wasm tmp.wat -o 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 代码
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
--release 编译后反汇编(下面是 Rust 1.96 产物经 wasm-tools print 的输出,wasm2wat 的输出格式略有差别,指令相同):
(func $add (;9;) (type 3) (param i32 i32) (result i32)
local.get 1
local.get 0
i32.add
)
完全符合预期——3 条指令(操作数顺序被 LLVM 对调,加法可交换,不影响结果);参数没有名字,是因为 LLVM 生成的名称段里只有函数、全局变量、数据段的名字,没有局部变量名。如果反汇编看到一长串指令,先确认是不是 debug 构建——debug 下 a + b 带溢出检查,会多出比较和 panic 分支;release 下仍然很长,才说明编译器没做预期的优化。
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/>switch/match 用 br_table 不直观"] A --> D["3. 库依赖<br/>没有包管理"] A --> E["4. 运行时性能<br/>没意义,跟二进制一样"] style A fill:#dc2626,color:#fff
WAT 的最佳定位:调试 + 教学 + 测试,不是编程语言。生产代码应该用 Rust/C++ 等高级语言写,编译到 WASM。
2.16.8 学习路径
flowchart LR A["从 WAT 入手"] --> B["1. 跑 wasm2wat 看 hello world"] B --> C["2. 阅读规范的文本格式章节"] C --> D["3. 对比 Rust 源码与 WAT 输出"] D --> E["4. 手写小型 WAT 测试"] E --> F["5. 高级:阅读规范测试套件"] style A fill:#10b981,color:#333 style F fill:#6366f1,color:#fff
WebAssembly 规范的核心测试套件(github.com/WebAssembly/spec/tree/main/test/core)用 .wast 脚本写——它是 WAT 的超集,多了 assert_return、assert_trap 等断言命令——是学习 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(print/parse/validate/dump 等子命令)与 wabt 并列为两大 WAT 工具集,前者与 Wasmtime 共用 wasmparser,跟进新提案更快。
掌握 WAT 是 WASM 工程师的"能力深度"标志——不是日常工作的工具,但理解后让你能透过编译产物看到本质。
2.17 WASM 二进制规范的演进史
WASM 不是从零设计——它继承了 asm.js 等前代技术的经验。理解规范演进史有助于理解"为什么 WASM 是这个样子",避免对设计决策的误解。
2.17.1 关键里程碑
timeline
title WASM 规范演进
2015 : WebAssembly 公布<br/>四大浏览器厂商联合推进
2017 : MVP 设计定稿<br/>年内四大浏览器陆续支持
2019 : 核心规范 1.0 成为 W3C 推荐标准
2020 : multi-value、sign-extension、非陷阱浮点转整数完成标准化
2021 : 引用类型、bulk memory、SIMD 完成标准化<br/>(与上一年的几项一起构成 2.0)
2024 : WASI 0.2(Preview 2)发布<br/>GC、尾调用、多内存等完成标准化
2025 : 异常处理、memory64 完成标准化<br/>Wasm 3.0 发布
时间点中的"完成标准化"取自 WebAssembly/proposals 的 finished-proposals 表(工作组表决日期与所属规范版本,2026-09-24 核对);threads 等提案截至核对日仍未进入该表。
每个阶段都有特定主题:
- 2015-2017:从 asm.js 提炼到 MVP,重点是"能跑"
- 2018-2021:扩展提案补足关键能力,汇总为 2.0
- 2022-2025:GC、尾调用、异常处理、memory64 等深度特性,汇总为 3.0;核心规范之外,WASI 0.2 与组件模型并行推进
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["与 JVM/.NET 一样加载前验证<br/>但规则更简单严格"] E --> E1["MVP 不内置高级类型<br/>留给后续提案"] style A fill:#10b981,color:#333
WASM 的"最小核心"哲学:MVP 故意不内置 String、Array、GC——通过提案逐步加。加进来的东西同样要永远向后兼容,所以每一项都得先走完提案流程再进核心。
2.17.3 与其他二进制格式的对比
| 维度 | WASM | JVM bytecode | .NET CIL | LLVM IR |
|---|---|---|---|---|
| 设计目标 | Web + 通用 | Java | .NET | 编译器中间表示 |
| 类型系统 | 极简(MVP 仅 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:#dc2626,color:#fff style C fill:#f59e0b,color:#333 style D fill:#10b981,color:#333
每个前辈的教训:
- NaCl:技术好但单家推动,没有跨厂商共识
- asm.js:跨平台但性能不够,文本格式 parse 慢
WASM 吸取教训:四家浏览器联合 + 二进制紧凑 + 标准化路线。
2.17.5 提案流程的演进
flowchart LR A["Phase 0<br/>Pre-Proposal"] --> B["Phase 1<br/>Feature Proposal"] B --> C["Phase 2<br/>Feature Description Available"] C --> D["Phase 3<br/>Implementation"] D --> E["Phase 4<br/>Standardize the Feature"] E --> F["Phase 5<br/>Standardized"] style F fill:#10b981,color:#333
WASM CG 的提案流程是 Phase 0 到 Phase 5 共六个阶段——任何提案都要走完全程才能进入核心规范。判断一个提案能不能在生产里用,看的就是它停在哪个 Phase:Phase 3 意味着至少有实现可试,Phase 4 之后才谈得上普遍可用。
跟踪渠道:github.com/WebAssembly/proposals 是中央索引。
2.17.6 历史教训
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["六阶段制度化<br/>vs HTML 早期混乱"] E --> E1["MVP 没有 String/Array<br/>留空间给后续提案"] style A fill:#10b981,color:#333
2.17.7 阅读规范的工程价值
flowchart TD A["读 WASM 规范的价值"] --> B["1. 理解工具链行为"] A --> C["2. 调试难题"] A --> D["3. 评估新提案"] A --> E["4. 自己写工具/runtime"] style A fill:#10b981,color:#333
不是所有工程师都需要读规范——但项目中至少有 1 人深入懂规范。否则遇到工具链 bug 或新提案时无法独立判断。
WASM 规范的官方文档:webassembly.github.io/spec ——结构清晰,比想象的可读。不必通读,先把二进制格式(Binary Format)和验证(Validation)两部分对照本章读一遍,收益最大。
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:#333 style E fill:#f59e0b,color:#333
每层难度递增——层 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 loader 插件 |
| IDA Pro | 商业,需 WASM 处理器插件 |
| Binary Ninja | 商业,WASM 支持靠插件,以其插件仓库现状为准 |
wabt(wasm-objdump / wasm-decompile) |
开源,官方工具集 |
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:#333
工具:
- wabt 的
wasm-decompile:实验性,输出一种介于 WAT 和 C 之间的可读形式 - Ghidra 的反编译器:需配合第三方 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:#333
每条都增加反向难度——但都不能完全防止。WASM 字节码本质是公开的,只能"提高门槛"而非"禁止反向"。
商业秘密不应放在客户端 WASM——应该在服务端。
2.18.8 与传统二进制反向的对比
| 维度 | 原生二进制(ELF/Mach-O) | WASM |
|---|---|---|
| 工具成熟度 | 极高(Ghidra/IDA) | 中等 |
| 反汇编质量 | 好 | 极好(结构化指令) |
| 反编译质量 | 中等 | 早期 |
| 控制流恢复 | 难(goto) | 简单(结构化) |
| 数据布局推断 | 难 | 中等 |
WASM 的反汇编比原生二进制简单——结构化控制流让分析容易。但反编译工具仍在发展。
2.18.9 给开发者的启示
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 内容可被分析。
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 和函数指针都通过表实现。