Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

编译一个 C/C++ 模块为 WebAssembly #64

Open
xiaoxiaojx opened this issue Sep 1, 2023 · 0 comments
Open

编译一个 C/C++ 模块为 WebAssembly #64

xiaoxiaojx opened this issue Sep 1, 2023 · 0 comments
Labels
C++ C++

Comments

@xiaoxiaojx
Copy link
Owner

image

最近出于业务需求需要, 于是把一个 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

# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
cd emsdk

# Fetch the latest version of the emsdk (not needed the first time you clone)
git pull

# Download and install the latest SDK tools.
./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
source ./emsdk_env.sh

开始编译

这里我们以 C++ 库 CppJieba 为例, CppJieba 的 README 中的编译的步骤是

mkdir build
cd build
cmake ..
make

那么我们使用 emsdk 编译为 Webassembly 的步骤就是

mkdir build
cd build
emcmake cmake ..
emmake make

其实不难发现 emsdk 能够完美兼容现有 C/C++ 库的工具链, cmake 替换为 emcmake cmake, make 替换为 emmake make 即可。如果是 gcc 也是可以直接替换为 emcc, 更多见: Building Projects

编译结束后就会看到 build 目录生成了如下文件

cppjieba
├── test.js
├── build
│   ├── demo.js
│   ├── demo.wasm

关于编译后的文件

  • 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 来测试下刚才编译后的产物

// test.js

var Module = require("./build/demo.js");

结果发现运行报错了

2023-09-01 21:47:44 /Users/github/cppjieba/include/cppjieba/DictTrie.hpp:215
FATAL exp: [ifs.is_open()] false. open /Users/github/cppjieba/dict/jieba.dict.utf8 failed.
Aborted(native code called abort())

然后定位 CppJieba 的代码是打开一个本地文件失败了, 具体原因是什么没有打印出来, 是无权限还是什么其他原因?

void LoadDict(const string& filePath) {
    ifstream ifs(filePath.c_str());
    XCHECK(ifs.is_open()) << "open " << filePath << " failed.";
}

于是加了如下代码来查看失败的原因

void LoadDict(const string& filePath) {
    ifstream ifs(filePath.c_str());
+    if (!ifs.is_open()) {
+      std::cerr << "OpenError: " << strerror(errno);
+    }
    XCHECK(ifs.is_open()) << "open " << filePath << " failed.";
}

最后打印的错误原因竟然是文件不存在, 人工检查后发现我们本机是有这个文件

OpenError: No such file or directory

文件系统

为什么本机的文件在 wasm 中运行时说找不到了, 让我们先认真看完文档 File systems

image

如上是 wasm 的文件系统的架构图, wasm 默认使用的 MEMFS, 类似于 Node.js 中的 memfs

原因是 wasm 为了可移植性, 比如在浏览器中刷新一次页面或者在 Node.js 程序运行停止后利于释放资源, 默认会把资源数据存在内存中。这样能够像启动 docker 一样每次启动做到无状态与隔离

如果你的程序确实需要访问文件资源可以像 docker volumes 一样在启动时挂载到运行时中

docker run -t -i \
-v /Users/github/cppjieba/dict:/Users/github/cppjieba/dict \
xxx /bin/bash

比如上面 docker 的实现在 wasm 中就是新增 --preload-file 参数达到同样的效果。如果你仔细观察会发现 wasm 的实现是把挂载目录的文件给序列化到了本机的 build/demo.data 文件中

--preload-file /Users/github/cppjieba/dic

上面我们说了 wasm 为了可移植性默认使用了 MEMFS, 那如果我的 wasm 代码只需要运行在 Node.js 环境有其他可行办法没有了?

如果不想通过挂载的方式, 那么可以使用 -s NODERAWFS=1 参数来使用 NODERAWFS 文件系统就可以直接访问本机, 那么此时你的 wasm 将只能运行在 Node.js 环境。

-s NODERAWFS=1

同样如果你的 wasm 只需运行在浏览器环境你可以使用 IDBFS, 这样数据都会保存在 IndexedDB 中。更多文件系统见: File System API

image

到这里我的一点感悟是如果运行 wasm 遇见了一些问题, 不妨先把 wasm 当作一个轻量级的 docker, 也许一下就能想明白为什么这里是这样设计了

导出函数

大多数场景我们需要简单封装一下函数然后给 js 去调用, 比如下面的 myFunction 函数, 需要在函数声明前面加入两个宏

#include <emscripten/emscripten.h>

int main(int argc, char** argv) {
  // ...
}

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE void myFunction(int argc, char ** argv) {
    printf("MyFunction Called\n");
}

EXTERN

EXTERN 宏的作用是防止 name mangling 类似于 js 的打包中告诉代码丑化插件如 terser 不要去压缩我们的函数名

function myFunction() {}
console.log(myFunction.name)
// 'myFunction'

试想上面的代码被丑化为如下后, 显然在一些场景下会造成问题。所以通常会配置 terserOptions.keep_fnames 为 true

function f1() {}
console.log(f1.name)
// 'f1'

接下来也可以看看使用了 EXTERN 宏与没有使用的区别

// main.cpp

void f() {}
void g();

extern "C" {
    void ef() {}
    void eg();
}

void h() { g(); eg(); }

编译后查看一下符号链接, 可以发现被 extern 修饰的 ef 与 eg 都保留了原名, 其他如 f、g、h 函数名都变了

     8: 0000000000000000     7 FUNC    GLOBAL DEFAULT    1 _Z1fv
     9: 0000000000000007     7 FUNC    GLOBAL DEFAULT    1 ef
    10: 000000000000000e    17 FUNC    GLOBAL DEFAULT    1 _Z1hv
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _Z1gv
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND eg

EMSCRIPTEN_KEEPALIVE

#define EMSCRIPTEN_KEEPALIVE __attribute__((used))

EMSCRIPTEN_KEEPALIVE 宏的作用是不要使用 dead code 优化, 虽然 myFunction 它暂时未被用到, 类似于 webpack 中的 Tree Shaking

比如 webpack 打包时, 可以通过 PURE 关键字进行声明表示 withAppProvider 函数调用没有副作用, 如果 Button 没有被使用到, 那么 withAppProvider 等也可以放心删除

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
if (${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 运行时初始化完成的勾子

// test.js

var Module = require("./build/demo.js");
var result = Module.onRuntimeInitialized = () => {
    Module.ccall('myFunction', // name of C function 
        null, // return type
        null, // argument types
        null // arguments
   );
}

发布 npm

现在我们终于完成了编译与运行流程, 接下来就可以在 demo.cpp 中类似于 myFunction 一样封装一下 CppJieba 相关的函数, 最后编译完成就可以把产物发布到 npm 上去了

node test.js 

// MyFunction Called
@xiaoxiaojx xiaoxiaojx added the C++ C++ label Sep 1, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C++ C++
Projects
None yet
Development

No branches or pull requests

1 participant