导语:把 .wasm 当作字节来读
大多数 WASM 开发者停留在"Rust 编译 → JS 调用"的黑盒层面:cargo build --target wasm32-unknown-unknown 产出一个二进制文件,交给浏览器执行。但当你需要调试格式问题、手写测试模块、理解体积优化、甚至实现运行时(Wasmtime/WasmEdge 的同事就在做这件事)时,必须钻进字节层面。
.wasm 不是加密格式,也不是压缩包——它是一套紧凑的、可流式解析的结构化二进制。本文带你逐字节解剖一个真实的 WASM 模块:Section 如何布局、整数如何用 LEB128 压缩、指令如何编码、线性内存如何增长、Table 如何支撑间接调用。
一句话总结:理解 WASM 二进制格式,就是理解一张「类型表 + 函数体 + 内存段 + 导出表」的清单,所有高级特性最终都落在这四个字节级的机制上。
1. 二进制文件结构总览
1.1 魔数与版本号
任何合法 .wasm 文件都以固定的 8 字节头部开头:
偏移 0-3 │ 魔数:0x00 0x61 0x73 0x6D (即 ASCII 字符串 "\0asm")
偏移 4-7 │ 版本:0x01 0x00 0x00 0x00 (当前仅此版本,即 WASM 1.0)
用十六进制查看器看一个真实文件:
xxd my-module.wasm | head -n 1
# 00000000: 0061 736d 0100 0000 017f 0160 017f 017f .asm.......`...
00 61 73 6d→ “\0asm”01 00 00 00→ 版本 1- 后续的
01 7f、01 60等就是 Section 数据(下文逐段解释)
版本号是唯一的。若版本不是 1,所有引擎会直接拒绝加载——这也是 WASM 规范承诺永不破坏二进制兼容性的技术基石:新特性一律通过新增操作码或可选段实现,绝不复用旧编码。
1.2 Section 布局与 LEB128 编码
魔数之后是一串 Section。每个 Section 由三部分组成:
┌────────────────────────────────────────────────────┐
│ Section ID (1 字节) │ Size (u32 LEB128) │
├────────────────────────────────────────────────────┤
│ Payload(Size 指定的字节数,内容因 Section 而异) │
└────────────────────────────────────────────────────┘
标准 Section ID 一览(按规范定义的顺序,后文逐一剖析):
| ID | 名称 | 内容 | 必选性 |
|---|---|---|---|
| 0 | Custom | 名称段、调试信息、source map,任意位置可插入 | 可选 |
| 1 | Type | 函数签名(functype)集合 | 必选 |
| 2 | Import | 导入的函数/表/内存/全局 | 可选 |
| 3 | Function | 模块内部函数的类型索引 | 可选 |
| 4 | Table | 间接调用表的声明 | 可选 |
| 5 | Memory | 线性内存声明(min/max/shared) | 可选 |
| 6 | Global | 全局变量声明 | 可选 |
| 7 | Export | 导出项(名称 → 索引) | 可选 |
| 8 | Start | 初始化函数索引(模块加载即执行) | 可选 |
| 9 | Element | Table 的初始元素(函数指针填充) | 可选 |
| 10 | Code | 函数体字节码 | 可选 |
| 11 | Data | 内存初始数据段 | 可选 |
| 12 | Data Count | Code 之前声明的 Data 段数量(流式编译预检) | 可选 |
关键机制——LEB128:所有整数(Section 大小、索引、常量)都用 LEB128(Little Endian Base 128)变长编码,每字节 7 位有效数据 + 1 位延续标志。小于 128 的数只占 1 字节,这正是 WASM 文件如此紧凑的原因。
一个 unsigned LEB128 的解码示例:
def read_u32_leb(buf, offset):
result = 0
shift = 0
while True:
byte = buf[offset]
offset += 1
result |= (byte & 0x7F) << shift
if byte & 0x80 == 0: # 最高位为 0 表示这是最后一字节
break
shift += 7
return result, offset
# 例:300 = 0b10_0101100 → 编码为 0xAC 0x02(解码:0x2C | (0x02<<7) = 300)✓
1.3 核心 Section 逐段解剖
Type Section(ID=1)——函数的类型签名
每个 functype 编码为:0x60 + 参数个数 + 参数类型序列 + 返回值个数 + 返回值类型序列。
0x60 0x02 0x7F 0x7F 0x01 0x7F
│ │ │ │ │ │
0x60 │ │ │ │ └─ 返回值 1 个:i32 (0x7F)
│ │ │ └─────── 返回值个数:1
│ │ └──────────── 参数 2:i32
│ └───────────────── 参数 1:i32
└─────────────────────── functype 标志
类型字节映射:0x7F=i32、0x7E=i64、0x7D=f32、0x7C=f64、0x7B=v128、0x70=funcref、0x6F=externref。
Import Section(ID=2)——模块与外界的契约
每条 import = 模块名 + 字段名 + 类型(kind)。kind 只有 4 种:0x00=func、0x01=table、0x02=memory、0x03=global。
00 61 73 6d ... 02 13 03 656e 76 03 6d65 6d 02 00 01
│ │ │ │ │ │ │ │ │ │
ID │ 模块名 │ 字段名 │ kind=memory │
size "env" "mem" min=1 max=2
Memory Section(ID=5)——线性内存声明
内存声明用 limits 结构编码:flags + min + (可选)max。
| flags | 含义 |
|---|---|
| 0x00 | 仅最小值,无最大值 |
| 0x01 | 有最小值和最大值 |
| 0x03 | 共享内存(shared,配合多线程,见 WASM 多线程专题) |
05 06 01 00 01 02
│ │ │ │ │ │
│ │ │ │ │ └─ max=2 页(128 KiB)
│ │ │ │ └──── min=1 页(64 KiB)
│ │ │ └─────── flags=0x01(有 max)
│ │ └────────── limits 条数=1
│ └───────────── size=6 字节
└──────────────── Section ID=5
Export Section(ID=7)——对外暴露的接口
每条 export = 名称 + kind + 索引,与 Import 对称。
07 08 03 61 64 64 00 00 04 6d 65 6d 02 00
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ └─ memory 索引 0
│ │ │ │ │ │ └───────────└──── kind=0x02 (memory)
│ │ │ │ │ └────────────────── 索引 0
│ │ │ │ └───────────────────── kind=0x00 (func)
│ │ │ └──────────────────────── 名称 "add" 长度=3
│ │ └──────────────────────────────────── 导出条数=2
│ └─────────────────────────────────────── size=8
└────────────────────────────────────────── Section ID=7
Code Section(ID=10)——函数体
每个函数体 = body_size + 局部变量声明区 + 指令序列 + 0x0B(end):
0a 09 01 07 00 20 00 41 03 6c 6a 0b
│ │ │ │ │ │ │ │ │ │ │ └─ 0x0B = end
│ │ │ │ │ │ │ │ │ │ └──── 0x6A = i32.add
│ │ │ │ │ │ │ │ │ └─────── 0x41 0x03 = i32.const 3
│ │ │ │ │ │ │ │ └────────── 0x6C = i32.mul
│ │ │ │ │ │ │ └───────────── 0x20 0x00 = local.get 0
│ │ │ │ │ │ └──────────────── 局部变量个数=0
│ │ │ │ │ └─────────────────── body_size=7 字节
│ │ │ │ └────────────────────── 函数个数=1
│ │ │ └───────────────────────── size=9
│ │ └──────────────────────────── Section ID=10
│ └─────────────────────────────── 完整函数体:local.get 0; i32.const 3; i32.mul; i32.add
一句话总结:WASM 二进制是一张「清单的清单」——Type 段定义签名,Function/Code 段配对定义函数,Memory/Data 段定义内存,Export 段把内部索引暴露给外部;一切数字都用 LEB128 压缩。
2. 指令编码:从操作码到栈机
2.1 栈式执行模型
WASM 是一个基于栈的虚拟机:指令从栈上取操作数、把结果压回栈。没有寄存器,没有直接内存寻址(除了 load/store 指令)。
常用操作码速查表:
| 指令 | 操作码 | 说明 |
|---|---|---|
unreachable | 0x00 | 触发 trap |
nop | 0x01 | 空操作 |
block / loop / if | 0x02 / 0x03 / 0x04 | 结构化控制流 |
else / end | 0x05 / 0x0B | 控制流边界 |
br / br_if | 0x0C / 0x0D | 分支 |
call / call_indirect | 0x10 / 0x11 | 直接/间接调用 |
local.get / local.set | 0x20 / 0x21 | 局部变量 |
global.get / global.set | 0x23 / 0x24 | 全局变量 |
i32.load | 0x28 | 从线性内存读 4 字节 |
i32.store | 0x36 | 写入线性内存 4 字节 |
i32.const | 0x41 | 压入 i32 立即数 |
i64.const | 0x42 | 压入 i64 立即数 |
i32.add / i32.sub / i32.mul | 0x6A / 0x6B / 0x6C | 整数算术 |
i32.eqz / i32.eq | 0x45 / 0x46 | 比较 |
memory.size / memory.grow | 0x3F 0x00 / 0x40 0x00 | 内存查询/增长 |
v128.load | 0xFD 前缀 | SIMD(见 SIMD 专题) |
i32.atomic.load | 0xFE 前缀 | Atomics(见 多线程专题) |
2.2 立即数、对齐与偏移
i32.const 的操作数是有符号 LEB128;load/store 则带两个无符号 LEB128 立即数:对齐指数 和 字节偏移。
i32.load offset=4 align=2 → 0x28 0x02 0x04
│ │ │ │
│ │ │ └─ 偏移 offset=4(从基地址 +4 处读)
│ │ └─────── 对齐 align=2(即 2^2=4 字节对齐提示)
└───────────────────────────────┘ └ 注意顺序:先 align 后 offset
对齐只是一个性能提示(编译器保证实际对齐可以更严格),不是语义要求;无效对齐不会 trap,只是让引擎无法生成最高效的访问代码。手写 WAT 时通常用 align=2 或直接省略(默认按类型宽度)。
2.3 手工解析一个真实函数体
取 add(a, b) 函数(含一个局部变量中间结果):
(func $add (param $a i32) (param $b i32) (result i32)
(local $t i32) ;; 1 个局部变量
local.get $a ;; 压入 a
local.get $b ;; 压入 b
local.set $t ;; t = b
i32.add ;; 弹出 (a + 上一步的 t)
return)
对应的 Code 段字节(逐字节注释):
0A 0E 01 0C 01 01 7F 20 00 20 01 21 00 6A 0F 0B
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0x0B end
│ │ │ │ │ │ │ │ │ │ │ │ │ │ └──── 0x0F return
│ │ │ │ │ │ │ │ │ │ │ │ │ └─────── 0x6A i32.add
│ │ │ │ │ │ │ │ │ │ │ │ └────────── 0x21 0x00 local.set $t
│ │ │ │ │ │ │ │ │ │ │ └───────────── 0x20 0x01 local.get $b
│ │ │ │ │ │ │ │ │ │ └──────────────── 0x20 0x00 local.get $a
│ │ │ │ │ │ │ │ │ └─────────────────── 0x01 0x7F 局部声明(1 个 i32)
│ │ │ │ │ │ │ │ └────────────────────── 0x0C body_size
│ │ │ │ │ │ │ └───────────────────────── 0x01 函数个数;0x0E Section 大小
一句话总结:WASM 指令是 1 字节操作码 + LEB128 立即数;理解编码后,任何
.wat你都能手工翻译成十六进制,也就能读懂反汇编输出。
3. 线性内存模型
3.1 页、地址空间与 64 KiB 粒度
线性内存是 WASM 与宿主(JS/OS)交换数据的唯一通道。它是一块连续的可增长字节数组,但增长粒度固定:
- 1 页 = 64 KiB = 65536 字节
- 最小初始值通常为 1 页(很多工具链默认 17 页 = 1MiB+,为堆和栈预留空间)
- 地址空间:32 位寻址,MVP 时代受 JS
ArrayBuffer上限约束约 2 GiB(32767 页);Memory64 提案将地址扩展到 64 位,理论上可达 16 EiB
┌──────────────────────────────────────────────┐
│ Page 0(64 KiB) │
│ ┌───────────┬────────────┬──────────────────┐ │
│ │ Stack │ Heap │ Data(静态) │ │
│ │ (向下增长) │ (向上增长) │ 常量/字符串 │ │
│ └───────────┴────────────┴──────────────────┘ │
├──────────────────────────────────────────────┤
│ Page 1(64 KiB,按需通过 memory.grow 追加) │
├──────────────────────────────────────────────┤
│ ... │
└──────────────────────────────────────────────┘
访问指令的语义边界:i32.load (align, offset) = mem[base_addr + offset, +4]。越界(base + offset + 宽度 > 当前内存大小)立即触发 trap——由 VM 硬件级保证,编译器生成的代码无法绕过。
3.2 memory.grow 与内存增长策略
memory.grow 是唯一能让内存变大的指令:
(memory.grow $mem) ;; 操作数在栈上:要增长的页数 N
;; 结果也压回栈:增长前的大小(页数),失败返回 -1
特性与陷阱:
| 特性 | 说明 |
|---|---|
| 只会失败,不会 trap | 分配失败返回 -1,需显式检查 |
| 连续分配 | 新页紧接现有内存,旧指针保持有效 |
| 幂等边界 | 达到 max 后返回 -1 |
| 共享内存 | shared 内存的 grow 通过 Atomics.wait 广播给所有 worker |
| 2 GiB 天花板 | 超过上限返回 -1(32 位寻址) |
Rust 侧的内存管理:编译器把 malloc/free(dlmalloc / mimalloc / wee_alloc)翻译为 memory.grow 系统调用:
// 这行 Rust 代码最终会触发 memory.grow:
let big = vec![0u8; 10 * 1024 * 1024]; // 10 MiB 堆分配
// LLVM 后端:call malloc → malloc 内部调 memory.grow 1 次(160 页)
JS 侧主动增长并写入:
const memory = new WebAssembly.Memory({ initial: 1, maximum: 10 });
console.log(memory.buffer.byteLength); // 65536
const result = memory.grow(4); // 增长 4 页
console.log(result); // 1(旧大小,页数)
console.log(memory.buffer.byteLength); // 327680(5 页)
// 注意:grow 会替换底层 ArrayBuffer,旧视图必须重新获取
const u8 = new Uint8Array(memory.buffer);
u8.set([0x68, 0x69]); // 写入 "hi"
⚠️ 经典坑:
memory.grow()之后,任何此前持有的ArrayBuffer/TypedArray视图都已脱离底层内存,必须通过memory.buffer重新创建。生产代码应在内存可能增长的入口统一刷新视图。
3.3 边界检查与陷阱
┌─────────────── 有效线性内存 ───────────────┐
│ │ │
│ load [0x1FFF8] │ load [0x20000] │
│ (页边界内) │ (越界 8 字节) │
└────────────────────┴───────────────────────┘
│
▼
┌─────────────┐
│ Trap:RangeError │
└─────────────┘
安全语义全部由 VM 负责:
- 边界检查在现代引擎中通过防护页 + 信号处理或显式比较实现,开销极低(通常 < 5%)
- 越界访问是确定性 trap,不是未定义行为——同一程序在任何平台行为一致
- 这保证了 WASM 模块可以安全地与宿主共享内存而不产生指针逃逸
一句话总结:线性内存 = 64 KiB 粒度的连续字节数组 +
memory.grow增长 + VM 强制的边界检查;它是 WASM 安全沙箱的物理边界。
4. Table 与间接调用
4.1 Table 的声明
WASM 的函数不能像 C 那样直接取地址。间接调用(函数指针、虚函数、回调)需要一张 Table:一个可索引的函数引用数组。
(module
(type $binop (func (param i32 i32) (result i32)))
(table $t 4 funcref) ;; 4 槽位的函数表
(func $add (type $binop) local.get 0 local.get 1 i32.add)
(func $mul (type $binop) local.get 0 local.get 1 i32.mul)
(elem (i32.const 0) $add $mul) ;; Element 段:槽 0→add,槽 1→mul
(export "table" (table $t)))
Table 元素类型只有两种:
| 类型 | 编码 | 用途 |
|---|---|---|
funcref | 0x70 | 函数引用(call_indirect 目标) |
externref | 0x6F | 任意宿主对象引用(JS 对象句柄) |
4.2 call_indirect 编码
call_indirect 是间接调用的核心指令:
;; WAT:从栈顶取表索引,按类型 $binop 调用
(call_indirect (type $binop) (local.get $i))
;; 二进制:0x11 <type_index> <table_index>
;; 实际字节:0x11 0x00 0x00 (type 0、table 0)
执行流程:
栈: [args..., i] (i 为表索引)
│
▼
1. 检查 i < table 长度(table.size),否则 trap(表越界)
2. 检查 table[i] 非空(不是 null funcref),否则 trap
3. 检查 table[i] 的函数签名与声明的 type 一致,否则 trap(间接调用类型不匹配)
4. 调用该函数
这三点检查(范围/空值/类型)保证了间接调用的控制流安全——这是 C/C++ 函数指针在 WASM 中依然安全的关键:不能跳到任意地址,只能跳向经过验证的、类型匹配的函数。
C++ 虚函数在 WASM 中的映射:编译器把每个虚表(vtable)翻译为一张 funcref Table,obj->method() 变成 call_indirect。
4.3 table 指令与 JS 互操作
Table 有专属指令(0xFC 前缀 + 子操作码):
| 指令 | 子操作码 | 语义 |
|---|---|---|
table.get | 0xFC 0x25 | 读 table[i] → 压入 ref |
table.set | 0xFC 0x26 | 弹栈写入 table[i] |
table.size | 0xFC 0x10 | 当前长度 |
table.grow | 0xFC 0x0F | 增长并初始化新槽 |
table.fill | 0xFC 0x11 | 批量填充 |
JS 侧可以直接操作 Table,实现"宿主注入回调":
const module = await WebAssembly.compile(bytes);
const table = new WebAssembly.Table({ initial: 4, element: "anyfunc" });
const instance = await WebAssembly.instantiate(module, {
js: { table }
});
// JS 向表槽位写入自己的函数 → WASM 内部通过 call_indirect 回调 JS
table.set(2, (x, y) => x + y);
table.set(3, (x, y) => x * y);
// 调用 WASM 的 dispatcher:它按传入索引走 call_indirect
console.log(instance.exports.dispatch(2, 10, 5)); // 15
console.log(instance.exports.dispatch(3, 10, 5)); // 50
一句话总结:Table 把「函数地址」变成「带类型检查的槽位索引」,
call_indirect在调用前完成越界、空值、签名三重验证——这是 C 函数指针在 WASM 安全沙箱里的等价物。
5. 从 WAT 到 .wasm 实战
用 WABT 工具链完成"文本 → 二进制 → 反汇编"闭环:
# 安装 WABT
brew install wabt # macOS
# 或 apt install wabt # Ubuntu
# 1. 编写 test.wat(内容如下)
# 2. 编译为二进制
wat2wasm test.wat -o test.wasm
# 3. 反汇编验证
wasm2wat test.wasm --inline-exports --fold-exprs
# 4. 输出十六进制
xxd test.wasm
test.wat——一个同时使用 memory、table、data 的完整模块:
(module
(type $binop (func (param i32 i32) (result i32)))
(memory $mem 1 4)
(data (i32.const 8) "table-demo") ;; 数据段写到内存偏移 8
(table $t 2 funcref)
(elem (i32.const 0) $add $mul)
(func $add (type $binop)
local.get 0 local.get 1 i32.add)
(func $mul (type $binop)
local.get 0 local.get 1 i32.mul)
;; 读取内存第 8 字节起的 10 个字符(ASCII)
(func $read (param $p i32) (result i32)
local.get $p i32.load8_u)
(export "add" (func $add))
(export "mul" (func $mul))
(export "memory" (memory $mem))
(export "table" (table $t))
)
生成的二进制(节选,含注释):
00 61 73 6d 01 00 00 00 ;; magic + version 1
01 07 01 60 02 7f 7f 01 7f ;; type 段:1 个 (i32,i32)->i32
03 02 01 00 ;; function 段
05 04 01 01 01 04 ;; memory 段:min=1 max=4
04 04 01 70 00 02 ;; table 段:funcref min=2
09 07 01 00 41 08 0b 01 00 ;; element 段:槽 0→$add
07 0e 03 03 61 64 64 00 00 03 6d 75 6c 00 01 06 6d 65 6d 6f 72 79 02 00
;; export 段:add / mul / memory 三个导出
0a 0d 02 07 00 20 00 20 01 6a 0b 07 00 20 00 20 01 6c 0b
;; code 段:函数 0(add)+ 函数 1(mul)
0b 08 01 00 41 08 0b 08 74 61 62 6c 65 2d 64 65 6d 6f
;; data 段:偏移 8 写入 "table-demo"
用 Node.js 直接运行验证:
const fs = require("fs");
const bytes = fs.readFileSync("test.wasm");
const { instance } = await WebAssembly.instantiate(bytes);
console.log(instance.exports.add(20, 22)); // 42
console.log(instance.exports.mul(6, 7)); // 42
console.log(instance.exports.table.get(0)(20, 22)); // 42(经 call_indirect)
console.log(instance.exports.table.get(1)(6, 7)); // 42
const u8 = new Uint8Array(instance.exports.memory.buffer);
console.log(Buffer.from(u8.slice(8, 19)).toString()); // "table-demo"
一句话总结:
wat2wasm+wasm2wat让你在人类可读与字节层之间自由切换,是学习格式、调试体积问题最趁手的工具。
6. 总结与实践建议
| 主题 | 核心结论 |
|---|---|
| Section 结构 | .wasm = 魔数 + 版本 + 12 类可选 Section,全部 LEB128 紧凑编码 |
| 指令编码 | 1 字节操作码 + 变长立即数;load/store 带 align/offset 两个立即数 |
| 线性内存 | 64 KiB 页粒度、memory.grow 增长、VM 强制边界检查 |
| Table | 类型安全的三重检查(范围/空值/签名)间接调用机制 |
| 工具链 | WABT(wat2wasm / wasm2wat)+ Node/浏览器直接实例化 |
实践建议:
- 调试体积时用
wasm2wat查看是否混入了未优化的符号;配合wasm-opt -Oz减体积(见 性能优化专题) - 手写测试模块用 WAT 而非目标语言——它绕开编译器优化,便于精确复现边界行为
- 多线程内存必须把
memory声明为 shared(flags=0x03),并配合 COOP/COEP 头,详见 WASM 多线程与 SharedArrayBuffer - 间接调用密集的场景(虚函数表、回调)关注
call_indirect的类型检查开销,必要时用ref.func+ 直接调用代替
继续学习:从字节层面向外看,下一步推荐 WASM 组件模型与 WIT 接口(新的组件层格式)与 SIMD 高性能计算(0xFD 前缀的向量指令)。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。