最近出于业务需求需要, 于是把一个 C++ 库编译为 Webassembly npm 包。主要的考虑是相比于使用 Node.js binding 版本无需在本机进行编译, 这样 Windows 开发的同学与现有的 CI/CD 镜像也不用去搭建编译环境
下载 Emscripten
Emscripten 是将 C/C++ 语言编译为 WebAssembly 的工具, 其他语言参考 Compile a WebAssembly module from…
下面是 macOS 中下载 Emscripten SDK 的步骤, 其他平台参考 Download and install
1 | # Get the emsdk repo |
开始编译
这里我们以 C++ 库 CppJieba 为例, CppJieba 的 README 中的编译的步骤是
1 | mkdir build |
那么我们使用 emsdk 编译为 Webassembly 的步骤就是
1 | mkdir build |
其实不难发现 emsdk 能够完美兼容现有 C/C++ 库的工具链, cmake 替换为 emcmake cmake, make 替换为 emmake make 即可。如果是 gcc 也是可以直接替换为 emcc, 更多见: Building Projects
编译结束后就会看到 build 目录生成了如下文件
1 | cppjieba |
关于编译后的文件
- js 文件(JavaScript 文件): .js 文件是一个 JavaScript 模块, 它负责加载和实例化 WebAssembly 模块,并提供与 JavaScript 代码的交互接口。它通常包含以下功能:
- 加载和解析 .wasm 文件。
- 创建 WebAssembly 实例。
- 导出 WebAssembly 模块的函数和内存。
- 提供与 JavaScript 代码的交互接口,例如将 JavaScript 对象传递给 WebAssembly 模块,或从 WebAssembly 模块返回结果给 JavaScript
- .wasm 文件(WebAssembly 二进制文件): .wasm 文件是编译后的二进制文件,它包含了实际的 WebAssembly 机器码和数据。.wasm 文件是由编译器将 C/C++ 代码编译成的可执行文件,其中包含了函数、数据段、内存分配等信息。.wasm 文件可以在 WebAssembly 虚拟机中运行,独立于 JavaScript 环境
测试运行
此时我们通过 node test.js 来测试下刚才编译后的产物
1 | // test.js |
结果发现运行报错了
1 | 2023-09-01 21:47:44 /Users/github/cppjieba/include/cppjieba/DictTrie.hpp:215 |
然后定位 CppJieba 的代码是打开一个本地文件失败了, 具体原因是什么没有打印出来, 是无权限还是什么其他原因?
1 | void LoadDict(const string& filePath) { |
于是加了如下代码来查看失败的原因
1 | void LoadDict(const string& filePath) { |
最后打印的错误原因竟然是文件不存在, 人工检查后发现我们本机是有这个文件
1 | OpenError: No such file or directory |
文件系统
为什么本机的文件在 wasm 中运行时说找不到了, 让我们先认真看完文档 File systems
如上是 wasm 的文件系统的架构图, wasm 默认使用的 MEMFS, 类似于 Node.js 中的 memfs。
原因是 wasm 为了可移植性, 比如在浏览器中刷新一次页面或者在 Node.js 程序运行停止后利于释放资源, 默认会把资源数据存在内存中。这样能够像启动 docker 一样每次启动做到无状态与隔离
如果你的程序确实需要访问文件资源可以像 docker volumes 一样在启动时挂载到运行时中
1 | docker run -t -i \ |
比如上面 docker 的实现在 wasm 中就是新增 –preload-file 参数达到同样的效果。如果你仔细观察会发现 wasm 的实现是把挂载目录的文件给序列化到了本机的 build/demo.data 文件中
1 | --preload-file /Users/github/cppjieba/dic |
上面我们说了 wasm 为了可移植性默认使用了 MEMFS, 那如果我的 wasm 代码只需要运行在 Node.js 环境有其他可行办法没有了?
如果不想通过挂载的方式, 那么可以使用 -s NODERAWFS=1 参数来使用 NODERAWFS 文件系统就可以直接访问本机, 那么此时你的 wasm 将只能运行在 Node.js 环境。
1 | -s NODERAWFS=1 |
同样如果你的 wasm 只需运行在浏览器环境你可以使用 IDBFS, 这样数据都会保存在 IndexedDB 中。更多文件系统见: File System API
到这里我的一点感悟是如果运行 wasm 遇见了一些问题, 不妨先把 wasm 当作一个轻量级的 docker, 也许一下就能想明白为什么这里是这样设计了
导出函数
大多数场景我们需要简单封装一下函数然后给 js 去调用, 比如下面的 myFunction 函数, 需要在函数声明前面加入两个宏
1 |
|
EXTERN
EXTERN 宏的作用是防止 name mangling 类似于 js 的打包中告诉代码丑化插件如 terser 不要去压缩我们的函数名
1 | function myFunction() {} |
试想上面的代码被丑化为如下后, 显然在一些场景下会造成问题。所以通常会配置 terserOptions.keep_fnames 为 true
1 | function f1() {} |
接下来也可以看看使用了 EXTERN 宏与没有使用的区别
1 | // main.cpp |
编译后查看一下符号链接, 可以发现被 extern 修饰的 ef 与 eg 都保留了原名, 其他如 f、g、h 函数名都变了
1 | 8: 0000000000000000 7 FUNC GLOBAL DEFAULT 1 _Z1fv |
EMSCRIPTEN_KEEPALIVE
1 |
EMSCRIPTEN_KEEPALIVE 宏的作用是不要使用 dead code 优化, 虽然 myFunction 它暂时未被用到, 类似于 webpack 中的 Tree Shaking
比如 webpack 打包时, 可以通过 PURE 关键字进行声明表示 withAppProvider 函数调用没有副作用, 如果 Button 没有被使用到, 那么 withAppProvider 等也可以放心删除
1 | var Button$1 = /*#__PURE__*/ withAppProvider()(Button); |
其他参数
因为 CppJieba 是通过 cmake 进行编译, 所以我们在 CMakeLists.txt 文件中补充了 wasm 编译所需的参数如 -s NODERAWFS=1, 其他参数比如
- INITIAL_MEMORY: 设置 wasm 运行时所需的内存, 默认为 16MB, 这里我们可以设置为 160MB
- NO_EXIT_RUNTIME: 确保当 main() 结束时我们的程序不需要立马退出
- EXPORTED_RUNTIME_METHODS: 告诉编译器我们要使用运行时函数 ccall
1
2
3if (${CMAKE_SYSTEM_NAME} MATCHES "Emscripten")
set_target_properties(demo PROPERTIES LINK_FLAGS "-s INITIAL_MEMORY=167772160 -s NO_EXIT_RUNTIME=1 -s \"EXPORTED_RUNTIME_METHODS=['ccall']\" -s NODERAWFS=1")
endif ()
完善 test.js
最后我们还需在 onRuntimeInitialized 的 callback 中调用 myFunction, Module.onRuntimeInitialized 是 wasm 运行时初始化完成的勾子
1 | // test.js |
发布 npm
现在我们终于完成了编译与运行流程, 接下来就可以在 demo.cpp 中类似于 myFunction 一样封装一下 CppJieba 相关的函数, 最后编译完成就可以把产物发布到 npm 上去了
1 | node test.js |