Emscripten 的 WebAssembly 编译实现解析

1. 项目背景与问题来源

2011 年,Alon Zakai 在 Mozilla Research 启动 Emscripten 项目时,面临一个根本性问题:Web 平台缺乏执行原生代码的能力。彼时浏览器只有 JavaScript 引擎,而大量 C/C++ 编写的关键基础设施——游戏引擎、多媒体编解码器、物理模拟、密码学库——无法直接在 Web 上运行。

已有方案的局限是明确的:

  • NaCl (Native Client):Google 的方案,需要专有沙箱,只在 Chrome 上运行,本质上是浏览器插件的变体
  • 手工 JavaScript 移植:工程量不可控,性能损失无法预测
  • 直接生成 JavaScript:早期 Emscripten 的目标即是生成普通 JavaScript,但缺乏类型信息,引擎难以做高效优化

Emscripten 解决的核心问题是:如何将 POSIX (Portable Operating System Interface) 语义的 C/C++ 程序,在保持可接受性能的前提下,编译为浏览器可执行的安全代码

Emscripten 与 asm.js 的演进关系:2013 年 Mozilla 在 Emscripten 输出基础上提出 asm.js——一个可被 AOT (Ahead-Of-Time) 编译的 JavaScript 严格子集。它显著提升了执行性能,但语法表达力受限于 JavaScript。2015 年起 WebAssembly 取代 asm.js 成为 Emscripten 的默认目标。

这不是一个单纯的编译器后端问题。它需要同时解决:

  1. 编译目标的指令集架构 ISA (Instruction Set Architecture) 转换(x86/ARM → WebAssembly)
  2. 系统调用的语义桥接(POSIX syscall → Web API)
  3. 运行时环境的重建(libc, filesystem, threading → JS/Wasm 实现)
  4. 应用二进制接口 ABI (Application Binary Interface) 兼容性(C 调用约定/calling convention → Wasm 函数签名)

Emscripten 在技术栈中的定位是一个 完整的交叉编译工具链——不只是编译器后端,而是从源码到可在浏览器中运行的完整产物的全链路方案。

原生工具链Emscripten 工具链wasi-sdk 工具链gcc/clangELFLinux/macOS/Windowsemcc.wasm + .js 胶水浏览器/Node.jsclang --target=wasm32-wasi.wasmWASI 运行时

Emscripten 的独特价值在于:它不仅编译代码,还提供了将原生程序 完整运行 在 Web 环境中所需的一切基础设施。


2. 从一个最小示例开始

1
2
3
4
5
6
7
8
// hello.c
#include <stdio.h>

int main() {
printf("Hello, WebAssembly!\n");
return 0;
}

编译命令:

1
2
emcc hello.c -o hello.html

这条命令触发的完整流水线:

1
2
3
4
5
6
7
8
9
10
11
hello.c

▼ [Clang: C → LLVM IR → wasm object (单次调用完成)]
hello.o (Wasm 目标文件/object file,包含重定位/relocation 信息)

▼ [wasm-ld: linking]
hello.wasm (linked wasm binary)

▼ [JS 编译器 + wasm-opt --post-emscripten + JS 死代码消除 (JSDCE)]
hello.wasm (finalized) + hello.js (胶水代码/glue code) + hello.html (shell)

注:现代 Emscripten 不再产生独立的 .bc 中间文件。Clang frontend 和 LLVM wasm backend 在一次 clang 调用中完成 C→wasm object 的编译。

最终生成三个文件(实测 emcc 4.0.7,-O2):

文件大小(-O2)职责
hello.wasm~2 KB(1,977 B)编译后的机器码
hello.js~9.5 KB(9,769 B)运行时 + 系统调用桥接 + 加载逻辑
hello.html~19 KB(19,600 B)页面壳 + 控制台输出

若只需 Node.js 执行:

1
2
3
4
emcc hello.c -o hello.js
node hello.js
# 输出: Hello, WebAssembly!

若只需 wasm 模块(无 JS 胶水):

1
2
emcc hello.c -o hello.wasm -sSTANDALONE_WASM

3. 最终产物分析

3.1 .wasm 文件

WebAssembly 二进制格式,包含:

  • 编译后的函数体:用户代码 + 链接进来的 libc 函数
  • 线性内存初始化数据:全局变量、字符串常量
  • 导入声明:需要宿主环境提供的函数(如 fd_write
  • 导出声明:暴露给外部调用的函数(如 _main, _malloc
  • 类型段:所有函数签名
  • 表段:间接函数调用表(function pointer 支持)

3.2 .js 胶水代码

这是 Emscripten 架构中最关键的产物之一,承担以下职责:

JS 胶水代码职责1. Wasm 模块加载与实例化2. 线性内存创建与管理3. 系统调用实现 (fd_write, fd_read 等)4. 虚拟文件系统 (MEMFS/IDBFS/NODEFS)5. C ↔ JS 类型转换 (字符串, 数组)6. 运行时生命周期管理7. 异常处理桥接8. pthread -> Web Worker 映射

JS 胶水代码的结构(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 1. 运行时初始化
var Module = {};
var wasmMemory = new WebAssembly.Memory({initial: 256});
var HEAP8 = new Int8Array(wasmMemory.buffer);
var HEAP32 = new Int32Array(wasmMemory.buffer);

// 2. 导入对象 —— 提供给 wasm 的宿主函数
var wasmImports = {
env: {
memory: wasmMemory,
fd_write: function(fd, iov, iovcnt, pnum) { /* ... */ },
emscripten_resize_heap: function(requestedSize) { /* ... */ },
},
wasi_snapshot_preview1: {
proc_exit: function(code) { /* ... */ },
}
};

// 3. 加载并实例化 wasm
WebAssembly.instantiate(wasmBinary, wasmImports).then(function(result) {
var exports = result.instance.exports;
Module._main = exports.main;
Module._malloc = exports.malloc;
// 调用 main
exports.main();
});

真实 -O2 产物片段分析

以下是 emcc hello.c -o hello.js -O2 生成的实际 JS 产物关键片段(精简注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// === 内存初始化 ===
var wasmMemory = new WebAssembly.Memory({initial:256,maximum:256});
var HEAP8 = new Int8Array(wasmMemory.buffer);
var HEAPU8 = new Uint8Array(wasmMemory.buffer);
var HEAP32 = new Int32Array(wasmMemory.buffer);

// === wasm import 实现(仅包含被引用的) ===
var wasmImports = {
fd_write: function(fd, iov, iovcnt, pnum) {
// 从线性内存读取 iovec 结构体
var num = 0;
for (var i = 0; i < iovcnt; i++) {
var ptr = HEAPU32[iov>>2];
var len = HEAPU32[iov+4>>2];
iov += 8;
// 输出到控制台
for (var j = 0; j < len; j++) process.stdout.write(
String.fromCharCode(HEAPU8[ptr+j]));
num += len;
}
HEAPU32[pnum>>2] = num;
return 0;
}
};

// === wasm 加载与实例化 ===
var wasmExports = createWasm(); // 内部调用 WebAssembly.instantiate()

// === 入口调用 ===
function callMain() {
var ret = wasmExports['__main_argc_argv'](0, 0);
exitJS(ret);
}
callMain();

注意:-O2 产物已经过 JSDCE 和 minify-whitespace 处理,以上为可读性还原版。实际 hello.js 仅 9,769 B(约 9.5 KB),与 §2 实测一致。

3.3 产物间的协作关系

hello.html 页面hello.js 胶水代码hello.wasm 模块浏览器 加载拉取并实例化导入 fd_write导入内存调用 main调用 printf -> fd_write控制台输出

关键认知:wasm 模块不能独立运行。它依赖 JS 胶水层提供的 import 函数来完成任何与外部世界的交互。这是 WebAssembly 沙箱模型的本质决定的——wasm 没有系统调用能力,只能通过 import/export 接口与宿主通信。

3.4 静态分析 .wasm:查看导出/导入

理解了 import/export 是 wasm 与宿主的唯一通信通道,下一步实践问题就是:只有一个 .wasm 文件时,如何查看它导出了什么、需要什么 import? WebAssembly 二进制有专门的 Export Section 和 Import Section,这些信息无需运行即可被静态读取。

演示用 demo.c(含 add/mul/main 三个函数),编译命令: emcc demo.c -O2 -o demo.wasm -sEXPORTED_FUNCTIONS='["_main","_add","_mul"]' -sSTANDALONE_WASM

方法 1:wasm-dis(Binaryen,emsdk 自带)— 推荐

.wasm 反编译成可读的 Wasm 文本格式 (.wat):

1
2
wasm-dis demo.wasm | grep '(export'

实测输出:

1
2
3
4
5
6
7
(export "memory" (memory $0))
(export "add" (func $1))
(export "mul" (func $2))
(export "main" (func $4))
(export "_start" (func $5))
(export "__indirect_function_table" (table $0))

每行包含:导出名、类型(func / memory / table / global)、内部索引。要看 imports 类似:wasm-dis demo.wasm | grep '(import'

方法 2:WebAssembly.Module.exports()(无需任何工具)

Node.js 一行即可,适合在程序中动态查询:

1
2
3
4
node -e 'const fs=require("fs");
const m=new WebAssembly.Module(fs.readFileSync("demo.wasm"));
console.log(WebAssembly.Module.exports(m));'

实测输出:

1
2
3
4
5
6
7
8
9
[
{ name: 'memory', kind: 'memory' },
{ name: 'add', kind: 'function' },
{ name: 'mul', kind: 'function' },
{ name: 'main', kind: 'function' },
{ name: '__indirect_function_table', kind: 'table' },
{ name: '_start', kind: 'function' }
]

对应的 WebAssembly.Module.imports(m) 返回 imports(即 wasm 需要宿主提供哪些函数/内存)。

方法 3:llvm-nm / llvm-objdump(emsdk 自带,类 ELF 风格)

1
2
3
llvm-nm --extern-only demo.wasm           # 只看导出符号 (T=代码段 D=数据段)
llvm-objdump --section-headers demo.wasm # 看 wasm 各段大小(TYPE/IMPORT/CODE/EXPORT 等)

方法 4:wasm-objdump(wabt 工具集,需另行安装)

1
2
3
4
brew install wabt    # macOS
wasm-objdump -x -j Export demo.wasm # 只 dump Export section
wasm-objdump -d demo.wasm # 反汇编代码

工具速查

工具来源适合场景
wasm-disupstream/bin/(emsdk 自带)看完整 wat 含函数体
WebAssembly.Module.exports/importsNode/浏览器内置程序中动态查询
llvm-nm / llvm-objdumpupstream/bin/Unix 习惯 / 看 section 布局
wasm-objdump / wasm-tools dumpwabt / wasm-tools (外部)最贴近规范的 dump

导出名 ≠ C 源码函数名:Emscripten 默认会在 C 函数前加下划线(add_add),但 wasm-opt --post-emscripten 阶段会做导出名规范化,最终 wasm 中 export 名是去掉下划线的 add。所以传给 -sEXPORTED_FUNCTIONS_add,从 JS 端 Module.exports.add 调用是 add,两者一致。


4. 全局架构与数据流

4.1 编译管线全景

C/C++ 源码Clang 前端LLVM 中间表示LLVM 优化管线LLVM Wasm 后端.o Wasm 目标文件wasm-ld 链接器系统库 musl/libc++/compiler-rt原始 .wasm 二进制wasm-opt --post-emscripten(取代旧版 wasm-emscripten-finalize)处理后 .wasm元数据JS 编译器 compiler.mjsJS 胶水代码Binaryen wasm-opt 优化优化后 .wasm

4.2 核心模块职责

模块源码位置职责
emcc.pyemcc.py编译器驱动器,参数解析,流程编排
link.pytools/link.py链接阶段逻辑,系统库选择,设置推导
emscripten.pytools/emscripten.py元数据提取 → JS 胶水代码生成
building.pytools/building.py底层工具调用封装(clang, wasm-ld, wasm-opt)
system_libs.pytools/system_libs.py系统库构建与缓存管理
extract_metadata.pytools/extract_metadata.py从 wasm 二进制中解析结构信息
compiler.mjstools/compiler.mjsJS 库符号解析与代码生成
settings.jssrc/settings.js2000+ 行配置项定义

4.3 数据流的两条主线

编译主线(数据面)

1
2
源码 → IR → wasm object → linked wasm → optimized wasm

元数据主线(控制面)

1
2
linked wasm → extract metadata → settings inference → JS code generation

这种设计将"代码编译"与"运行时生成"解耦。Wasm 二进制的 import/export 信息决定了 JS 胶水层需要提供哪些实现——这是一个由数据驱动的过程。


5. 核心实现机制

5.1 JS 类型合法化(Legalization)

解决什么问题

WebAssembly 的 i64 类型无法直接传递给 JavaScript(在 BigInt 普及前)。C 函数若使用 int64_t 参数或返回值,跨越 wasm↔JS 边界时会出现类型不匹配。

如何实现

wasm-emscripten-finalize 工具对 wasm 模块的 import/export 签名进行改写:

1
2
3
4
原始签名:  (i64) → i64
合法化后: (i32, i32) → i32 // 低32位作为返回值
// 高32位通过全局变量 getTempRet0() 获取

对于每个包含 i64 的 import 函数,生成一个 wrapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;; 原始 import
(import "env" "foo" (func $foo (param i64) (result i64)))

;; 合法化后的 import
(import "env" "foo" (func $legalimport$foo (param i32 i32) (result i32)))

;; 生成的内部 wrapper
(func $foo (param $p i64) (result i64)
(local $lo i32) (local $hi i32)
(local.set $lo (i32.wrap_i64 (local.get $p)))
(local.set $hi (i32.wrap_i64 (i64.shr_u (local.get $p) (i64.const 32))))
(call $legalimport$foo (local.get $lo) (local.get $hi))
;; 返回值组合
(i64.or
(i64.extend_i32_u (call $legalimport$foo ...))
(i64.shl (i64.extend_i32_u (global.get $tempRet0)) (i64.const 32)))
)

为什么这样设计

JavaScript 的 Number 类型是 64 位 IEEE 754 浮点数,只能精确表示 53 位整数。直接传递 i64 会丢失精度。将 i64 拆分为两个 i32 是唯一能保证精确性的方案。

替代方案

  • WASM_BIGINT 模式-sWASM_BIGINT):现代浏览器支持 BigInt 后,可以直接传递 i64,跳过合法化。这消除了 wrapper 开销,是当前推荐方案。
  • 代价:需要目标环境支持 BigInt(Chrome 67+, Firefox 68+, Safari 15+)。

Trade-off

合法化增加了约 5-15% 的跨边界调用开销(额外的拆分/合并操作),但保证了对旧浏览器的兼容性。启用 WASM_BIGINT 后无此开销,但牺牲了向后兼容。


5.2 虚拟文件系统

解决什么问题

C 程序依赖 POSIX 文件 I/O(fopen, fread, write)。浏览器环境没有文件系统。若不提供文件系统抽象,几乎所有使用标准 I/O 的 C 程序都无法编译运行。

如何实现

Emscripten 实现了一个层次化的 VFS (Virtual File System):

VFS 分层架构C 标准库调用层fopen() / fread() / printf()musl libc (编译为 wasm)-> syscall(__NR_write, fd, buf, count)Syscall 桥接层 (JS)fd_write -> FS.write(stream, ...)VFS 抽象层 (JS)mount() / lookup() / read() / write()后端实现MEMFS | NODEFS | IDBFS | WORKERFS

MEMFS (Memory File System,默认):文件存储在 JavaScript 堆内存中,页面刷新后丢失。适合临时文件和嵌入式数据。

NODEFS (Node File System):直接映射到 Node.js 的 fs 模块,提供真实文件系统访问。仅限 Node.js 环境。

IDBFS (IndexedDB File System):使用浏览器 IndexedDB 持久化存储。支持 FS.syncfs() 手动同步。

为什么这样设计

分层设计允许同一份编译产物在不同环境中运行——只需挂载不同的文件系统后端。musl libc 的 syscall 接口作为稳定的分割点:musl 编译为 wasm(跨平台不变),syscall 实现在 JS 层(按环境适配)。

智能裁剪

emscripten.py 中的 maybe_disable_filesystem 函数检查程序实际使用了哪些 syscall。如果只使用了 fd_write(即只有 printf 输出),则自动禁用完整文件系统支持,大幅减小产物体积:

1
2
3
4
5
# tools/emscripten.py
syscalls = {d for d in all_imports if d.startswith(syscall_prefixes)}
if syscalls.issubset({'__syscall_ioctl', 'fd_seek', 'fd_write', 'fd_close', 'fd_fdstat_get'}):
settings.SYSCALLS_REQUIRE_FILESYSTEM = 0

Trade-off

完整 VFS 在 JS 中实现,增加约 50-100KB 的代码体积。对于只需简单 I/O 的程序是浪费。Emscripten 通过静态分析 import 来决定是否包含 VFS,在体积和兼容性之间取得平衡。

演进方向:WasmFS

传统 JS 实现的 VFS 受限于 JS 堆分配与 GC (Garbage Collection) 压力,性能不佳且不易扩展。-sWASMFS 启用完全用 C++ 编写的新一代文件系统(位于 system/lib/wasmfs/),代码在 wasm 内执行,性能与扩展性显著优于 JS VFS:

  • OPFS (Origin Private File System) 后端:浏览器提供的真正持久化存储,性能远优于 IndexedDB
  • 线程安全:内置 mutex,无需额外同步
  • 更低的 JS 调用开销:大部分逻辑在 wasm 内部完成

WasmFS 是 Emscripten 文件系统的演进方向,可能在未来版本中替代默认实现。


5.3 线性内存模型与栈管理

解决什么问题

WebAssembly 提供的是一段无类型的线性内存(WebAssembly.Memory)。C 程序需要栈、堆、全局数据段的标准布局。Wasm 本身不提供栈(Wasm 的执行栈是虚拟的,不可寻址),但 C 程序需要可寻址的栈空间(用于 alloca、VLA (Variable Length Array)、取栈变量地址等)。

如何实现

Emscripten 在线性内存中建立以下布局。先明确三个核心概念:

  • Shadow Stack(影子栈):Wasm 的操作数栈是虚拟的、不可寻址的,无法支持 C 中 &local_varalloca、VLA 等需取栈内地址的语义。Emscripten 在线性内存中维护一个独立的"影子栈"用于存放这类局部变量。栈指针由 wasm global __stack_pointer 维护,初值为 STACK_HIGH,向 STACK_LOW(低地址)方向增长。LLVM Wasm 后端会自动判断哪些变量必须放到 shadow stack 上。
  • GLOBAL_BASE / HEAP_BASE:分别是全局数据段起点与堆起点,将线性内存切分成"全局区 / 栈 / 堆"三部分。
  • NULL 页(NULL Page):地址 0 ~ GLOBAL_BASE-1(默认 0~1023)的低地址区,刻意保留不放任何有效数据,用于近似模拟传统 OS 的 NULL 指针保护。注意:wasm 没有 MMU (Memory Management Unit),地址 0 本身仍可被读写、不会自动 trap——这只是一个"软件约定 + 优化器假设":
    • GLOBAL_BASE = 1024 保证此区无全局变量
    • Binaryen 的 --low-memory-unused pass 假设它无用,做激进的访存偏移优化
    • STACK_OVERFLOW_CHECK >= 1 时运行时会主动检查写入地址 0 的操作来报告 NULL 写入
    • 例外:STACK_FIRST-O0 默认)下栈占据这段,NULL 页消失,改靠"栈溢出方向 → 地址 0 → wasm out-of-bounds trap"实现保护

优化构建(-O1+)默认布局

优化构建 (-O1+) 默认布局NULL 页 (保留区, 默认 0~1023)全局数据 / BSS (Block Started by Symbol) — GLOBAL_BASE影子栈 (Shadow Stack) ↑向低地址增长 — STACK_HIGH~STACK_LOW堆 (动态分配) ↓向高地址增长 — HEAP_BASE~MAXIMUM_MEMORYsbrk / dlmalloc 管理, 可通过 memory.grow 扩展

调试构建(-O0)默认布局STACK_FIRST=True):

调试构建 (-O0) STACK_FIRST 布局影子栈 (Shadow Stack) ↑向低地址增长 — STACK_HIGH~STACK_LOW=0全局数据 / BSS — GLOBAL_BASE堆 ↓向高地址增长 — HEAP_BASE

STACK_FIRST 模式的设计动机:栈溢出会向地址 0 方向溢出,触发 wasm trap(out-of-bounds memory access),而非悄悄覆盖 global data 导致静默数据损坏。这是一个调试友好性 vs 优化空间的权衡。

1
2
3
4
5
# tools/link.py — STACK_FIRST 默认在 -O0 下启用
if 'GLOBAL_BASE' not in user_settings and not settings.SHRINK_LEVEL \
and not settings.OPT_LEVEL and not settings.USE_ASAN:
settings.STACK_FIRST = True

栈地址计算逻辑(RELOCATABLE 模式下由 Python 侧计算,普通静态链接由 wasm-ld 确定):

1
2
3
4
5
6
7
8
# tools/emscripten.py — 仅在动态链接模式下使用
def set_memory(static_bump):
stack_low = align_memory(settings.GLOBAL_BASE + static_bump)
stack_high = align_memory(stack_low + settings.STACK_SIZE)
settings.STACK_HIGH = stack_high
settings.STACK_LOW = stack_low
settings.HEAP_BASE = align_memory(stack_high)

对于普通静态链接构建,STACK_LOW / STACK_HIGH / HEAP_BASE 从 wasm 二进制的 global exports 中提取(由 wasm-ld 在链接时确定)。

栈溢出检测

STACK_OVERFLOW_CHECK >= 2 时,Emscripten 通过 Binaryen 的 --check-stack-overflow pass 在每个函数入口插入检查:

1
2
3
4
5
6
7
(func $user_function
;; 检查 SP 是否越过 STACK_LOW
(if (i32.lt_u (global.get $__stack_pointer) (global.get $__stack_end))
(then (call $__handle_stack_overflow)))
;; ... 原始函数体
)

动态内存增长(ALLOW_MEMORY_GROWTH)

WebAssembly 的线性内存是由连续的页面(Page,每页 64KB)组成的 ArrayBuffer。默认情况下,Emscripten 会在编译时硬编码一个固定的初始内存大小(通常为 16MB)。如果 C/C++ 代码通过 malloc 申请的内存超出了这个上限,程序将直接发生 OOM (Out Of Memory) 崩溃。

通过开启编译参数 -sALLOW_MEMORY_GROWTH=1,可以允许 Wasm 模块在堆内存耗尽时,通过底层调用 memory.grow 指令动态向浏览器(JS Host)申请扩容线性内存。

为什么默认是关闭的? 动态扩容虽然能避免 OOM,但代价是:每次内存发生增长时,由于底层的 ArrayBuffer 被重新分配,JS 胶水层中所有指向旧 Wasm 内存的 TypedArray 视图(如 HEAPU8, HEAP32 等)都会瞬间失效(detached),必须执行昂贵的重新绑定(Rebinding)操作,这会引入运行时的微小卡顿(Stutter)。同时,固定大小的内存允许现代浏览器的 JIT 引擎(如 V8)在 AOT 编译阶段消除大量的内存边界检查(Bounds Checks),从而实现最极致的数组访问性能。因此,在内存需求可预测的场景中,建议提前估算并分配足够的固定内存大小(如 -sINITIAL_MEMORY=64MB),而不是依赖动态增长。

Trade-off

Shadow stack 带来额外的内存访问(与纯寄存器分配相比),但这是在 Wasm 线性内存模型下支持 C 语义的必要代价。LLVM 会尽量将不需要取地址的变量保留在 Wasm 局部变量中(等价于寄存器),只有必须时才溢出到 shadow stack。


5.4 Asyncify:将同步 C 代码转为异步执行

解决什么问题

浏览器是事件驱动模型,不允许阻塞主线程。但 C 程序中大量使用阻塞调用:sleep(), fgets(), emscripten_wget(), 游戏主循环中的 while(1)。直接运行会冻结页面。

如何实现

Asyncify 是 Binaryen 提供的一个 wasm-to-wasm 变换 pass。它对 wasm 函数进行 CPS (续体传递风格,Continuation-Passing Style) 变换,使得函数可以在任意点暂停并恢复。

核心原理:

原始执行流展开阶段 (Unwind)恢复阶段 (Rewind)main()A()B()sleep() <- 阻塞点sleep() 触发保存 B 的状态 -> 返回到 A保存 A 的状态 -> 返回到 main保存 main 状态 -> 返回到 JS 事件循环恢复 main 状态 -> 跳到 A恢复 A 状态 -> 跳到 B恢复 B 状态 -> 继续 sleep 之后 Asyncify 变换等待异步操作完成

变换后的函数结构(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(func $B (local $state i32)
;; 检查是否在 rewind
(if (global.get $asyncify_state)
(then
;; 从保存的数据恢复局部变量
(local.set $x (i32.load (asyncify_data_ptr)))
(br $resume_point)))

;; 正常执行
(local.set $x (i32.const 42))
(call $sleep)

;; sleep 返回后检查是否需要 unwind
(if (global.get $asyncify_state)
(then
;; 保存局部变量到 asyncify buffer
(i32.store (asyncify_data_ptr) (local.get $x))
(return)))

;; 继续后续逻辑
$resume_point:
;; ...
)

替代方案

  • JSPI (JavaScript Promise Integration):WebAssembly 标准提案,允许 wasm 函数返回 Promise。这是 Asyncify 的 "零成本" 替代——由引擎原生支持暂停/恢复。Emscripten 通过 -sJSPI 支持(早期通过 -sASYNCIFY=2 启用,现已废弃,应直接使用 -sJSPI)。支持 JSPI_EXPORTSJSPI_IMPORTS 精确控制哪些函数参与 Promise 调度。
  • emscripten_set_main_loop():不做代码变换,要求用户手动将主循环拆分为每帧回调。无运行时开销,但需要修改源码。

Trade-off

Asyncify 的变换会使 wasm 二进制体积膨胀 ~50%,运行时开销约 ~5%。因为每个可能在暂停路径上的函数都需要插入保存/恢复逻辑。通过 ASYNCIFY_ONLY / ASYNCIFY_REMOVE 可精确控制哪些函数参与变换,减小膨胀。

完整可运行示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// async_sleep.c — 演示同步 C 代码如何在浏览器中异步执行
#include <stdio.h>
#include <emscripten.h>

// 这个函数在原生环境中会阻塞,但通过 Asyncify 变换后
// 它会自动让出控制权给浏览器事件循环
void do_work() {
printf("Step 1: starting...\n");
emscripten_sleep(1000); // 暂停 1 秒(浏览器不会冻结)
printf("Step 2: after 1s sleep\n");
emscripten_sleep(500); // 再暂停 0.5 秒
printf("Step 3: done!\n");
}

int main() {
do_work();
return 0;
}

编译与运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 编译(启用 Asyncify)
emcc async_sleep.c -o async_sleep.js -O2 -sASYNCIFY

# 用 Node.js 运行
node async_sleep.js
# 输出(每行间有实际延迟):
# Step 1: starting...
# Step 2: after 1s sleep
# Step 3: done!

# 精确控制参与变换的函数(减小体积膨胀)
emcc async_sleep.c -o async_sleep.js -O2 \
-sASYNCIFY \
-sASYNCIFY_ONLY='["do_work","main"]'

5.5 MetaDCE:跨语言死代码消除

解决什么问题

Emscripten 产物包含两部分代码:wasm 和 JS。传统 DCE (Dead Code Elimination) 只能在单一语言内工作。但 wasm 中未使用的 export 可能在 JS 中被引用,JS 中未使用的 import 实现可能被 wasm 调用。需要跨语言的整体 DCE。

如何实现

-O3-Os-Oz 优化级别下(且未启用 ASSERTIONS),Emscripten 构建一个 跨语言依赖图

MetaDCE 依赖图✗ wasm: _unused_func(无入边,可删除)✗ JS: $dead_helper\n(无入边,可删除)JS: $initMemorywasm: memoryJS: $callMainwasm: _mainJS: fd_writewasm: _printfJS: $FS usesusesusesusesuses

工作流程:

  1. 使用 acorn-optimizeremitDCEGraph pass 对已生成的 JS 文件做静态分析,提取依赖图
  2. 从 wasm 二进制中提取 import/export 关系
  3. 合并为统一依赖图,写入 JSON 文件
  4. 从根集合(用户指定的 EXPORTED_FUNCTIONS + 入口点)做可达性分析
  5. 不可达的 wasm 函数通过 Binaryen 的 wasm-metadce 删除
  6. 不可达的 JS 函数通过 applyDCEGraphRemovals pass 从输出中移除
1
2
3
4
5
6
# tools/link.py
def will_metadce():
if settings.ASSERTIONS:
return False # 断言模式下的 JS 结构不兼容
return settings.OPT_LEVEL >= 3 or settings.SHRINK_LEVEL >= 1

Trade-off

MetaDCE 可减少 20-40% 的最终产物体积(取决于程序规模),但增加链接时间。在 debug 构建中禁用以保持快速迭代。

注意:MetaDCE 在 phase_binaryen()minify_wasm_js() 内部执行,位于 wasm-opt 主优化和 wasm-ctor-eval 之后。它同时修改 JS 和 wasm 两侧代码。


5.6 EM_JS / EM_ASM:内联 JavaScript 互操作

解决什么问题

C/C++ 代码需要调用浏览器 API(DOM 操作、WebGL、fetch 等)。为每个调用都编写单独的 JS 库文件过于繁琐。

如何实现

EM_JS 允许在 C 源码中直接嵌入 JavaScript 函数:

1
2
3
4
5
6
7
8
EM_JS(int, compute_dom_height, (), {
return document.body.clientHeight;
});

int main() {
int h = compute_dom_height(); // 调用 JS 函数
}

实现路径:

  1. Clang 将 EM_JS 的 JS 代码体存入 wasm object 的 __em_js__compute_dom_height 全局变量(作为数据段字符串)
  2. 函数本身声明为 wasm import
  3. 链接后,extract_metadata 从 wasm global exports 中提取 __em_js__ 前缀的字符串
  4. JS compiler 将其生成为对应的 import 实现函数

EM_ASM 是内联版本,通过运行时 eval-like 机制执行(通过 emscripten_asm_const_* 函数 + 字符串查找表),适合一次性代码片段。EM_JS 性能更优(无运行时字符串解析),是推荐方式。

Trade-off

EM_JS 的体积开销几乎为零(JS 代码直接嵌入胶水层)。EM_ASM 有运行时查找开销但使用更灵活(支持内联在任意函数体中)。


5.7 EVAL_CTORS:编译时执行全局构造函数

解决什么问题

C++ 程序的全局构造函数(__wasm_call_ctors)在每次加载时执行,初始化全局对象、静态变量等。对于复杂应用,这可能耗时数百毫秒。

如何实现

-sEVAL_CTORS 使用 Binaryen 的 wasm-ctor-eval 工具,在编译时解释执行全局构造函数,将结果直接嵌入 wasm 的 data segment。运行时跳过已求值的构造函数,实现 "编译时求值"。

这是一种“部分求值”策略——只有纯计算型的构造函数(无副作用,不调用 import)能被安全地在编译时执行。涉及 I/O 或外部状态的构造函数保留在运行时执行。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ctor_example.cpp
#include <cmath>
#include <cstdio>

// 这个全局构造可以被 EVAL_CTORS 在编译时求值
static double lookup_table[256];
struct TableInit {
TableInit() {
for (int i = 0; i < 256; i++)
lookup_table[i] = sin(i * 3.14159 / 128.0);
}
};
static TableInit table_init; // 全局构造

int main() {
printf("sin(64) = %f\n", lookup_table[64]);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
# 普通编译:运行时执行构造函数
emcc ctor_example.cpp -o normal.js -O2

# 启用 EVAL_CTORS:编译时执行构造函数,结果嵌入 data segment
emcc ctor_example.cpp -o evalctors.js -O2 -sEVAL_CTORS

# 效果:
# - normal.wasm 中包含 sin() 计算代码,启动时执行 256 次循环
# - evalctors.wasm 中 lookup_table 已是预计算的常量,启动即用
# - evalctors.wasm 的 data segment 稍大(含 2KB 查找表),但启动时间更短

Trade-off

EVAL_CTORS 用启动时间换 data segment 体积。只有纯计算型构造函数(无副作用、不访问 import)能被安全求值;涉及 I/O、外部状态或动态分配的构造函数会保留到运行时执行。这是 Binaryen wasm-ctor-eval 的安全保证。


5.8 Feature Matrix:自动浏览器特性降级

LLVM 默认生成的 wasm 可能使用较新的特性(符号扩展/sign-extension、块内存操作/bulk-memory、非截断浮点到整数转换/nontrapping-fptoint),但目标浏览器可能不支持。tools/feature_matrix.py 维护了一个“特性 × 浏览器版本”矩阵,根据 MIN_CHROME_VERSION / MIN_FIREFOX_VERSION / MIN_SAFARI_VERSION,自动通过 Binaryen pass 降级特定指令:

1
2
3
4
5
6
# tools/link.py
if not feature_matrix.caniuse(feature_matrix.Feature.SIGN_EXT):
passes += ['--signext-lowering']
if not feature_matrix.caniuse(feature_matrix.Feature.BULK_MEMORY):
passes += ['--llvm-memory-copy-fill-lowering']

这使得同一份源码可以根据目标环境自动生成兼容性不同的 wasm 二进制——类似于 Babel 的 browserslist 机制,但作用于二进制指令层面。

Trade-off

降级 pass 会带来轻微的代码体积膨胀和性能损失(如 signext-lowering 用 shift 序列模拟 sign-extension)。默认 MIN_*_VERSION 设为较新版本,鼓励使用原生 wasm 指令;只有在显式指定旧浏览器目标时才会启用降级。


5.9 JS 库链接机制(JS Library System)

解决什么问题

Emscripten 需要用 JavaScript 实现大量系统调用(文件 I/O、网络、OpenGL 等),这些 JS 函数必须按需链接——只有 wasm 实际 import 的函数才应包含在最终产物中。同时函数间存在复杂依赖(如 fd_write 依赖 $FS),需要自动解析依赖图并按正确顺序输出。

架构概览

JS 库系统的核心组件:

1
2
3
4
5
6
7
8
9
10
11
src/lib/           ← 81 个 JS 库文件
libcore.js ← 基础运行时(stackSave/stackRestore 等)
libfs.js ← 文件系统
libsyscall.js ← syscall 实现
libpthread.js ← pthread 支持
libwebgl.js ← WebGL 绑定
...

src/modules.mjs ← LibraryManager: 加载与注册所有库
src/jsifier.mjs ← 核心链接器: 依赖解析 + 代码生成

库函数声明格式

每个 JS 库文件通过 addToLibrary() 注册函数,并使用 装饰器后缀 声明元信息:

1
2
3
4
5
6
7
8
9
10
11
12
// src/lib/libcore.js
addToLibrary({
// 函数实现($ 前缀表示 JS-only 符号,不对应 C 名字)
$stackSave__deps: ['emscripten_stack_get_current'], // 依赖声明
$stackSave: () => _emscripten_stack_get_current(),

// wasm import 实现(无 $ 前缀 → 对应 C 符号 _emscripten_resize_heap)
emscripten_resize_heap__deps: ['$getHeapMax', 'emscripten_notify_memory_growth'],
emscripten_resize_heap__sig: 'ip', // 签名: 返回 i32, 参数 pointer
emscripten_resize_heap: (requestedSize) => { /* ... */ },
});

装饰器后缀完整列表:

后缀作用
__deps依赖列表(字符串数组),链接时递归拉入
__sig函数签名(用于 i64 合法化和类型检查)
__postset初始化代码,在模块加载后执行
__async标记为异步函数(Asyncify 感知)
__proxy线程代理模式:'sync'/'async'/'none'
__i53abi启用 i64→i53 自动转换
__docsJSDoc 注释注入
__internal标记为内部函数(不导出到 Module)

链接流程

jsifier.mjs 链接流程1. LibraryManager.load() 加载库加载所有 src/lib/*.js -> 合并为统一 library 对象2. 确定需要的符号集合 (symbolsNeeded)= 默认库函数 + 导出函数中匹配的库函数 + 导出的运行时方法3. 对每个需要的符号,递归解析 __depsgetTransitiveDeps: BFS 遍历 -> 收集所有传递依赖4. 按依赖拓扑序输出 JS 代码i64 签名处理 / 隐式依赖注入 / 线程代理包装 / __postset 收集5. 输出最终 .js 文件

依赖解析:getTransitiveDeps

这是链接机制的核心算法,实现了 BFS 传递依赖收集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/jsifier.mjs
function getTransitiveDeps(symbol) {
const transitiveDeps = new Set();
const seen = new Set();
const toVisit = [symbol];
while (toVisit.length) {
const sym = toVisit.pop();
if (!seen.has(sym)) {
let directDeps = LibraryManager.library[sym + '__deps'] || [];
directDeps = directDeps.filter((d) => typeof d === 'string');
for (const dep of directDeps) {
const resolved = resolveAlias(dep);
transitiveDeps.add(resolved);
toVisit.push(resolved); // 继续展开
}
seen.add(sym);
}
}
return Array.from(transitiveDeps);
}

名字约定

  • $ 前缀:纯 JS 辅助符号(如 $FS$PATH),不对应任何 C 函数名,链接后变量名去掉 $
  • 无前缀:对应 C 符号,链接后加 _ 前缀(如库中 fd_write → wasm import _fd_write
  • 别名:值为字符串时表示别名(如 stackAlloc: '$stackAlloc'

条件编译

JS 库文件支持 C 预处理器风格的条件编译(由 Emscripten 的 JS 预处理器处理):

1
2
3
4
5
6
7
8
9
10
11
// src/lib/libfs.js
$FS__deps: [
'$PATH', '$MEMFS',
#if LibraryManager.has('libidbfs.js')
'$IDBFS', // 仅当用户 --js-library 包含 libidbfs 时
#endif
#if ASSERTIONS
'$strError', '$ERRNO_CODES', // 仅 debug 模式
#endif
],

这使得最终产物只包含实际启用的功能。

JS 预处理管线:库文件先经过 preprocess()(处理 #if/#else/#endif/#include)进行条件裁剪,再经过 processMacros()(展开 {{{ expr }}} 宏表达式)生成最终 JS 代码。两者是串行的两阶段处理(processMacros(preprocess(fileName)))。

隐式依赖注入

jsifier.mjs 还会扫描函数体,自动注入常用依赖(无需手动在 __deps 中声明):

1
2
3
4
5
6
7
8
const autoDeps = [
'getDynCaller', // makeDynCall 宏生成
'getWasmTableEntry', // 间接调用
'runtimeKeepalivePush', // 事件循环管理
'runtimeKeepalivePop',
'UTF8ToString', // 字符串转换
];

如果函数体中包含 UTF8ToString(,会自动添加 $UTF8ToString 依赖。

关键 Trade-off

  • 按需链接 vs 全量包含:默认只链接被引用的函数;-sINCLUDE_FULL_LIBRARY 强制包含所有
  • Tree-shaking 精度:依赖声明是手动的,遗漏 __deps 会导致运行时 ReferenceError;多余的 __deps 会增大产物
  • 预处理开销:所有 81 个库文件都会被加载和预处理,即使大部分函数最终不被链接

6. 一次完整编译路径分析

emcc hello.c -O2 -o hello.js 为例追踪完整执行路径:

Phase 1: 参数解析与模式确定

1
2
3
4
5
6
7
8
9
10
11
# emcc.py: run()

# 0. Response file 展开(兼容 GCC 工具链的 @file 语法)
args = substitute_response_files(args) # 展开 @response_file.txt

# 1. 参数解析
state = EmccState(args)
options, newargs = phase_parse_arguments(state)
# 确定 mode = COMPILE_AND_LINK(默认)
# 解析 -O2 → OPT_LEVEL=2, SHRINK_LEVEL=0

Phase 2: 编译阶段

1
2
3
4
5
6
7
8
9
# emcc.py 调用 clang
cmd = [CLANG_CC,
'--target=wasm32-unknown-emscripten',
'--sysroot=/path/to/cache/sysroot',
'-O2',
'-mllvm', '-combiner-global-alias-analysis=false',
'hello.c',
'-c', '-o', 'hello.o']

Clang 使用 LLVM 的 WebAssembly 后端,生成 .o 格式的 wasm object file(遵循 wasm object file 规范,包含 relocation 信息)。

Phase 3: 链接阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# tools/link.py: phase_linker_setup()
# 推导需要链接的系统库
libraries = system_libs.calculate([...])
# 典型结果:libcompiler_rt.a, libc.a, libdlmalloc.a, libc++.a

# 调用 wasm-ld
cmd = [WASM_LD,
'hello.o',
'-L/path/to/cache/sysroot/lib/wasm32-emscripten',
'-lc', '-ldlmalloc', '-lcompiler_rt',
'--export=main', '--export=__data_end', '--export=__stack_pointer',
'-o', 'hello.wasm',
'--import-memory',
'--stack-first'] # 注:实际参数更复杂

wasm-ld (LLVM linker) 完成:符号解析、重定位、段合并、dead stripping。

Phase 4: 后处理与 JS 生成

重要演进说明:在 Emscripten 4.0+ 中,wasm-emscripten-finalize 不再被显式调用。其职责已被 wasm-ld 的 export flags 和 wasm-opt 的 --post-emscripten pass 取代。以下为 实测验证的实际工具链顺序(emcc 4.0.7)。

实际执行顺序(-O2 实测)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
wasm-ld 输出

▼ [llvm-objcopy: 剥离调试段]
│ --remove-section=.debug*
│ --remove-section=producers
│ --remove-section=name

▼ [compiler.mjs: 生成 JS 胶水代码]
│ node tools/compiler.mjs -
│ (从 stdin 读取 settings JSON)

▼ [wasm-opt: 优化 + post-emscripten 处理]
│ --strip-target-features
│ --post-emscripten ← 替代了旧版 finalize 的职责
│ -O2
│ --low-memory-unused
│ --zero-filled-memory
│ --pass-arg=directize-initial-contents-immutable

▼ [acorn-optimizer: JSDCE]
JSDCE --minify-whitespace
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# tools/emscripten.py: emscript() 核心流程

# 4a. 元数据提取 + 条件性修改
metadata = finalize_wasm(in_wasm, out_wasm, js_syms)
# 内部流程:
# 1. extract_metadata.extract_metadata() ← 解析 wasm 二进制
# 2. 在 4.0+ 中,wasm-emscripten-finalize 已不再调用
# 其职责由 wasm-opt --post-emscripten 承接

# 4b. 设置推导
update_settings_glue(wasm_file, metadata, base_metadata)
# 根据 imports 决定需要哪些 JS 库函数
# 根据 exports 决定暴露哪些接口

# 4c. JS 编译
glue, forwarded_data = compile_javascript()
# 运行 node tools/compiler.mjs,从 stdin 读取 settings
# 解析 src/lib/lib*.js 中的库实现
# 只包含被引用的库函数(tree-shaking)

wasm-emscripten-finalize 的历史与现状

在早期版本中,wasm-emscripten-finalize 负责 i64 合法化、dyncalls 生成、堆栈检查注入等。在 Emscripten 4.0+ 中,这些职责已分散到:

原 finalize 职责现由谁承接
i64 合法化wasm-ld 导出配置 + wasm-opt --post-emscripten
dyncalls 生成wasm-ld --export-if-defined flags
调试段剥离llvm-objcopy --remove-section
metadata 提取Python 端 extract_metadata.py 直接解析

注:源码中 finalize_wasm() 函数仍然存在,但在默认配置下 modify_wasm=False,即不再调用外部 finalize 工具。仅在极少数场景下(如 WASM2JS、显式 DYNCALLS)才会触发。

Phase 5: Binaryen 优化

实测验证的 wasm-opt 实际 passes(-O2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 实测 emcc 4.0.7 实际调用
wasm-opt \
--strip-target-features \
--post-emscripten \
-O2 \
--low-memory-unused \
--zero-filled-memory \
--pass-arg=directize-initial-contents-immutable \
hello.wasm -o hello.wasm \
--mvp-features --enable-bulk-memory --enable-bulk-memory-opt \
--enable-call-indirect-overlong --enable-multivalue \
--enable-mutable-globals --enable-nontrapping-float-to-int \
--enable-reference-types --enable-sign-ext

-O3 完整流程(含 MetaDCE + 二次 wasm-opt)

-O3 完整后处理流程(实测验证)1. wasm-opt (主优化)--post-emscripten -O3 --no-stack-ir--low-memory-unused --zero-filled-memory2. acorn-optimizer (JS 死代码消除)进阶 JSDCE,移除未使用的 JS 函数3. emitDCEGraph (构建依赖图)构造跨语言依赖图 JSON4. wasm-metadce (跨语言 DCE)--graph-file -> 跨 JS/wasm 死代码消除5. applyDCEGraphRemovals从 JS 中移除不可达函数6. 二次 JS 死代码消除清理 DCE 后新产生的死代码7. wasm-opt (二次优化)--minify-imports-and-exports-and-modules压缩导入/导出名称,优化 StackIR8. applyImportAndExportNameChangesJS 端同步重命名后的导入/导出名

Binaryen 的优化 passes 包括:常量折叠、死代码消除、函数内联、本地变量合并、控制流简化、StackIR 优化等。

实测产物体积对比

hello.c(仅含 printf)为例,实测数据(emcc 4.0.7, macOS):

优化级别.wasm.jswasm 缩减JS 缩减
-O015,170 B51,959 Bbaselinebaseline
-O21,980 B9,769 B-86.9%-81.2%
-O31,740 B9,266 B-88.5%-82.2%

-O3 相比 -O2 再缩小 wasm 240B(约12%),主要归功于 MetaDCE + 导入/导出名最小化。

Phase 6: 最终组装

将生成的 JS 代码与 pre-js、post-js、shell 模板组合,输出最终的 hello.js


7. Runtime 与底层机制

7.1 系统调用桥接

Emscripten 的 libc 基于 musl,编译为 wasm。musl 中的 syscall 接口被重定向到 JS 实现:

1
2
3
4
5
6
C: printf("hello")
→ musl: __stdio_write()
→ musl: __wasi_fd_write(fd, iovs, iovcnt, &nwritten) // 直接 WASI API 调用
→ wasm import: fd_write(fd, iovs, iovs_len, pnum)
→ JS: function fd_write(...) { /* 浏览器环境实现 */ }

musl 中通过 #if __EMSCRIPTEN__ 条件编译,直接调用 WASI API 函数(如 __wasi_fd_write),而非通过通用的 syscall 分发器。这消除了传统 Linux 中 syscall dispatcher 的间接调用开销。

syscall 接口采用 WASI (WebAssembly System Interface) preview1 规范的子集。这意味着:

  • 函数命名遵循 WASI 约定(fd_write 而非 __syscall_write
  • 参数传递遵循 WASI ABI
  • 同一 wasm binary 有潜力在 WASI runtime 中直接运行(STANDALONE_WASM 模式)

7.2 C++ 异常处理的两条路径

Emscripten 支持两种 C++ 异常实现:

JS-based exceptions-fexceptions,默认):

  • 每个可能抛异常的 wasm 函数调用被包裹在 invoke_* wrapper 中
  • wrapper 在 JS 层使用 try/catch 捕获异常
  • 异常对象存储在 JS 堆中,通过指针传递
  • 开销:每次调用都需 JS ↔ Wasm 边界跳转,性能影响显著

Wasm native exceptions-fwasm-exceptions):

  • 使用 WebAssembly EH (Exception Handling) 指令(try/catch/throw
  • 异常完全在 wasm 内部处理,无需跨越 JS 边界
  • 性能接近原生异常处理
  • 需要浏览器支持 Wasm EH proposal(Chrome 95+, Firefox 100+)

示例与对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// exceptions.cpp
#include <stdio.h>
#include <stdexcept>

void might_throw(int x) {
if (x < 0) throw std::runtime_error("negative value");
printf("value = %d\n", x);
}

int main() {
try {
might_throw(42);
might_throw(-1);
} catch (const std::exception& e) {
printf("caught: %s\n", e.what());
}
return 0;
}
1
2
3
4
5
6
7
8
9
# 方式 1: JS-based exceptions(兼容性好,体积大)
emcc exceptions.cpp -o exc_js.js -O2 -fexceptions

# 方式 2: Wasm native exceptions(性能好,体积小)
emcc exceptions.cpp -o exc_wasm.js -O2 -fwasm-exceptions

# 方式 3: 禁用异常(最小体积,但 throw 会 abort)
emcc exceptions.cpp -o exc_none.js -O2 -fno-exceptions

典型体积对比:

模式.wasm.js说明
-fexceptions~45 KB~25 KB含 invoke_* wrappers + JS 异常表
-fwasm-exceptions~30 KB~12 KB原生 EH 指令,无 JS 边界跨越
-fno-exceptions~8 KB~10 KB无异常支持,最小

7.3 pthread → Web Worker 映射

主线程工作线程 1工作线程 2Wasm 实例 (共享内存)SharedArrayBuffer = wasmMemoryWasm 实例(同一 SharedArrayBuf)= pthread 1Wasm 实例(同一 SAB)= pthread 2 postMessage / AtomicspostMessage / Atomics

关键实现约束:

  • 所有线程共享同一 SharedArrayBuffer(SAB,= wasm 线性内存)
  • 使用 Atomics.wait() / Atomics.notify() 实现 futex 语义
  • pthread_create 在 JS 层创建 Web Worker,Worker 内实例化相同的 wasm 模块
  • 需要 Cross-Origin-Isolation HTTP headers(COOP=Cross-Origin Opener Policy / COEP=Cross-Origin Embedder Policy)
  • 内存增长(memory.grow)在多线程模式下有性能警告——所有线程的 TypedArray view 需要重建

最小可运行示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// threads.c
#include <stdio.h>
#include <pthread.h>
#include <emscripten.h>

void* thread_func(void* arg) {
int id = *(int*)arg;
printf("Thread %d: Hello from worker!\n", id);
return NULL;
}

int main() {
pthread_t threads[4];
int ids[4] = {0, 1, 2, 3};

for (int i = 0; i < 4; i++) {
pthread_create(&threads[i], NULL, thread_func, &ids[i]);
}
for (int i = 0; i < 4; i++) {
pthread_join(threads[i], NULL);
}
printf("All threads done.\n");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
# 编译(启用 pthread,预创建 4 个 Worker)
emcc threads.c -o threads.js -O2 \
-pthread \
-sPTHREAD_POOL_SIZE=4

# Node.js 运行(需要 --experimental-wasm-threads)
node --experimental-wasm-threads threads.js

# 浏览器运行需要设置 COOP/COEP headers:
# Cross-Origin-Opener-Policy: same-origin
# Cross-Origin-Embedder-Policy: require-corp

注:-sPTHREAD_POOL_SIZE=N 预创建 Worker 池,避免首次 pthread_create 时的 Worker 创建延迟。

7.4 运行时生命周期

页面加载下载 .wasm预加载文件 (addRunDependency)WebAssembly.instantiate()创建 Memory, Table / 绑定 imports / 初始化全局变量__wasm_call_ctors()C++ 全局构造函数 / __attribute__((constructor))main(入口函数)emscripten_set_main_loop() 或直接返回主循环 (requestAnimFrame)每帧回调用户函数exit() / 页面卸载atexit 处理函数 / __wasm_call_dtors() 析构

8. 性能与工程取舍

8.1 编译产物的性能特征

指标相对原生代码说明
计算密集型代码~1.0-1.5xWasm 接近原生性能
内存访问~1.0-1.2x线性内存模型开销小
间接函数调用~2-3xTable 间接调用 + 类型检查
JS ↔ Wasm 边界调用~10-50ns/call取决于参数数量和类型
文件 I/O~5-20xVFS 在 JS 中实现
线程创建~100xWeb Worker 创建成本高

8.2 体积优化策略

Emscripten 在不同优化级别下的行为:

  • -O0:不优化,保留所有调试信息,包含完整 ASSERTIONS
  • -O1:不运行 Binaryen 优化 passes,但仍可能运行 feature lowering passes(如 signext-lowering、asyncify 等)
  • -O2:运行 Binaryen 优化 passes
  • -O3:启用 MetaDCE + 更激进的内联
  • -Os/-Oz:面向体积优化(SHRINK_LEVEL=1/2),同样触发 MetaDCE
1
2
3
4
5
6
7
# tools/link.py
def should_run_binaryen_optimizer():
# -O1 不运行 Binaryen 优化 passes——LLVM 已做大部分优化工作
# 保持 -O1 "快编译但已优化"的语义
# 注意:feature lowering passes(signext/bulk-memory 降级)不受此限制
return settings.OPT_LEVEL >= 2

8.3 关键工程决策

决策 1:系统库缓存

系统库(musl, libc++, compiler-rt)编译一次后缓存在 ~/.emscripten_cache/。不同的编译配置(pthread, MEMORY64, RELOCATABLE)有不同的缓存变体。首次编译可能需要数十秒构建系统库;后续编译直接使用缓存。

决策 2:增量式 JS 生成

JS 胶水代码不是一个模板填充过程,而是由 compiler.mjs 根据 settings 和实际 import 需求按需组装。只有被 wasm 实际引用的 JS 库函数才会包含在输出中。

决策 3:延迟编译系统库

1
2
3
4
# tools/system_libs.py
# 系统库在链接时按需构建,而非安装时全量构建
# 不同的 settings 组合产生不同的库变体

这意味着第一次使用新的编译配置会有冷启动延迟,但避免了预编译所有可能的变体组合(组合爆炸问题)。


9. 与同类方案对比

Emscripten vs wasi-sdk

维度Emscriptenwasi-sdk
目标环境浏览器 + Node.jsWASI runtime (wasmtime, wasmer)
输出产物.wasm + .js + .html纯 .wasm
JS 胶水自动生成无(由 runtime 提供 WASI 实现)
系统调用WASI 子集 + Emscripten 扩展纯 WASI preview1
文件系统VFS (MEMFS/IDBFS/NODEFS)由 WASI runtime 提供
OpenGL/SDL完整支持(映射到 WebGL/Canvas)不支持
pthreadWeb Worker 映射wasi-threads 提案
DOM 访问支持 (EM_JS, embind)不支持
产物可移植性绑定 Web 平台跨 WASI runtime
libcmusl (修改版)musl (wasi-libc)

选择 Emscripten 的场景:需要在浏览器中运行,需要 Web API 访问(图形、音频、网络),需要与 JS 深度互操作。

选择 wasi-sdk 的场景:目标是服务端 wasm 执行,不需要 Web 特有 API,需要 WASI 标准兼容性。

Emscripten vs AssemblyScript

维度EmscriptenAssemblyScript
源语言C/C++ (任何 LLVM 前端)TypeScript 子集
编译器LLVM + Binaryen自研编译器
内存管理手动 (malloc/free)GC runtime (in wasm)
优化成熟度极高 (LLVM 数十年积累)较低
产物体积中等 (含 libc)小 (无 libc 依赖)
适用场景移植现有 C/C++ 代码从零写 wasm 应用
学习曲线低 (对 C/C++ 开发者)低 (对 TS 开发者)
性能接近原生中等(GC 开销)

AssemblyScript 的定位是 "TypeScript 到 Wasm 的直接路径",适合从零编写小型 wasm 模块。Emscripten 的定位是 "移植现有大型 C/C++ 代码库到 Web"。


10. 源码关键路径分析

对于希望阅读 Emscripten 源码的开发者,以下是核心路径图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
入口点
emcc.py: main() → run()

├── phase_parse_arguments() // 参数解析
│ 解析 -s 设置, 输入文件, 输出格式

├── [编译阶段] 调用 clang // 若有 .c/.cpp 输入
│ get_clang_flags() + get_cflags()
│ → subprocess: clang --target=wasm32-unknown-emscripten

└── [链接阶段]
tools/link.py: phase_linker_setup()

├── 推导 settings (2000+ 设置项的依赖关系)
├── system_libs.calculate() // 确定需要哪些系统库

├── phase_link()
│ 调用 wasm-ld

├── phase_post_link()
│ tools/emscripten.py: emscript()
│ ├── extract_metadata() // 解析 wasm 二进制
│ ├── finalize_wasm() // Python 包装函数;4.0+ 已不再调用
│ │ // 同名 wasm-emscripten-finalize 二进制
│ │ // (其职责由 wasm-opt --post-emscripten 承接)
│ ├── compile_javascript() // 生成 JS
│ └── 组装最终 JS

└── phase_binaryen()
├── wasm-opt (get_binaryen_passes)
├── wasm-ctor-eval (EVAL_CTORS)
├── JS 后处理 (safe_heap/asan 注入)
├── minify_wasm_js()
│ ├── JSDCE / AJSDCE
│ ├── metadce (wasm-metadce) ← 跨语言 DCE
│ └── minify_wasm_imports_and_exports
└── Closure compiler (可选)

注:后处理阶段 JS 和 wasm 的优化是交替进行的,MetaDCE 同时修改两侧代码,而非简单的“先 wasm 后 JS”。

建议阅读顺序

  1. emcc.py — 理解整体流程编排
  2. tools/link.py: phase_linker_setup() — 理解 settings 推导逻辑(这是最复杂的部分,600+ 行)
  3. tools/emscripten.py: emscript() — 理解 JS 胶水代码生成
  4. tools/extract_metadata.py — 理解 wasm 二进制解析
  5. tools/webassembly.py — Python 实现的 wasm 二进制解析库
  6. src/preamble.js + src/postamble.js — 理解运行时结构
  7. src/jsifier.mjs + src/modules.mjs — 实际 JS 代码生成与模块/符号管理
  8. tools/system_libs.py — 理解系统库管理
  9. src/settings.js + src/settings_internal.js — 2000+ 行配置项定义及内部设置

11. 测试基础设施

理解了编译流水线之后,理解 Emscripten 如何验证自己的编译正确性 同样重要——这套测试基础设施本身就是逆向理解工具链行为的最佳入口:每个编译选项、每个 settings 推导分支都对应了 test/ 中的具体用例。当你想确认某个 flag 在不同优化级别下的真实行为时,对应的测试模式(如 core2.test_xxx)往往是比文档更可靠的参考。

Emscripten 提供了一套完善的测试框架,覆盖编译器行为验证、运行时正确性和浏览器兼容性。

测试架构概览

1
2
3
4
5
6
7
8
9
10
11
12
test/
├── runner.py ← 测试入口(支持并行、随机测试等模式)
├── common.py ← 基础类 RunnerCore,提供 do_run/do_runf/断言等
├── parallel_testsuite.py ← 多进程并行测试执行器
├── test_core.py ← 核心编译测试 (~9800 行,最大测试套件)
├── test_other.py ← 工具链、链接、杂项测试 (~15900 行)
├── test_browser.py ← 浏览器环境测试 (WebGL, SDL, 音频等)
├── test_sockets.py ← 网络/WebSocket 测试
├── test_sanity.py ← 安装/配置/环境完整性测试
├── test_benchmark.py ← 性能基准测试
└── test_posixtest.py ← POSIX 合规性测试

测试模式 (Test Modes)

测试框架通过"测试模式"控制编译配置,同一个测试用例可以在不同模式下运行:

模式说明
core0-O0 无优化,基准模式
core2-O2 优化
core3-O3 优化
cores-Os 体积优化
corez-Oz 极致体积
strict严格模式(禁用废弃 API)
wasm2js*wasm2js 后备模式
wasm6464位内存模式
asanAddressSanitizer
lsanLeakSanitizer
ubsanUndefinedBehaviorSanitizer
browser浏览器环境测试
wasmfsWasmFS 文件系统后端

运行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 运行单个测试
python test/runner.py test_core.test_hello_world

# 指定测试模式
python test/runner.py core2.test_hello_world

# 运行整个测试套件
python test/runner.py test_core

# 并行执行(自动根据 CPU 核心数)
python test/runner.py test_core -j

# 随机跑 100 个 core 测试
python test/runner.py random100

# 跑浏览器测试(需要设置 EMTEST_BROWSER)
EMTEST_BROWSER=chrome python test/runner.py test_browser

# 跳过慢测试
EMTEST_SKIP_SLOW=1 python test/runner.py test_core

测试用例编写示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# test/test_core.py 中的典型测试用例
class test_core(RunnerCore):
def test_hello_world(self):
# do_run: 编译源码→运行→验证输出
self.do_run(r'''
#include <stdio.h>
int main() {
printf("hello, world!\n");
return 0;
}
''', 'hello, world!')

def test_float_math(self):
# do_run_in_out_file_test: 自动匹配 .c/.out 文件对
self.do_run_in_out_file_test('core/test_float_math.c')

@requires_threads
def test_pthread_create(self):
# 条件装饰器 + 额外 emcc 参数
self.do_runf('pthread/test_pthread_create.c',
'thread done',
emcc_args=['-pthread', '-sPTHREAD_POOL_SIZE=1'])

核心测试工具方法

方法用途
do_run(src, expected)编译内联源码,运行并验证输出
do_runf(file, expected)编译文件,运行并验证输出
do_run_in_out_file_test(src)自动匹配 .c/.out 文件对
assertContained(val, str)断言字符串包含
run_process(cmd)执行子进程并捕获输出
emcc(src, args)调用 emcc 编译
require_v8() / require_node()声明 JS 引擎依赖
@is_slow_test标记慢测试(可跳过)
@flaky()标记不稳定测试(可重试)

环境变量控制

变量作用
EMTEST_BROWSER指定浏览器命令(设为 0 禁用浏览器测试)
EMTEST_SKIP_SLOW跳过标记为 slow 的测试
EMTEST_SKIP_FLAKY跳过不稳定测试
EMTEST_RETRY_FLAKY不稳定测试重试次数
EMTEST_ALL_ENGINES在所有 JS 引擎上运行
EMTEST_SAVE_DIR保留测试临时目录(调试用)
EMTEST_REBASELINE重新生成期望输出文件

设计特点

  • 并行执行parallel_testsuite.py 使用多进程池并行跑测试,每个测试在独立临时目录中执行
  • 模式复用:同一测试可以通过不同模式运行(-O0/-O2/-O3/wasm2js/wasm64),覆盖多种配置组合
  • 自动 rebaselineEMTEST_REBASELINE=1 自动更新 .out 期望输出文件
  • 浏览器测试BrowserCore 基类内建 HTTP 服务器,自动启动浏览器并通过截图对比验证渲染结果
  • 备选引擎:支持 Node.js、V8 (d8)、SpiderMonkey (sm) 多引擎交叉验证
  • CI 集成:在 GitHub Actions 上每次提交自动运行 ~3000 个测试用例

12. 实践建议

适用场景

  • 将现有 C/C++ 库移植到 Web(如图像处理、加密、编解码器)
  • 性能敏感的 Web 应用模块(如 wasm 比 JS 快 3-10x 的场景)
  • 游戏引擎移植(Unity, Unreal 均使用 Emscripten)
  • 将桌面应用移植到浏览器(如 AutoCAD Web, Figma 的 C++ 渲染引擎)

不适用场景

  • 从零编写简单的 wasm 模块(直接写 Rust/AssemblyScript 更简单)
  • 需要 WASI 标准兼容的服务端 wasm(用 wasi-sdk)
  • 产物体积极度敏感 < 10KB 的场景(Emscripten 最小产物约 ~15KB)
  • 不需要 Web API 的纯计算场景(WASI + wasmtime 更轻量)

常见误区

  1. 误区:-O3 总是最优。对于体积敏感场景,-Os-Oz 可能产生更小产物且性能差异不大。
  2. 误区:所有 C 代码都可以直接编译。阻塞调用、fork()、信号处理等需要适配。
  3. 误区:wasm 体积 = 产物体积。JS 胶水代码可能比 wasm 本身还大。-sMINIMAL_RUNTIME 可大幅削减 JS 开销。
  4. 误区:多线程无条件可用。需要 SharedArrayBuffer(需要安全头 COOP/COEP),Safari 限制 Worker 数量。

性能陷阱

  • 频繁的 JS↔Wasm 边界调用:每次跨越边界有固定开销。批量处理数据优于逐元素调用。
  • 无脑开启 -sALLOW_MEMORY_GROWTH=1:固定内存大小(默认的 0)能允许 V8 等 JS 引擎做最极致的内存访问越界检查消除,开启动态增长会带来一定性能损耗。
  • Debug 构建部署到生产ASSERTIONS 在热路径中插入大量检查。
  • 未使用 -sEXPORTED_FUNCTIONS 精确指定导出:多余的导出阻止 DCE。

常用命令 Cheatsheet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# === 基础编译 ===
emcc hello.c -o hello.html # 生成 .html + .js + .wasm
emcc hello.c -o hello.js # 只生成 .js + .wasm(用于 Node.js)
emcc hello.c -o hello.wasm -sSTANDALONE_WASM # 纯 wasm(无 JS 胶水)

# === 优化级别 ===
emcc src.c -o out.js -O0 # 无优化,快编译,含 debug 信息
emcc src.c -o out.js -O2 # 推荐生产级
emcc src.c -o out.js -O3 # 最大优化(含 MetaDCE)
emcc src.c -o out.js -Os # 体积优先
emcc src.c -o out.js -Oz # 极致体积

# === 常用 flags ===
emcc src.c -o out.js -O2 \
-sEXPORTED_FUNCTIONS='["_main","_my_func"]' # 精确导出

emcc src.c -o out.js -O2 \
-sEXPORTED_RUNTIME_METHODS='["ccall","cwrap"]' \
-sALLOW_MEMORY_GROWTH # 暴露 JS API + 允许堆增长

# === 调试 ===
emcc src.c -o out.js -g # 保留调试信息
emcc src.c -o out.js -gsource-map # 生成 source map
emcc src.c -o out.js -sASSERTIONS=2 # 最详细的运行时检查
emcc src.c -o out.js -v # 显示完整工具链调用

# === 特殊功能 ===
emcc src.c -o out.js -sASYNCIFY # 启用同步→异步变换
emcc src.c -o out.js -pthread -sPTHREAD_POOL_SIZE=4 # 多线程
emcc src.c -o out.js -sWASM_BIGINT # 使用 BigInt 传递 i64
emcc src.c -o out.js -sEVAL_CTORS # 编译时求值构造函数
emcc src.c -o out.js -sMINIMAL_RUNTIME # 最小 JS 运行时

# === 体积分析 ===
emcc src.c -o out.html -O2 --emit-symbol-map # 生成符号映射
emcc src.c -o out.js -O3 --closure 1 # Closure Compiler 压缩 JS

# === C++ 与 JS 互操作 ===
emcc src.cpp -o out.js -lembind # 启用 embind(C++ 类绑定到 JS)
emcc src.c -o out.js --js-library my_lib.js # 自定义 JS 库

13. 总结

Emscripten 本质上是一个 跨环境 ABI 适配层。它的核心洞察是:将一个面向 POSIX 的程序移植到 Web 平台,不只是一个指令集翻译问题——真正的难度在于系统接口的语义桥接。

最重要的设计思想:

  1. 数据驱动的运行时生成:不是提供一个通用 runtime 然后让用户手动配置,而是从编译产物(wasm import/export)自动推导出最小必要的 JS 运行时。这使得一个 hello world 只需要 2KB 运行时,而一个 OpenGL 游戏能自动获得 WebGL 绑定。

  2. 分层解耦的系统库架构:musl libc 编译为 wasm(平台无关),syscall 实现在 JS 中(平台适配)。这个分割点的选择使得 libc 可以保持上游同步,而不需要修改整个 libc 实现。

  3. 工具链而非运行时的设计哲学:与 JVM 或 .NET 不同,Emscripten 的复杂性集中在编译时而非运行时。最终产物是自包含的,不需要独立安装的 runtime。

最值得学习的工程实践:将复杂度前移到编译时。通过编译时的静态分析(import 分析、死代码检测、文件系统需求推断)来最小化运行时开销——这是一种在编译器工程中被反复验证的有效策略。