C++ 与 WebAssembly:Emscripten 工具链与运行时实践

WebAssembly 让 C++ 代码可以跑在浏览器、Node.js 与边缘函数里。本文从线性内存模型讲起,梳理 emcc、emcmake、emrun 等命令,详解 -O 级别与 -s 设置的取舍,给出 KEEPALIVE、embind、val 三种互操作方案与 WASI。

把桌面级的 C++ 代码搬到浏览器里,曾经意味着用 JavaScript 重写一遍。WebAssembly 改变了这件事:它是一套紧凑的栈式字节码,能被浏览器以接近原生的速度执行,同时保持完整的沙箱隔离。Emscripten 是目前最成熟的 C++ 到 WebAssembly 工具链,它基于 LLVM/Clang,把 C++ 编译成 wasm 并生成一层 JavaScript 胶水代码来处理内存、文件系统与 DOM 交互。

一、WebAssembly 基础与线性内存模型

WebAssembly 模块(.wasm)由若干段组成:类型段、导入段、函数段、内存段、全局段、导出段。理解它的内存模型是写出高效代码的前提。

  • 线性内存:模块持有一块连续字节数组,导出为 WebAssembly.Memory。在 JS 侧它是一个 ArrayBuffer,在 C++ 侧就是所有指针指向的地址空间。wasm32 下地址是 32 位,理论上限 4 GB,实践中受 MAXIMUM_MEMORY 限制。
  • 栈式虚拟机:指令从操作数栈取参数、压结果,没有寄存器概念,JIT 编译器会把它映射到真实寄存器。
  • 函数表:函数指针不直接存地址,而是存在 WebAssembly.Table 里的索引。这保证了间接调用只能跳到合法函数,是沙箱安全性的来源。
  • 无系统调用:wasm 本身没有文件、网络、时钟的概念,一切能力都要由宿主通过导入函数显式提供,WASI 就是一套标准化的宿主接口。
wasm-objdump -x module.wasm | head -30   # wabt 工具链查看模块结构
wasm2wat module.wasm -o module.wat       # 反汇编为可读的文本格式

二、Emscripten 工具链

2.1 emcc、emcmake、emrun 与配套工具

Emscripten SDK(emsdk)安装后会激活一批命令,它们都是 Clang 的封装:

命令作用
emcc / em++C / C++ 编译器,接口与 gcc/g++ 高度兼容
emcmake包装 cmake,自动注入 Emscripten 工具链文件
emmake包装 make,把 gcc/g++ 替换为 emcc/em++
emrun启动一个本地 HTTP 服务器并打开页面,方便测试
emar / emranlib静态库归档工具
emnm / emstrip / emsize符号查看、剥离与体积分析工具
git clone https://github.com/emscripten-core/emsdk.git && cd emsdk
./emsdk install latest && ./emsdk activate latest
source ./emsdk_env.sh && em++ --version    # 每个新 shell 都要 source
# emcc (Emscripten gcc/clang-like replacement) 3.1.60

2.2 从 C++ 到 wasm 的第一次编译

// hello.cpp
#include <cstdio>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) { return a + b; }
int main() {
    std::printf("add(2, 3) = %d\n", add(2, 3));
    return 0;
}
em++ hello.cpp -o hello.js          # 产出 hello.js(胶水代码)与 hello.wasm(模块本体)
em++ hello.cpp -o hello.html        # 直接产出可运行的 HTML 页面,适合快速验证
emrun --port 8080 hello.html        # 本地起服务器并打开浏览器

EMSCRIPTEN_KEEPALIVE 展开为 __attribute__((used, visibility("default"))),作用是阻止链接器把未被引用的函数当作死代码删掉——这是 C++ 导出函数给 JS 调用时最常踩的坑。

三、编译选项详解

3.1 -O 级别

级别含义适用场景
-O0不优化,保留断言与名称调试
-O1基础优化快速迭代
-O2常规优化,关闭运行时断言日常发布
-O3激进优化,含跨模块优化与 LTO性能优先
-Os以体积为主首屏加载敏感
-Oz更激进地压体积极限体积
em++ app.cpp -o app.js -O0 -g -gsource-map -sASSERTIONS=2   # 调试:保留断言与名称
em++ app.cpp -o app.js -O3 -flto --closure 1                # 发布:性能优先
em++ app.cpp -o app.js -Oz --closure 1 -sFILESYSTEM=0       # 体积优先

-O2 及以上会关闭 ASSERTIONS 与内存安全检查,体积和速度都明显改善;调试阶段务必回到 -O0 -sASSERTIONS=2,否则越界访问只会表现为莫名其妙的错误结果。

3.2 -s 设置

-s 用来配置运行时行为,取值写在 -sNAME=VALUE 或 -s NAME=VALUE 中。

em++ app.cpp -o app.js -O3 \
    -sMODULARIZE=1 -sEXPORT_NAME=createApp -sEXPORT_ES6=1 \
    -sENVIRONMENT=web,node -sALLOW_MEMORY_GROWTH=1 \
    -sINITIAL_MEMORY=64MB -sSTACK_SIZE=1MB \
    -sEXPORTED_FUNCTIONS=_main,_add,_process \
    -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPF32,UTF8ToString

关键设置说明:

  • MODULARIZE=1 把胶水代码包成一个工厂函数,避免污染全局作用域,是现代打包器的必备选项。
  • EXPORT_ES6=1 输出 ES Module,配合 MODULARIZE 使用。
  • EXPORTED_FUNCTIONS 列出要导出的 C 符号,下划线前缀不可省略(_add 对应 C 的 add)。
  • EXPORTED_RUNTIME_METHODS 导出运行时辅助方法,ccall/cwrap 用于调用导出函数,HEAPF32 等用于直接读写内存。
  • ALLOW_MEMORY_GROWTH=1 允许堆在运行期增长,代价是 HEAP* 视图在增长后失效,需要重新获取。
  • FILESYSTEM=0 不链接文件系统,能省下几十 KB;SINGLE_FILE=1 则把 wasm 以 base64 内嵌进 JS,只产出一个文件。

3.3 内存与文件系统选项

INITIAL_MEMORY 设得过大会拖慢启动(要分配并清零),过小则频繁增长,经验值是「稳态峰值的 1.5 倍」。MALLOC=emmalloc 体积优先,适合分配次数不多的场景;dlmalloc 是默认值,mimalloc 性能最好但体积更大。MAXIMUM_MEMORY 配合 ABORTING_MALLOC=0,可在超限时让 malloc 返回空指针而不是直接 abort。

四、C++ 与 JS 互操作

4.1 EMSCRIPTEN_KEEPALIVE 与 ccall/cwrap

最轻量的方式是导出 C 函数,JS 侧用 ccall/cwrap 调用。

// api.cpp
#include <emscripten.h>

extern "C" {

EMSCRIPTEN_KEEPALIVE
int fib(int n) {
    int a = 0, b = 1;
    for (int i = 0; i < n; ++i) { int t = a + b; a = b; b = t; }
    return a;
}

EMSCRIPTEN_KEEPALIVE
double sum_floats(const float* data, int n) {
    double s = 0.0;
    for (int i = 0; i < n; ++i) s += data[i];
    return s;
}

}  // extern "C"
em++ api.cpp -o api.js -O3 -sMODULARIZE=1 -sEXPORT_NAME=createApi \
    -sEXPORTED_FUNCTIONS=_fib,_sum_floats,_malloc,_free \
    -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPF32
import createApi from './api.js';
const Module = await createApi();

console.log(Module.ccall('fib', 'number', ['number'], [10]));   // 55

const fib = Module.cwrap('fib', 'number', ['number']);   // 绑定一次,反复调用更快
console.log(fib(20));                                    // 6765

const n = 4;                                             // 传数组
const ptr = Module._malloc(n * 4);
Module.HEAPF32.set(new Float32Array([1.5, 2.5, 3.5, 4.5]), ptr >> 2);
console.log(Module.ccall('sum_floats', 'number', ['number', 'number'], [ptr, n]));
Module._free(ptr);

注意 HEAPF32 的索引是「字节地址除以 4」,所以写 ptr >> 2。若开启了 ALLOW_MEMORY_GROWTH,内存增长后 HEAPF32 会指向新的 ArrayBuffer,必须重新读取 Module.HEAPF32 而不能缓存。

4.2 embind

embind 用宏注册类型与函数,自动处理 std::string、std::vector、类与重载,是面向对象接口的首选。

// bindings.cpp
#include <emscripten/bind.h>
#include <string>
#include <vector>

using namespace emscripten;

int add(int a, int b) { return a + b; }
std::string greet(const std::string& name) { return "hello, " + name; }

std::vector<int> range(int n) {
    std::vector<int> v;
    for (int i = 0; i < n; ++i) v.push_back(i);
    return v;
}

class Counter {
public:
    explicit Counter(int start) : value_(start) {}
    void inc() { ++value_; }
    int  value() const { return value_; }
private:
    int value_;
};

EMSCRIPTEN_BINDINGS(my_module) {
    function("add", &add);
    function("greet", &greet);
    function("range", &range);
    register_vector<int>("VectorInt");
    class_<Counter>("Counter")
        .constructor<int>()
        .function("inc", &Counter::inc)
        .function("value", &Counter::value);
}
em++ bindings.cpp -o bindings.js -O3 -lembind -sMODULARIZE=1 -sEXPORT_NAME=createBindings
import createBindings from './bindings.js';
const M = await createBindings();
console.log(M.add(2, 3), M.greet('wasm'));   // 5 hello, wasm

const v = M.range(5);
for (let i = 0; i < v.size(); ++i) console.log(v.get(i));

const c = new M.Counter(10);
c.inc();
console.log(c.value());   // 11
c.delete();               // 必须手动释放,否则内存泄漏

embind 的代价是体积:链接 -lembind 会给产物增加 30 到 80 KB,且字符串与容器的每次跨界传递都有一次拷贝。若接口只需要传几个数字,用 EMSCRIPTEN_KEEPALIVE 更划算。

4.3 emscripten::val

emscripten::val 是一个动态类型的 JS 值包装,让 C++ 可以直接操作 DOM 与 JS 对象。

#include <emscripten/val.h>
#include <string>

void update_ui(int count) {
    using emscripten::val;
    val document = val::global("document");
    val el = document.call<val>("getElementById", std::string("counter"));
    el.set("textContent", std::to_string(count));
    val now = val::global("Date").call<val>("now");   // 调用 JS 函数并接收返回值
    el.set("dataset", val::object());
    el["dataset"].set("ts", now);
}

val 的每次属性访问与函数调用都要经过 JS 互操作层,开销远高于普通 C++ 调用,不要把它放进内层循环。

五、虚拟文件系统

Emscripten 提供了一套 POSIX 风格的文件系统 API,底层由若干后端实现:

后端说明
MEMFS默认,全部在内存中,页面刷新即丢失
IDBFS基于 IndexedDB,需调用 FS.syncfs 持久化
NODEFS挂载 Node.js 的真实目录
PROXYFS跨 worker 共享文件系统
WORKERFS只读挂载 File/Blob 对象
em++ app.cpp -o app.js --preload-file assets@/data        # 打包成 .data,运行时挂到 /data
em++ app.cpp -o app.js --embed-file config.json@/config.json   # 直接内嵌(小文件)

挂载之后,C++ 侧就是普通的 POSIX 文件操作:std::ifstream in("/data/config.json") 与原生代码完全一致,无需任何条件编译。

// IDBFS 持久化:写完之后必须显式同步
FS.writeFile('/persist/state.bin', new Uint8Array([1, 2, 3]));
FS.syncfs(false, (err) => { if (err) console.error('sync failed', err); });

注意 --preload-file 生成的 .data 文件必须与 .js、.wasm 一起部署,且服务器要能正确响应 range 请求,否则加载会失败。

六、WASI 与原生运行时

6.1 WASI 目标

WASI(WebAssembly System Interface)把文件、时钟、随机数等能力标准化,让 wasm 可以脱离浏览器运行。用 wasi-sdk 编译:

/opt/wasi-sdk/bin/clang++ -O3 -o hello.wasm hello.cpp   # 默认目标 wasm32-wasi
wasmtime hello.wasm
# add(2, 3) = 5

wasi-sdk 的 clang++ 产物是一个自包含的 wasm,不需要任何 JS 胶水。Emscripten 也能产出类似形态:

em++ hello.cpp -o hello.wasm -O3 -sSTANDALONE_WASM --no-entry
# --no-entry 表示没有 main,作为 reactor 模块

6.2 wasmtime 与 wasmer

wasmtime run --dir=. hello.wasm          # Bytecode Alliance 维护的运行时
wasmtime run --env KEY=VALUE hello.wasm
wasmer run --dir=. hello.wasm            # 支持 LLVM / Cranelift / Singlepass 多后端
node --experimental-wasi-unstable-preview1 run.js   # Node.js 内置 WASI 支持

在 Node 中加载 WASI 模块只需构造 new WASI({ version: 'preview1' }),把 wasi.getImportObject() 传给 WebAssembly.instantiate,最后调用 wasi.start(instance)。

WASI 的成熟度仍在演进:preview1 是当前主流,preview2 引入组件模型(Component Model)与更细粒度的能力授权。选型时若目标只是「在服务端跑一段 C++ 逻辑」,wasi-sdk + wasmtime 是最短路径;若要复用 Emscripten 的文件系统与 embind 生态,则继续用 Emscripten。

七、体积与性能优化

体积直接决定首屏加载时间,是 wasm 项目最需要关注的指标。先用 em++ ... --profiling-funcs 配合 emsize app.js 看清体积构成,再用 Binaryen 的 wasm-opt -Oz --enable-bulk-memory app.wasm -o app.opt.wasm 压一遍(-O3 之外仍有收益),最后 emstrip app.wasm 剥离调试信息。

优化清单:

  • -Oz 加 --closure 1:这是体积优化的默认组合,通常能把 JS 胶水压掉 40% 以上。--closure 1 要求代码不依赖未声明的全局变量,遇到报错时先检查是否有手写的 Module.xxx 全局引用。
  • -sFILESYSTEM=0 与 -sMALLOC=emmalloc:不用文件系统时务必关掉,后者以体积换一点分配性能。
  • 避免 embind:若接口简单,改用 EMSCRIPTEN_KEEPALIVE,能省下几十 KB。
  • 用 Brotli 而非 gzip:wasm 的二进制结构对 Brotli 特别友好,典型压缩率能到 4:1 甚至更高。服务器配置 Content-Encoding: br 与 Content-Type: application/wasm。
  • 拆分模块:把不常用的功能编译成独立的 wasm,按需 WebAssembly.instantiateStreaming 加载。

性能方面,wasm 的执行速度通常在原生代码的 60% 到 90% 之间,差距主要来自三点:SIMD 指令支持不完整(需 -msimd128 且浏览器支持)、间接调用(函数表)比直接调用慢、GC 与异常处理有额外开销。若热点是数值计算,开启 -msimd128 常能带来接近两倍的提升。

八、在浏览器与边缘函数中的部署

浏览器部署只需要静态托管,但有几个硬性要求:

  • MIME 类型必须是 application/wasm(nginx 里 types { application/wasm wasm; } 加 add_header),否则 instantiateStreaming 会退化甚至失败。
  • 压缩:对 .wasm 开启 Brotli 并关闭 gzip。
  • 跨域隔离:使用 SharedArrayBuffer 与多线程需要 Cross-Origin-Opener-Policy: same-origin 与 Cross-Origin-Embedder-Policy: require-corp 两个响应头。
  • 缓存策略:.wasm 文件名带内容哈希,设 Cache-Control: public, max-age=31536000, immutable。

边缘函数是近年的新场景。Cloudflare Workers、Deno Deploy、Fastly Compute 都支持直接运行 wasm 模块,把 C++ 写的解析器、压缩器、图像处理逻辑部署到离用户最近的节点上,既避免了 JS 重写,又绕开了容器的冷启动开销。用 em++ codec.cpp -o codec.wasm -O3 -sSTANDALONE_WASM --no-entry -sEXPORTED_FUNCTIONS=_decode,_encode 就能产出无 JS 胶水的边缘模块。

相关阅读

  • https://plumephp.com/cpp-cross-platform-build-matrix/ — 多目标平台的构建矩阵组织
  • https://plumephp.com/cpp-cmake-project/ — CMake target 模型与工具链文件
  • https://plumephp.com/cpp-performance-optimization/ — 数值热点的通用优化手法

延伸阅读

  • https://plumephp.com/cpp-compilation-linking/ — 编译、链接与目标文件格式的基础
  • https://plumephp.com/cpp-embedded-game-engine-integration/ — 宿主嵌入与语言绑定生成
  • https://plumephp.com/cpp-simd-vectorization-practice/ — SIMD 向量化与 wasm 的 -msimd128

文末完整示例

// 完整可编译示例:同时导出 C 函数与 embind 类的 wasm 模块
// 编译(两个接口一起):
//   em++ wasm_demo.cpp -o wasm_demo.js -O3 -lembind \
//       -sMODULARIZE=1 -sEXPORT_NAME=createDemo \
//       -sEXPORTED_FUNCTIONS=_malloc,_free,_crc32_of \
//       -sEXPORTED_RUNTIME_METHODS=cwrap,HEAPU8

#include <cstdint>
#include <cstddef>
#include <string>

#include <emscripten.h>
#include <emscripten/bind.h>

// ====== 1. C 函数接口:CRC32 ======
static std::uint32_t crc32_impl(const std::uint8_t* data, std::size_t n) {
    std::uint32_t crc = 0xFFFFFFFFu;
    for (std::size_t i = 0; i < n; ++i) {
        crc ^= data[i];
        for (int k = 0; k < 8; ++k)
            crc = (crc & 1u) ? (0xEDB88320u ^ (crc >> 1)) : (crc >> 1);
    }
    return crc ^ 0xFFFFFFFFu;
}

extern "C" {

EMSCRIPTEN_KEEPALIVE
std::uint32_t crc32_of(const std::uint8_t* data, std::size_t n) {
    return crc32_impl(data, n);
}

}  // extern "C"

// ====== 2. embind 接口:文本统计 ======
class TextStats {
public:
    void feed(const std::string& s) {
        chars_ += s.size();
        for (char c : s) if (c == '\n') ++lines_;
    }
    std::size_t chars() const { return chars_; }
    std::size_t lines() const { return lines_; }

private:
    std::size_t chars_ = 0;
    std::size_t lines_ = 0;
};

EMSCRIPTEN_BINDINGS(wasm_demo) {
    emscripten::class_<TextStats>("TextStats")
        .constructor<>()
        .function("feed", &TextStats::feed)
        .function("chars", &TextStats::chars)
        .function("lines", &TextStats::lines);
}
import createDemo from './wasm_demo.js';          // ES Module
const M = await createDemo();

const crc32Of = M.cwrap('crc32_of', 'number', ['number', 'number']);
const bytes = new TextEncoder().encode('123456789');
const ptr = M._malloc(bytes.length);
M.HEAPU8.set(bytes, ptr);                          // HEAPU8 按字节寻址
console.log(crc32Of(ptr, bytes.length).toString(16));   // cbf43926
M._free(ptr);

const stats = new M.TextStats();                   // embind 对象须手动 delete
stats.feed('Hello WebAssembly\nsecond line');
console.log(stats.chars(), stats.lines());   // 29 1
stats.delete();

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer