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 的默认目标。
这不是一个单纯的编译器后端问题。它需要同时解决:
- 编译目标的指令集架构 ISA (Instruction Set Architecture) 转换(x86/ARM → WebAssembly)
- 系统调用的语义桥接(POSIX syscall → Web API)
- 运行时环境的重建(libc, filesystem, threading → JS/Wasm 实现)
- 应用二进制接口 ABI (Application Binary Interface) 兼容性(C 调用约定/calling convention → Wasm 函数签名)
Emscripten 在技术栈中的定位是一个 完整的交叉编译工具链——不只是编译器后端,而是从源码到可在浏览器中运行的完整产物的全链路方案。
Emscripten 的独特价值在于:它不仅编译代码,还提供了将原生程序 完整运行 在 Web 环境中所需的一切基础设施。
2. 从一个最小示例开始
1 | // hello.c |
编译命令:
1 | emcc hello.c -o hello.html |
这条命令触发的完整流水线:
1 | hello.c |
注:现代 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 | emcc hello.c -o hello.js |
若只需 wasm 模块(无 JS 胶水):
1 | 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 | // 1. 运行时初始化 |
真实 -O2 产物片段分析
以下是 emcc hello.c -o hello.js -O2 生成的实际 JS 产物关键片段(精简注释):
1 | // === 内存初始化 === |
注意:-O2 产物已经过 JSDCE 和 minify-whitespace 处理,以上为可读性还原版。实际
hello.js仅 9,769 B(约 9.5 KB),与 §2 实测一致。
3.3 产物间的协作关系
关键认知: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 | wasm-dis demo.wasm | grep '(export' |
实测输出:
1 | (export "memory" (memory $0)) |
每行包含:导出名、类型(func / memory / table / global)、内部索引。要看 imports 类似:wasm-dis demo.wasm | grep '(import'。
方法 2:WebAssembly.Module.exports()(无需任何工具)
Node.js 一行即可,适合在程序中动态查询:
1 | node -e 'const fs=require("fs"); |
实测输出:
1 | [ |
对应的 WebAssembly.Module.imports(m) 返回 imports(即 wasm 需要宿主提供哪些函数/内存)。
方法 3:llvm-nm / llvm-objdump(emsdk 自带,类 ELF 风格)
1 | llvm-nm --extern-only demo.wasm # 只看导出符号 (T=代码段 D=数据段) |
方法 4:wasm-objdump(wabt 工具集,需另行安装)
1 | brew install wabt # macOS |
工具速查
| 工具 | 来源 | 适合场景 |
|---|---|---|
wasm-dis | upstream/bin/(emsdk 自带) | 看完整 wat 含函数体 |
WebAssembly.Module.exports/imports | Node/浏览器内置 | 程序中动态查询 |
llvm-nm / llvm-objdump | upstream/bin/ | Unix 习惯 / 看 section 布局 |
wasm-objdump / wasm-tools dump | wabt / 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 编译管线全景
4.2 核心模块职责
| 模块 | 源码位置 | 职责 |
|---|---|---|
| emcc.py | emcc.py | 编译器驱动器,参数解析,流程编排 |
| link.py | tools/link.py | 链接阶段逻辑,系统库选择,设置推导 |
| emscripten.py | tools/emscripten.py | 元数据提取 → JS 胶水代码生成 |
| building.py | tools/building.py | 底层工具调用封装(clang, wasm-ld, wasm-opt) |
| system_libs.py | tools/system_libs.py | 系统库构建与缓存管理 |
| extract_metadata.py | tools/extract_metadata.py | 从 wasm 二进制中解析结构信息 |
| compiler.mjs | tools/compiler.mjs | JS 库符号解析与代码生成 |
| settings.js | src/settings.js | 2000+ 行配置项定义 |
4.3 数据流的两条主线
编译主线(数据面):
1 | 源码 → IR → wasm object → linked wasm → optimized wasm |
元数据主线(控制面):
1 | 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 | 原始签名: (i64) → i64 |
对于每个包含 i64 的 import 函数,生成一个 wrapper:
1 | ;; 原始 import |
为什么这样设计
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):
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 | # tools/emscripten.py |
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_var、alloca、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-unusedpass 假设它无用,做激进的访存偏移优化 STACK_OVERFLOW_CHECK >= 1时运行时会主动检查写入地址 0 的操作来报告 NULL 写入- 例外:
STACK_FIRST(-O0默认)下栈占据这段,NULL 页消失,改靠"栈溢出方向 → 地址 0 → wasm out-of-bounds trap"实现保护
- 由
优化构建(-O1+)默认布局:
调试构建(-O0)默认布局(STACK_FIRST=True):
STACK_FIRST 模式的设计动机:栈溢出会向地址 0 方向溢出,触发 wasm trap(out-of-bounds memory access),而非悄悄覆盖 global data 导致静默数据损坏。这是一个调试友好性 vs 优化空间的权衡。
1 | # tools/link.py — STACK_FIRST 默认在 -O0 下启用 |
栈地址计算逻辑(RELOCATABLE 模式下由 Python 侧计算,普通静态链接由 wasm-ld 确定):
1 | # tools/emscripten.py — 仅在动态链接模式下使用 |
对于普通静态链接构建,STACK_LOW / STACK_HIGH / HEAP_BASE 从 wasm 二进制的 global exports 中提取(由 wasm-ld 在链接时确定)。
栈溢出检测
当 STACK_OVERFLOW_CHECK >= 2 时,Emscripten 通过 Binaryen 的 --check-stack-overflow pass 在每个函数入口插入检查:
1 | (func $user_function |
动态内存增长(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) 变换,使得函数可以在任意点暂停并恢复。
核心原理:
变换后的函数结构(伪代码):
1 | (func $B (local $state i32) |
替代方案
- JSPI (JavaScript Promise Integration):WebAssembly 标准提案,允许 wasm 函数返回 Promise。这是 Asyncify 的 "零成本" 替代——由引擎原生支持暂停/恢复。Emscripten 通过
-sJSPI支持(早期通过-sASYNCIFY=2启用,现已废弃,应直接使用-sJSPI)。支持JSPI_EXPORTS和JSPI_IMPORTS精确控制哪些函数参与 Promise 调度。 - emscripten_set_main_loop():不做代码变换,要求用户手动将主循环拆分为每帧回调。无运行时开销,但需要修改源码。
Trade-off
Asyncify 的变换会使 wasm 二进制体积膨胀 ~50%,运行时开销约 ~5%。因为每个可能在暂停路径上的函数都需要插入保存/恢复逻辑。通过 ASYNCIFY_ONLY / ASYNCIFY_REMOVE 可精确控制哪些函数参与变换,减小膨胀。
完整可运行示例
1 | // async_sleep.c — 演示同步 C 代码如何在浏览器中异步执行 |
编译与运行:
1 | # 编译(启用 Asyncify) |
5.5 MetaDCE:跨语言死代码消除
解决什么问题
Emscripten 产物包含两部分代码:wasm 和 JS。传统 DCE (Dead Code Elimination) 只能在单一语言内工作。但 wasm 中未使用的 export 可能在 JS 中被引用,JS 中未使用的 import 实现可能被 wasm 调用。需要跨语言的整体 DCE。
如何实现
在 -O3、-Os 或 -Oz 优化级别下(且未启用 ASSERTIONS),Emscripten 构建一个 跨语言依赖图:
工作流程:
- 使用
acorn-optimizer的emitDCEGraphpass 对已生成的 JS 文件做静态分析,提取依赖图 - 从 wasm 二进制中提取 import/export 关系
- 合并为统一依赖图,写入 JSON 文件
- 从根集合(用户指定的 EXPORTED_FUNCTIONS + 入口点)做可达性分析
- 不可达的 wasm 函数通过 Binaryen 的
wasm-metadce删除 - 不可达的 JS 函数通过
applyDCEGraphRemovalspass 从输出中移除
1 | # tools/link.py |
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 | EM_JS(int, compute_dom_height, (), { |
实现路径:
- Clang 将 EM_JS 的 JS 代码体存入 wasm object 的
__em_js__compute_dom_height全局变量(作为数据段字符串) - 函数本身声明为 wasm import
- 链接后,
extract_metadata从 wasm global exports 中提取__em_js__前缀的字符串 - 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 | // ctor_example.cpp |
1 | # 普通编译:运行时执行构造函数 |
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 | # tools/link.py |
这使得同一份源码可以根据目标环境自动生成兼容性不同的 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 | src/lib/ ← 81 个 JS 库文件 |
库函数声明格式
每个 JS 库文件通过 addToLibrary() 注册函数,并使用 装饰器后缀 声明元信息:
1 | // src/lib/libcore.js |
装饰器后缀完整列表:
| 后缀 | 作用 |
|---|---|
__deps | 依赖列表(字符串数组),链接时递归拉入 |
__sig | 函数签名(用于 i64 合法化和类型检查) |
__postset | 初始化代码,在模块加载后执行 |
__async | 标记为异步函数(Asyncify 感知) |
__proxy | 线程代理模式:'sync'/'async'/'none' |
__i53abi | 启用 i64→i53 自动转换 |
__docs | JSDoc 注释注入 |
__internal | 标记为内部函数(不导出到 Module) |
链接流程
依赖解析:getTransitiveDeps
这是链接机制的核心算法,实现了 BFS 传递依赖收集:
1 | // src/jsifier.mjs |
名字约定
$前缀:纯 JS 辅助符号(如$FS、$PATH),不对应任何 C 函数名,链接后变量名去掉$- 无前缀:对应 C 符号,链接后加
_前缀(如库中fd_write→ wasm import_fd_write) - 别名:值为字符串时表示别名(如
stackAlloc: '$stackAlloc')
条件编译
JS 库文件支持 C 预处理器风格的条件编译(由 Emscripten 的 JS 预处理器处理):
1 | // src/lib/libfs.js |
这使得最终产物只包含实际启用的功能。
JS 预处理管线:库文件先经过
preprocess()(处理#if/#else/#endif/#include)进行条件裁剪,再经过processMacros()(展开{{{ expr }}}宏表达式)生成最终 JS 代码。两者是串行的两阶段处理(processMacros(preprocess(fileName)))。
隐式依赖注入
jsifier.mjs 还会扫描函数体,自动注入常用依赖(无需手动在 __deps 中声明):
1 | const autoDeps = [ |
如果函数体中包含 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 | # emcc.py: run() |
Phase 2: 编译阶段
1 | # emcc.py 调用 clang |
Clang 使用 LLVM 的 WebAssembly 后端,生成 .o 格式的 wasm object file(遵循 wasm object file 规范,包含 relocation 信息)。
Phase 3: 链接阶段
1 | # tools/link.py: phase_linker_setup() |
wasm-ld (LLVM linker) 完成:符号解析、重定位、段合并、dead stripping。
Phase 4: 后处理与 JS 生成
重要演进说明:在 Emscripten 4.0+ 中,
wasm-emscripten-finalize不再被显式调用。其职责已被 wasm-ld 的 export flags 和 wasm-opt 的--post-emscriptenpass 取代。以下为 实测验证的实际工具链顺序(emcc 4.0.7)。
实际执行顺序(-O2 实测)
1 | wasm-ld 输出 |
1 | # tools/emscripten.py: emscript() 核心流程 |
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 | # 实测 emcc 4.0.7 实际调用 |
-O3 完整流程(含 MetaDCE + 二次 wasm-opt)
Binaryen 的优化 passes 包括:常量折叠、死代码消除、函数内联、本地变量合并、控制流简化、StackIR 优化等。
实测产物体积对比
以 hello.c(仅含 printf)为例,实测数据(emcc 4.0.7, macOS):
| 优化级别 | .wasm | .js | wasm 缩减 | JS 缩减 |
|---|---|---|---|---|
-O0 | 15,170 B | 51,959 B | baseline | baseline |
-O2 | 1,980 B | 9,769 B | -86.9% | -81.2% |
-O3 | 1,740 B | 9,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 | C: printf("hello") |
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 | // exceptions.cpp |
1 | # 方式 1: JS-based 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 映射
关键实现约束:
- 所有线程共享同一
SharedArrayBuffer(SAB,= wasm 线性内存) - 使用
Atomics.wait()/Atomics.notify()实现 futex 语义 pthread_create在 JS 层创建 Web Worker,Worker 内实例化相同的 wasm 模块- 需要
Cross-Origin-IsolationHTTP headers(COOP=Cross-Origin Opener Policy / COEP=Cross-Origin Embedder Policy) - 内存增长(
memory.grow)在多线程模式下有性能警告——所有线程的 TypedArray view 需要重建
最小可运行示例
1 | // threads.c |
1 | # 编译(启用 pthread,预创建 4 个 Worker) |
注:
-sPTHREAD_POOL_SIZE=N预创建 Worker 池,避免首次pthread_create时的 Worker 创建延迟。
7.4 运行时生命周期
8. 性能与工程取舍
8.1 编译产物的性能特征
| 指标 | 相对原生代码 | 说明 |
|---|---|---|
| 计算密集型代码 | ~1.0-1.5x | Wasm 接近原生性能 |
| 内存访问 | ~1.0-1.2x | 线性内存模型开销小 |
| 间接函数调用 | ~2-3x | Table 间接调用 + 类型检查 |
| JS ↔ Wasm 边界调用 | ~10-50ns/call | 取决于参数数量和类型 |
| 文件 I/O | ~5-20x | VFS 在 JS 中实现 |
| 线程创建 | ~100x | Web 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 | # tools/link.py |
8.3 关键工程决策
决策 1:系统库缓存
系统库(musl, libc++, compiler-rt)编译一次后缓存在 ~/.emscripten_cache/。不同的编译配置(pthread, MEMORY64, RELOCATABLE)有不同的缓存变体。首次编译可能需要数十秒构建系统库;后续编译直接使用缓存。
决策 2:增量式 JS 生成
JS 胶水代码不是一个模板填充过程,而是由 compiler.mjs 根据 settings 和实际 import 需求按需组装。只有被 wasm 实际引用的 JS 库函数才会包含在输出中。
决策 3:延迟编译系统库
1 | # tools/system_libs.py |
这意味着第一次使用新的编译配置会有冷启动延迟,但避免了预编译所有可能的变体组合(组合爆炸问题)。
9. 与同类方案对比
Emscripten vs wasi-sdk
| 维度 | Emscripten | wasi-sdk |
|---|---|---|
| 目标环境 | 浏览器 + Node.js | WASI runtime (wasmtime, wasmer) |
| 输出产物 | .wasm + .js + .html | 纯 .wasm |
| JS 胶水 | 自动生成 | 无(由 runtime 提供 WASI 实现) |
| 系统调用 | WASI 子集 + Emscripten 扩展 | 纯 WASI preview1 |
| 文件系统 | VFS (MEMFS/IDBFS/NODEFS) | 由 WASI runtime 提供 |
| OpenGL/SDL | 完整支持(映射到 WebGL/Canvas) | 不支持 |
| pthread | Web Worker 映射 | wasi-threads 提案 |
| DOM 访问 | 支持 (EM_JS, embind) | 不支持 |
| 产物可移植性 | 绑定 Web 平台 | 跨 WASI runtime |
| libc | musl (修改版) | musl (wasi-libc) |
选择 Emscripten 的场景:需要在浏览器中运行,需要 Web API 访问(图形、音频、网络),需要与 JS 深度互操作。
选择 wasi-sdk 的场景:目标是服务端 wasm 执行,不需要 Web 特有 API,需要 WASI 标准兼容性。
Emscripten vs AssemblyScript
| 维度 | Emscripten | AssemblyScript |
|---|---|---|
| 源语言 | 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 | 入口点 |
注:后处理阶段 JS 和 wasm 的优化是交替进行的,MetaDCE 同时修改两侧代码,而非简单的“先 wasm 后 JS”。
建议阅读顺序:
emcc.py— 理解整体流程编排tools/link.py: phase_linker_setup()— 理解 settings 推导逻辑(这是最复杂的部分,600+ 行)tools/emscripten.py: emscript()— 理解 JS 胶水代码生成tools/extract_metadata.py— 理解 wasm 二进制解析tools/webassembly.py— Python 实现的 wasm 二进制解析库src/preamble.js+src/postamble.js— 理解运行时结构src/jsifier.mjs+src/modules.mjs— 实际 JS 代码生成与模块/符号管理tools/system_libs.py— 理解系统库管理src/settings.js+src/settings_internal.js— 2000+ 行配置项定义及内部设置
11. 测试基础设施
理解了编译流水线之后,理解 Emscripten 如何验证自己的编译正确性 同样重要——这套测试基础设施本身就是逆向理解工具链行为的最佳入口:每个编译选项、每个 settings 推导分支都对应了 test/ 中的具体用例。当你想确认某个 flag 在不同优化级别下的真实行为时,对应的测试模式(如 core2.test_xxx)往往是比文档更可靠的参考。
Emscripten 提供了一套完善的测试框架,覆盖编译器行为验证、运行时正确性和浏览器兼容性。
测试架构概览
1 | test/ |
测试模式 (Test Modes)
测试框架通过"测试模式"控制编译配置,同一个测试用例可以在不同模式下运行:
| 模式 | 说明 |
|---|---|
core0 | -O0 无优化,基准模式 |
core2 | -O2 优化 |
core3 | -O3 优化 |
cores | -Os 体积优化 |
corez | -Oz 极致体积 |
strict | 严格模式(禁用废弃 API) |
wasm2js* | wasm2js 后备模式 |
wasm64 | 64位内存模式 |
asan | AddressSanitizer |
lsan | LeakSanitizer |
ubsan | UndefinedBehaviorSanitizer |
browser | 浏览器环境测试 |
wasmfs | WasmFS 文件系统后端 |
运行测试
1 | # 运行单个测试 |
测试用例编写示例
1 | # test/test_core.py 中的典型测试用例 |
核心测试工具方法
| 方法 | 用途 |
|---|---|
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),覆盖多种配置组合
- 自动 rebaseline:
EMTEST_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 更轻量)
常见误区
- 误区:
-O3总是最优。对于体积敏感场景,-Os或-Oz可能产生更小产物且性能差异不大。 - 误区:所有 C 代码都可以直接编译。阻塞调用、
fork()、信号处理等需要适配。 - 误区:wasm 体积 = 产物体积。JS 胶水代码可能比 wasm 本身还大。
-sMINIMAL_RUNTIME可大幅削减 JS 开销。 - 误区:多线程无条件可用。需要
SharedArrayBuffer(需要安全头 COOP/COEP),Safari 限制 Worker 数量。
性能陷阱
- 频繁的 JS↔Wasm 边界调用:每次跨越边界有固定开销。批量处理数据优于逐元素调用。
- 无脑开启
-sALLOW_MEMORY_GROWTH=1:固定内存大小(默认的 0)能允许 V8 等 JS 引擎做最极致的内存访问越界检查消除,开启动态增长会带来一定性能损耗。 - Debug 构建部署到生产:
ASSERTIONS在热路径中插入大量检查。 - 未使用
-sEXPORTED_FUNCTIONS精确指定导出:多余的导出阻止 DCE。
常用命令 Cheatsheet
1 | # === 基础编译 === |
13. 总结
Emscripten 本质上是一个 跨环境 ABI 适配层。它的核心洞察是:将一个面向 POSIX 的程序移植到 Web 平台,不只是一个指令集翻译问题——真正的难度在于系统接口的语义桥接。
最重要的设计思想:
-
数据驱动的运行时生成:不是提供一个通用 runtime 然后让用户手动配置,而是从编译产物(wasm import/export)自动推导出最小必要的 JS 运行时。这使得一个 hello world 只需要 2KB 运行时,而一个 OpenGL 游戏能自动获得 WebGL 绑定。
-
分层解耦的系统库架构:musl libc 编译为 wasm(平台无关),syscall 实现在 JS 中(平台适配)。这个分割点的选择使得 libc 可以保持上游同步,而不需要修改整个 libc 实现。
-
工具链而非运行时的设计哲学:与 JVM 或 .NET 不同,Emscripten 的复杂性集中在编译时而非运行时。最终产物是自包含的,不需要独立安装的 runtime。
最值得学习的工程实践:将复杂度前移到编译时。通过编译时的静态分析(import 分析、死代码检测、文件系统需求推断)来最小化运行时开销——这是一种在编译器工程中被反复验证的有效策略。