Chapter 04

C/C++ 编译:Emscripten 工具链

使用 Emscripten SDK 将 C/C++ 代码编译为 Wasm,掌握 ccall/cwrap 等绑定方式

Emscripten:C/C++ 到 WebAssembly

Emscripten 是什么?

Emscripten 是一个完整的 C/C++ 到 WebAssembly 编译工具链,由 Alon Zakai 于 2010 年创建(最初编译到 asm.js)。它不仅包含编译器前端 emcc,还模拟了大量的 POSIX API(文件系统、网络、线程等),让现有的 C/C++ 库几乎可以不修改地编译到 Wasm。

安装 Emscripten SDK

# 安装 emsdk
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk

# 安装并激活最新版本
./emsdk install latest
./emsdk activate latest

# 配置环境变量(每次新终端需要执行)
source ./emsdk_env.sh

# 验证安装
emcc --version  # emcc (Emscripten gcc/clang-like replacement) 3.x.x

编译 C 代码

// hello.c
#include <stdio.h>
#include <math.h>

int add(int a, int b) {
    return a + b;
}

double sqrt_approx(double x) {
    return sqrt(x);
}

// EMSCRIPTEN_KEEPALIVE 防止函数被 DCE(死代码消除)
int process_array(int* arr, int len) {
    int sum = 0;
    for (int i = 0; i < len; i++) sum += arr[i];
    return sum;
}
# 编译为 Wasm(生成 hello.js + hello.wasm)
emcc hello.c -o hello.js   -s WASM=1   -s EXPORTED_FUNCTIONS='["_add","_sqrt_approx","_process_array"]'   -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'   -O3

# 编译为纯 ES 模块(不生成 HTML)
emcc hello.c -o hello.mjs   -s WASM=1   -s MODULARIZE=1   -s EXPORT_ES6=1   -O3

ccall 和 cwrap:调用 C 函数

// 在 HTML 中加载并使用
const Module = await import('./hello.mjs');
await Module.ready;

// ccall:直接调用 C 函数
// ccall(函数名, 返回类型, 参数类型数组, 参数值数组)
const result = Module.ccall('add', 'number', ['number', 'number'], [3, 4]);
console.log(result);  // 7

// cwrap:创建可重复调用的 JS 函数包装器
const add = Module.cwrap('add', 'number', ['number', 'number']);
console.log(add(10, 20));  // 30

// 处理数组:通过 Emscripten 的堆内存
function processArray(arr) {
  const bytes = arr.length * Int32Array.BYTES_PER_ELEMENT;
  const ptr = Module._malloc(bytes);
  Module.HEAP32.set(arr, ptr / Int32Array.BYTES_PER_ELEMENT);
  const sum = Module.ccall('process_array', 'number',
    ['number', 'number'], [ptr, arr.length]);
  Module._free(ptr);
  return sum;
}
console.log(processArray([1, 2, 3, 4, 5]));  // 15

EM_JS 和 EM_ASM:内联 JavaScript

#include <emscripten.h>

// EM_JS:在 C 中定义一个 JavaScript 函数
EM_JS(void, log_to_console, (const char* str), {
    console.log(UTF8ToString(str));
});

// EM_ASM:直接内联 JavaScript 代码片段
void show_progress(int percent) {
    EM_ASM({
        document.getElementById('progress').style.width = $0 + '%';
    }, percent);
}

Emscripten 的架构原理

Emscripten 如何将 C/C++ 带进 Web

Emscripten 不只是一个「C→Wasm 编译器」,它是一个完整的仿真环境:

Clang/LLVM 前端
Emscripten 使用修改版的 Clang 作为 C/C++ 前端,将源码编译为 LLVM IR,再由后端编译为 Wasm 字节码。支持 C17 和 C++20 标准。
系统库仿真
Emscripten 实现了大量 POSIX/libc 函数(fopen/fread/socket 等),底层转为 JavaScript API(Fetch、IndexedDB、WebSocket)。现有的 C 代码几乎不需要修改就能编译。
虚拟文件系统(MEMFS/NODEFS)
在 Wasm 线性内存中实现了完整的 POSIX 文件系统(MEMFS)。程序的文件操作会在内存中进行。还有 NODEFS(映射到 Node.js 文件系统)和 IDBFS(映射到 IndexedDB)。
主循环处理(emscripten_set_main_loop)
浏览器不允许阻塞主线程,但 C 程序通常有 while(true) 主循环。Emscripten 提供 emscripten_set_main_loop() 来将主循环转换为 requestAnimationFrame 回调。

Embind:C++ 类的高级绑定

相比 ccall/cwrap 的优势

ccall/cwrap 只能调用 C 风格函数(基本类型参数)。对于 C++ 类、模板、枚举等,应使用 Embind 来生成更自然的 JavaScript 绑定。

// matrix.cpp — 使用 Embind 绑定 C++ 类
#include <emscripten/bind.h>
#include <vector>
#include <stdexcept>

class Matrix {
public:
    Matrix(int rows, int cols)
        : rows_(rows), cols_(cols), data_(rows * cols, 0.0) {}

    void set(int row, int col, double val) {
        if (row >= rows_ || col >= cols_) throw std::out_of_range("Index out of bounds");
        data_[row * cols_ + col] = val;
    }

    double get(int row, int col) const {
        if (row >= rows_ || col >= cols_) throw std::out_of_range("Index out of bounds");
        return data_[row * cols_ + col];
    }

    Matrix multiply(const Matrix& other) const;

    int rows() const { return rows_; }
    int cols() const { return cols_; }

private:
    int rows_, cols_;
    std::vector<double> data_;
};

// Embind 绑定声明
EMSCRIPTEN_BINDINGS(matrix_module) {
    emscripten::class_<Matrix>("Matrix")
        .constructor<int, int>()           // 导出构造函数
        .function("set", &Matrix::set)      // 导出成员函数
        .function("get", &Matrix::get)
        .function("multiply", &Matrix::multiply)
        .property("rows", &Matrix::rows)   // 导出只读属性
        .property("cols", &Matrix::cols);
}
# 使用 Embind 编译(--bind 标志)
emcc matrix.cpp -o matrix.js \
  -s WASM=1 \
  --bind \
  -O2
// JavaScript 使用 Embind 绑定的类
const Module = await import('./matrix.js');
await Module.ready;

// 像使用普通 JS 类一样!
const a = new Module.Matrix(2, 2);  // 调用 C++ 构造函数
a.set(0, 0, 1);  a.set(0, 1, 2);
a.set(1, 0, 3);  a.set(1, 1, 4);

console.log(a.rows);   // 2(属性访问)
console.log(a.get(0, 1));  // 2

// ❗ 重要:C++ 对象需要手动释放!
a.delete();  // 调用 C++ 析构函数,释放内存
Embind 对象必须手动 delete() — 最常见的内存泄漏

JavaScript 的垃圾回收器(GC)只管理 JS 对象的内存,不会自动调用 C++ 析构函数。用 Embind 创建的 C++ 对象(如 new Module.Matrix(2,2))在 JS 侧只是一个轻量包装,内部的 C++ 内存只有在显式调用 .delete() 时才会释放。在函数或循环中大量创建 Embind 对象而不 delete,会造成严重的内存泄漏,最终导致浏览器 OOM(内存不足)崩溃。建议使用 try-finally 或类似 defer 的包装函数确保 delete 被调用。

Emscripten 文件系统操作

// 使用 Emscripten 预加载文件
#include <stdio.h>
#include <emscripten.h>

int process_file(const char* path) {
    FILE* f = fopen(path, "rb");  // 在 MEMFS 中打开文件
    if (!f) return -1;
    char buf[256];
    fread(buf, 1, sizeof(buf), f);
    fclose(f);
    // 处理 buf...
    return 0;
}
# 将文件预嵌入 Wasm 模块(打包进 .js 中的 base64)
emcc processor.c -o processor.js \
  --preload-file data/model.bin \   # 单个文件
  --preload-file assets/           # 整个目录
  -s WASM=1

# 编译时嵌入(embed-file,编译进模块,无需 .data 文件)
emcc processor.c -o processor.js \
  --embed-file data/model.bin \
  -s WASM=1
EXPORTED_FUNCTIONS 必须包含 malloc/free

如果你的 C 代码需要传递数组或字符串(需要 _malloc/_free),必须在 EXPORTED_FUNCTIONS 中显式列出它们:-s EXPORTED_FUNCTIONS='["_malloc","_free","_my_func"]'。否则 Emscripten 的 DCE(死代码消除)会将它们从输出中删除,导致运行时 "not a function" 错误。函数名前需要加 _ 前缀(C 符号的 Emscripten 约定)。

常用编译标志参考

-s WASM=1
启用 WebAssembly 输出(vs asm.js)。这是必须的标志,现代版本默认已开启。
-s MODULARIZE=1 + -s EXPORT_ES6=1
生成 ES6 模块工厂函数,而非全局 Module 对象。适合与 Vite/Webpack 集成的现代开发。
-s ALLOW_MEMORY_GROWTH=1
允许 Wasm 内存动态增长。默认内存大小固定(512KB 或通过 INITIAL_MEMORY 设置),启用后内存可按需增长,但会稍微降低性能。
-s ASSERTIONS=1
开启运行时断言检查(未定义行为、内存越界等)。调试时必用,发布时应关闭以减小体积。
-s MINIMAL_RUNTIME=1
生成最小化的运行时胶水代码(不包含文件系统、网络等)。适合纯计算库,可将 .js 胶水文件从 100KB+ 减小到 2-5KB。
-s PTHREAD_POOL_SIZE=4
配置 pthreads 线程池大小(Emscripten 多线程支持,基于 Web Workers)。使用多线程需要设置 COOP/COEP 响应头。

本章小结

本章核心要点