面向前端或不熟悉 C/C++ 开发人员的 Emscripten WebAssembly 入门介绍。
WebAssembly
MDN 传送门:https://developer.mozilla.org/zh-CN/docs/WebAssembly
WebAssembly 把服务端语言带进了浏览器,在需要大量 CPU 密集运算的场景(如前端加密算法,3D 图形渲染等等)可以弥补 JavaScript 的性能不足,也为 Web 开发生态打开了新的一扇窗。
支持编译到 WebAssembly 的主流编程语言包括但不限于:
- C/C++
- Rust
- Go
- AssemblyScript (阉割版的 TypeScript)
目前主流浏览器已支持 WebAssembly,浏览器外也有独立的 WebAssembly 运行时如 wasmtime / wasmer 等等。
Emscripten
编译工具链
C/C++ 在不同平台有不同的编译工具链:
- Windows: Microsoft Visual C++ (
cl.exe
,link.exe
, …) - Linux: GNU GCC (
gcc
,g++
, …) - macOS: Clang (
clang
,clang++
, …) - Android: NDK (
aarch64-linux-android23-clang
,aarch64-linux-android23-clang++
, …)
这些工具链所做的事情类比前端就相当于是 Webpack / Rollup / Vite + Babel + Terser,把 JS / JSX / TS / TSX / Vue 的模板 / Svelte 的模板转译到 ES5 再打包摇树压缩。
它们可以将 C/C++ 源码编译到不同 CPU 架构操作系统所支持的机器码,链接出来的可执行文件可以被操作系统直接运行,不像 Java / JavaScript 需要有 JVM / V8 那样的虚拟机来解释执行。
但是 WebAssembly 有自己的一套二进制标准,它的可执行文件(.wasm)并不直接由操作系统运行,而是由 WebAssembly 虚拟机来运行,WebAssembly 平台的 C/C++ 编译工具链是 Emscripten。
- WebAssembly: Emscripten (
emcc
,em++
,emar
, …)
安装 Emscripten
官方提供了专门的安装工具 emscripten-core/emsdk,使用 emsdk 需要 Git 和 Python 3 环境,安装过程中请保持良好的网络环境。
除此之外,使用 Emscripten 时也需要 Node.js 环境。
检查环境:
1 | git --version |
非 Windows
1 | ~/Projects$ git clone https://github.com/emscripten-core/emsdk.git |
加环境变量
1 | export EMSDK=~/Projects/emsdk |
Windows
1 | C:\Projects> git clone https://github.com/emscripten-core/emsdk.git |
编辑系统环境变量
1 | set EMSDK=C:\Projects\emsdk |
编译 Hello World
以下是 C 语言的 Hello World 代码,命名为 main.c
1 |
|
编译命令:
1 | emcc -o main.js main.c |
生成了 main.js
和 main.wasm
。
启动本地服务在浏览器中运行:
1 | <script src="main.js"></script> |
可以看到 Console 输出了 Hello World
。
编译过程
C/C++ 源码的编译过程分以下几个步骤:
- 预处理(Preprocessing)
- 编译(Compilation)
- 汇编(Assemble)
- 链接(Linking)
预处理阶段会处理源码中的 #include
、#ifdef
、#define
等等编译器指令,可以近似理解为是代码复制粘贴加替换。
1 | emcc -E -o main.i main.c |
编译阶段会把预处理得到的代码转换成文本格式的汇编代码(.s)。
1 | emcc -S -o main.s main.i |
汇编阶段会由上一步得到的汇编代码生成二进制格式的目标文件(.o)
1 | emcc -c -o main.o main.s |
每个编译单元都会经过这三个步骤生成一个目标文件,如果源文件中不 include 其他源文件,那么一个源文件就是一个编译单元。等价于 emcc -c -o main.o main.c
。这三步类比前端就像是 Webpack 的 DefinePlugin
在编译时就处理了代码中的 process.env.NODE_ENV
,根据配置的值自动删掉了 if else 走不到的分支,然后 Babel 转译 ESNext JS / JSX 源码到 Pure ES5。
链接阶段会把多个目标文件和需要用到的库文件(静态链接库或动态链接库)链接输出最终的可执行文件或动态链接库。不过 Emscripten 没有动态链接库。
1 | emcc -o main.js main.o [xxx.o xxx.o xxx.a] |
类比前端就像是 Webpack 把全部 JS 模块打包成一个 JS 文件,并且经过了 tree shaking 和压缩,把没有用到的代码都去掉了。
emcc -o main.js main.c
这个命令是一次性做完了所有步骤,一步到位生成了可执行文件。
如果要编译多个源文件,一般会用到 Makefile 或 CMake 来配置构建,本质上还是把所有编译单元编译成目标文件,最后再进行链接。
1 | emcc -c -o file1.o file1.c |
更多 emcc 的参数用法在这里查看。
编译目标类型
C/C++ 最终的编译目标可以是可执行文件、静态链接库或动态链接库。
可执行文件
Windows 的可执行文件后缀是 .exe
,Linux / macOS / Android 的可执行文件没有后缀,Emscripten 的可执行文件后缀是 .wasm
。
可执行文件是链接时生成的包含机器码的二进制文件,操作系统的可执行文件可以由操作系统直接运行。
类比前端就像是 <script>
引入的 JS 文件。
静态链接库
Windows 的静态链接库后缀是 .lib
,Linux / macOS / Android / Emscripten 的静态链接库后缀都是 .a
。
静态链接库是由编译后的目标文件打包生成的结果,在链接时传给链接器,链接器会去目标文件里寻找最终可执行文件或动态链接库要用的函数符号。
类比前端就像是 node_modules 里的 package,Webpack 打包时静态解析 import
和 require
,把 node_modules 里包打进了最终的 JS 里。
动态链接库
Windows 的动态链接库后缀是 .dll
,Linux / Android 的动态链接库后缀是 .so
,macOS 的动态链接库后缀是 .dylib
,Emscripten 不存在真正意义上的动态链接库,有也只是把 .wasm
改个后缀名改成了 .so
。
动态链接库和可执行文件类似,都经过链接生成,包含机器码,但是动态链接库不能直接运行,必须在可执行文件运行时动态装入内存再运行。
类比前端就像是 Webpack 动态 import()
分出来的包,可以按需加载。
从 C/C++ 导出函数给 JavaScript
一般来说大部分情况使用 WebAssembly 不会直接写 main 函数,而是暴露原生函数给 JS,在 JS 要调用的时候再去调用原生函数。这里我推荐几种做法。
EMSCRIPTEN_KEEPALIVE
第一种最原始也是效率最好的办法,就是在函数签名上加上 EMSCRIPTEN_KEEPALIVE
,它会告诉编译器这个函数会被用到,不要在“tree shaking”的时候删掉,并且会将函数名加上前缀 _
导出给 JS,就和编译器参数 -sEXPORTED_FUNCTIONS
一样。使用 C++ 时还需要加上 extern "C"
告诉 C++ 编译器不要修改函数名,保留 C 语言的函数名。
1 | // lib.c |
1 | emcc -o lib.js lib.c |
1 | <script src="lib.js"></script> |
用这种方法导出的函数参数只能是数字类型或裸指针(指针也是数字),返回值也只能是数字类型或 void
,在 JS 传的参数只能是 number
类型,返回值也只能是 number
或 undefined
。
JS 传字符串给 C
字符串在 C 中实际上是一个以 0 结尾的字符数组,内存是连续的。如果要把 JS 的 string
传到 C,首先要开辟一块 C 的内存,再把 JS 的字符串放进这段内存里,C 中就可以访问到了。
1 |
|
1 | # 导出 C 的 malloc 和 free 在 JS 中分配和释放 C 的内存 |
1 | <script src="lib.js"></script> |
C 传字符串给 JS
同理,传字符串指针,往 JS 分配的内存中写入字符串内容。
1 |
|
1 | emcc -sEXPORTED_FUNCTIONS=["_malloc","_free"] -o lib.js lib.c |
1 | <script src="lib.js"></script> |
原则上内存是谁分配的就由谁来释放。
还有一种做法可以直接从 C 函数返回字符串的首地址指针,不返回字符串长度,然后在 JS 中用循环拼接字符串,遇到 0 时跳出循环。
1 |
|
1 | emcc -o lib.js lib.c |
1 | <script src="lib.js"></script> |
embind
第二种办法是使用 Emscripten 官方提供的 Embind 来绑定 C++ 的函数和类到 JavaScript 对象,写起来更自然,类似 Node.js 的 NAPI,没有了传参类型的限制。
使用这个特性时必须用 C++ 语言。
Emscripten 3.1.3 之前要加链接器选项 --bind
。从 3.1.3 版本开始此选项被废弃,改用 -lembind
。
1 |
|
1 | # -sDISABLE_EXCEPTION_CATCHING=0 启用 C++ 异常 |
1 | <script src="lib.js"></script> |
类型映射
上面 Add
函数用到的类型 int
,Embind 可以自动映射成 JS 的 number
类型,用 TypeScript 声明来描述的话相当于:
1 | export declare function add (a: number, b: number): number |
也就是说 JS 调用的时候可以传 number
类型进来,如果传别的类型就会抛错。
下表是 Embind 支持的类型映射:
C++ 类型 | JavaScript 类型 |
---|---|
void |
undefined |
bool |
boolean |
char |
number |
signed char |
number |
unsigned char |
number |
short |
number |
unsigned short |
number |
int |
number |
unsigned int |
number |
long |
number |
unsigned long |
number |
float |
number |
double |
number |
std::string |
string | ArrayBuffer | Uint8Array | Uint8ClampedArray | Int8Array |
std::wstring |
string (utf-16) |
emscripten::val |
any |
值得注意的是 emscripten::val
这个类,定义在 <emscripten/val.h>
里面,它可以映射成任意 JS 类型,相当于是 NAPI 的 Napi::Value
,可以用它来直接操作 JS 对象。
比如这样用:
1 |
|
等价于:
1 | function stringify (jsobj: any): string { |
Node-API Emscripten 实现
第三种方法是使用 Node.js 原生扩展的 API,官方没有提供,我自己实现了一套 emnapi,方便一套代码同时编译到 WebAssembly 和 Node 原生扩展。具体写法请参照代码仓库的 README 和 Node.js 官方文档。
从 C/C++ 调用 JavaScript 函数
使用 embind 或 Node-API 很容易做到,这里不做介绍。重点介绍 Emscripten 的 JS Library 写法。
写一个 JavaScript 文件
library_add.js
1
2
3
4
5mergeInto(LibraryManager.library, {
add: function (a, b) {
return a + b;
}
});这个文件是 Emscripten 编译时去运行的,只有函数体的内容会被内联进最终的运行时 JS,生成的内容:
1
2
3
4function _add (a, b) {
return a + b;
}
// _add 会被加入传入 WebAssembly 初始化对象中在 C/C++ 中只声明函数,不写定义(函数体)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// main.c
EXTERN_C int add(int a, int b);
int main() {
printf("%d\n", add(3, 4));
return 0;
}编译命令
1
2# --js-library 可以重复多个,链接时要链接的 JavaScript library
emcc --js-library=library_add.js -o main.js main.cHTML
1
<script src="main.js"></script>