WASM 二进制格式与内存模型深度剖析

深入 WASM 二进制文件内部:Section 结构(type/import/function/memory/export)、LEB128 变长整数编码、指令字节码、线性内存与 Memory Growth、Table 间接调用机制,从字节层面彻底理解 WebAssembly。

导语:把 .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名称内容必选性
0Custom名称段、调试信息、source map,任意位置可插入可选
1Type函数签名(functype)集合必选
2Import导入的函数/表/内存/全局可选
3Function模块内部函数的类型索引可选
4Table间接调用表的声明可选
5Memory线性内存声明(min/max/shared)可选
6Global全局变量声明可选
7Export导出项(名称 → 索引)可选
8Start初始化函数索引(模块加载即执行)可选
9ElementTable 的初始元素(函数指针填充)可选
10Code函数体字节码可选
11Data内存初始数据段可选
12Data CountCode 之前声明的 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 指令)。

常用操作码速查表:

指令操作码说明
unreachable0x00触发 trap
nop0x01空操作
block / loop / if0x02 / 0x03 / 0x04结构化控制流
else / end0x05 / 0x0B控制流边界
br / br_if0x0C / 0x0D分支
call / call_indirect0x10 / 0x11直接/间接调用
local.get / local.set0x20 / 0x21局部变量
global.get / global.set0x23 / 0x24全局变量
i32.load0x28从线性内存读 4 字节
i32.store0x36写入线性内存 4 字节
i32.const0x41压入 i32 立即数
i64.const0x42压入 i64 立即数
i32.add / i32.sub / i32.mul0x6A / 0x6B / 0x6C整数算术
i32.eqz / i32.eq0x45 / 0x46比较
memory.size / memory.grow0x3F 0x00 / 0x40 0x00内存查询/增长
v128.load0xFD 前缀SIMD(见 SIMD 专题)
i32.atomic.load0xFE 前缀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 元素类型只有两种:

类型编码用途
funcref0x70函数引用(call_indirect 目标)
externref0x6F任意宿主对象引用(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.get0xFC 0x25读 table[i] → 压入 ref
table.set0xFC 0x26弹栈写入 table[i]
table.size0xFC 0x10当前长度
table.grow0xFC 0x0F增长并初始化新槽
table.fill0xFC 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/浏览器直接实例化

实践建议:

  1. 调试体积时用 wasm2wat 查看是否混入了未优化的符号;配合 wasm-opt -Oz 减体积(见 性能优化专题)
  2. 手写测试模块用 WAT 而非目标语言——它绕开编译器优化,便于精确复现边界行为
  3. 多线程内存必须把 memory 声明为 shared(flags=0x03),并配合 COOP/COEP 头,详见 WASM 多线程与 SharedArrayBuffer
  4. 间接调用密集的场景(虚函数表、回调)关注 call_indirect 的类型检查开销,必要时用 ref.func + 直接调用代替

继续学习:从字节层面向外看,下一步推荐 WASM 组件模型与 WIT 接口(新的组件层格式)与 SIMD 高性能计算(0xFD 前缀的向量指令)。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 调试与性能剖析:源码映射、断点调试与火焰图分析
  2. WASM 游戏与 WebGPU:高性能浏览器图形渲染与游戏引擎
  3. WASM 智能合约:区块链执行环境、确定性运行与合约开发