因爲業務須要,咱們須要將已有的 c++ 代碼編譯爲 webassembly( wasm ),本文記錄下編譯過程當中碰到的一些問題和解決方式。 能編譯成 wasm 的語言有不少種,官網列舉了一些目前支持編譯到 wasm 的語言列表html
其中 C/C++ 和 Rust 和 C# 是比較成熟的,其工具鏈比較成熟,rust 轉 wasm 的實踐可參考 zhuanlan.zhihu.com/p/38661879, 因爲咱們現有的項目是 c++,所以本文專一於 C++ 編譯爲 wasm 的實踐。前端
本項目是一個相似 RN|Weex 的一個跨端項目,其上層 DSL 爲小程序,經過編譯工具 (node cli) 將小程序編寫的代碼編譯 (encode) 爲一段 binary 代碼(其包含了首屏的 vdom 和 style 信息以及業務的 js 代碼),並將其動態下發給客戶端,客戶端將下發的 binary 代碼進行 decode,並進行 native 渲染,並與業務的 js 代碼進行動態綁定。node
因爲 encode 和 decode 存在大量的代碼複用(內嵌了一個 mini 的 js 引擎實現),因此 encode 和 decode 的代碼均經過 c++ 編寫,客戶端(ios|android)SDK 源碼依賴 c++ 代碼,而 node cli 則是須要先將 C++ 代碼編譯爲動態庫(linux 爲 so,mac 下爲 dylib,windows 下爲 dll),而後 node 層經過 ffi(github.com/node-ffi/no…) 進行跨語言調用動態庫代碼。 雖然經過 ffi 咱們可以成功的實如今 node 層調用 c++ 的代碼,這仍然面臨着一些問題linux
早期使用的 node-ffi 存在 node 的版本限制,不支持 node12 以上的版本(後來切換到 ref-napi 解決兼容性問題)android
c++ 代碼的 crash 會致使 node 進程奔潰,影響 node 在服務端側的使用和穩定性webpack
多平臺的發佈問題,若是咱們想本身發佈的 node cli 可以在多個平臺正常運行基本上有兩種方式ios
經過 node-gyp 交給用戶側完成 so 的編譯過程,可是因爲 c++ 代碼裏對標準庫和語言版本都有一些要求,在用戶側編譯對用戶的環境有必定的要求c++
發佈 cli 的時候,完成各個平臺的動態庫編譯,這就要求每次發佈 cli 的時候都要如今三個平臺完成動態庫的編譯,這實際上要求咱們在三個系統上搭建好本身的 gitlab-runner,然而公司的內部的 gitlab-runner 默認只支持 linux,這就要求咱們本身搭建好一套成熟的 gitlab 多平臺的 CICD 流程,這並不簡單,並且這也難以解決開發者本身在本地發版的需求git
動態庫雖然能完美的支持 node、android 和 ios,可是在 web 端卻沒法去加載執行動態庫,這阻止了咱們將編譯流程遷移到 web 的嘗試。github
雖然咱們發佈了動態庫,使得用戶無需本身本地編譯動態庫,動態庫的調用仍然依賴於 ref-napi 這個庫去完成 c++ 到 js 的 binding,該庫須要在用戶本地進行編譯(依賴了 node-gyp 進而依賴了 xcode), 而 wasm 不依賴 xcode 等 c++ 環境,避免了用戶對 c++ 編譯環境的依賴。
出於上述的一些限制,咱們嘗試將 c++ 代碼編譯爲 wasm,wasm 除了其出色的執行性能,其還具備出色的跨平臺特性,完美的契合了咱們的需求。
wasm 也可同時運行在 web,使得咱們後期能夠探索 web 上的編譯方案。
wasm 的運行在一個沙盒環境中,並不會由於其執行異常致使進程奔潰。
所以咱們嘗試將該 c++ 模塊變異的動態庫遷移到 wasm。
考慮以下的簡單的 c 程序
// hello.c
#include
int main(){
printf("hello world\n");
return 0;
}
複製代碼
編譯爲可執行文件並執行
$ clang hello.c -o hello
$ ./hello
複製代碼
很不幸上述編譯的代碼只能運行在一樣的 os 且一樣的 cpu 指令集上。在 32 位 linux 編譯出來的結果,沒法運行在 64 位 linux 上,更沒法運行在 mac 和 window 上。
咱們將其編譯爲 wasm 碰到的第一個問題就是如何處理系統調用,實際上述編譯結果難以跨平臺的一大緣由就在於不一樣操做系統的系統調用實現是不一樣的,咱們必需要爲不一樣的操做系統生成不一樣的代碼來適配不一樣的系統調用實現。 這時候一個天然的處理方式就是將上述的系統調用結果編譯到一個已經支持跨平臺的 runtime 的系統調用上。幸運的是已經存在了多種上述的 runtime
browser
nodejs
wasi
以瀏覽器爲例,瀏覽器裏 js 的 console.log 是一個自然的跨平臺的系統調用,其能夠平穩的運行在不一樣的操做系統上。 所以咱們只須要將上述 c++ 代碼編譯爲 wasm+ js glue 代碼便可,js glue 代碼負責將系統調用適配到瀏覽器提供的 js api 上。其流程以下圖所示
對於 nodejs 其處理方式和 browser 相似, 只是這時候 js glue code 適配的並不是瀏覽器提供的 api,而是 node 提供的 api。 咱們能夠看下 emscripten 如何將上述代碼編譯上述結果
$ emcc hello.c -o hello.js $ node hello.js hello world
複製代碼
瀏覽器中也能夠正常執行
上述的使用方式都有一個缺點,由於生成的 wasm 依賴了 js gule 代碼注入的 api,致使其依賴了 js glue 代碼才能執行對應的 wasm。 這致使了若是其餘的第三方環境若是想要脫離 js gule 代碼使用生成的 wasm,則須要模擬 js glue code 給 wasm 注入的 api,然而 js glue 代碼注入的 api 並不是標準,也常常發生變化,這實際上致使生成的 wasm 很難在其餘的環境下平穩運行。
爲了解決上述問題,wasm 制定了標準的 api 接口 (WASI),這時候 wasm 並不須要依賴 js glue 代碼才能正常運行,任何實現了 WASI 的接口的 runtime 都可以正常加載該 wasm。 其實 wasm 本質上和 js 是無關的,其能夠徹底運行在獨立的沙箱環境裏,經過 WASI 和系統 API 進行交互,這實際上促使了 wasm runtime 的發展,此時已經並不侷限在能夠將多種語言編譯爲 wasm,更進一步的咱們能夠用各類語言實現 wasm 的 runtime,wasm 此時能夠運行在除了 browser 和 node 以外的其餘 runtime 裏,甚至能夠被內嵌入移動端的 sdk 裏。目前已經支持的 wasi 的 runtime 包括
wasmtime, Mozilla’s WebAssembly runtime
Lucet, Fastly’s WebAssembly runtime
node@14 在開啓 --experimental-wasi-unstable-preview1 的狀況下
emcc 目前已經支持了生成 wasi 格式的代碼,咱們此次將上述的 hello-world 代碼編譯爲支持 wasi
$ emcc hello.c -o hello.js -s STANDALONE_WASM
複製代碼
此時生成的 wasm 並不依賴了生成的 js glue code,咱們使用任何支持 wasi 的 runtime 均可以執行生成的 wasm。 咱們使用 wasmtime 執行上述代碼
咱們也能夠經過 node 的 wasi 功能,執行上述代碼
const fs = require('fs');
const { WASI } = require('wasi');
const wasi = new WASI({ args: process.argv, env: process.env, });
const importObject = { wasi_snapshot_preview1: wasi.wasiImport };
(async () => {
const wasm = await WebAssembly.compile(fs.readFileSync('./hello.wasm'));
const instance = await WebAssembly.instantiate(wasm, importObject);
wasi.start(instance);
})();
複製代碼
執行結果以下
咱們發現上述代碼並不須要處理任何系統調用的綁定,這一切都得益於 wasi 的支持。
若是咱們的代碼並非以 STANDALONE\_WASM
模式下編譯的,咱們使用 wasi 的 runtime 執行,實際上會報錯
由於此時生成的 wasm 會依賴 js gulu 代碼注入的 api。
一個 c++ 到 wasm 的編譯流程基本上以下圖所示,是 c++ -> llvm bitcode -> wasm + js(glue) | standalone wasm
對於簡單的 c++ 項目,咱們能夠直接調用 emcc 將 c++ 編譯爲 wasm,可是對於大型項目,都是使用 cmake 等構建工具進行構建的。 幸運的是 emscripten 很好的和 cmake 進行了集成,咱們只須要進行以下替換
$ cmake => 替換爲 emcmake cmake
$ make => 替換爲 emmake make 便可
複製代碼
按照以前的 cmake 方式進行項目的構建。 此時 cmake 編譯的產物是 llvm bit code , 咱們能夠接下來經過 emcc 將 llvm bit code 進一步編譯爲 wasm, 一個完整的編譯步驟以下
cd build && emcmake ..
emmake make // 生成lib.a 的llvm bitcode
emcc lib.a -o lib.js // 生成 lib.wasm和lib.js
複製代碼
下面說一下編譯中須要處理的細節問題。
c++ 爲了支持函數重載,默認會對函數的名稱進行 mangle(即便沒有重載) 與傳統的將 c++ 編譯成動態庫,而後 js 經過 ffi 調用動態庫導出的函數相似,emscripten 裏若是須要在 JS 裏使用 C++ 導出的函數,一樣須要將 C++ 的函數進行導出。 c++ 爲了支持重載函數,默認會對函數的名稱進行 mangle 處理,這致使咱們編寫的函數和實際動態庫導出的函數名不一致 以下代碼爲例
#include <stdio.h>
int myadd(int a,int b){
int res = a+b;
res = res + 2;
return res;
}
int main(){
int res = myadd(1,2);
printf("res: %d\n",res);
}
複製代碼
當咱們使用 clang++ 進行編譯後,再經過 nm 查看導出的 symbol 名
|
這時候本來的 myadd 函數名變成了__Z5myaddii,這對於 js 的使用方很不友好,所以咱們須要關掉 c++ 的 name mangle 處理。 經過 extern "C" 咱們能夠阻止 c++ 的默認 name mangle 行爲
#include <stdio.h>
extern "C" {
int myadd(int a,int b){
int res = a+b;
res = res + 2;
return res;
}
int main(){
int res = myadd(1,2);
printf("res: %d\n",res);
}
}
複製代碼
這樣咱們再次查看符號表,此時 myadd 變成了_myadd,這樣 js 側就能夠經過_myadd 引用 myadd 函數了。
emcc 爲了減少生成的 wasm 大小,對 c++ 的代碼進行了各類優化,其中有些優化會致使咱們沒法在 js 里正常的讀取 c++ 導出的函數,包括 DCE 和函數內聯。
emscripten 爲了保證生成的 wasm 儘量小,會將不少沒有使用的函數進行刪除,既作了 Dead code ellimination(DCE,相似於 treeshaking) 爲了保證須要使用的函數不被 emscripten 給 DCE 掉,須要告訴編譯器不要刪除該函數, emcc 經過 EXPORTED_FUNCTIONS
來保證所需函數不被刪除
emcc - s "EXPORTED_FUNCTIONS=['_main', '_my_func']" ...
emcc 的 EXPORTED_FUNCTIONS
的默認配置爲 _main
所以咱們看到咱們的 main 沒有被去除,實際上 main 和其餘函數並無本質區別 , 所以咱們但願保留 main,則須要將 _main
也添加到EXPORTED_FUNCTIONS
emscripten 爲了減少運行時的函數開銷,可能將部分函數內聯 除了 DCE,函數內聯也可能致使函數沒有被正常導出, 爲了保證函數不被內聯,可使用 EMSCRIPTEN_KEEPALIVE 來保證函數不被
inline void EMSCRIPTEN_KEEPALIVE yourCfunc() { .. }
複製代碼
由於 Javascript 和 c++ 有徹底不一樣的數據體系,Number 是二者的惟一交集,所以 JavaScript 與 C++ 相互調用的時候,都是經過 Number 類型進行交換。 當咱們須要在 C++ 和 Javascript 傳遞其餘類型時,須要先將其餘類型轉換成 Number 類型才能夠進行交換。幸運的是 emscripten 爲咱們封裝了一些功能函數來簡化 C++ 和 Javascript 之間的參數傳遞。 咱們能夠經過 allocateUTF8 將一個 js 的 string 類型轉換爲 number 數組類型,同時能夠經過 UTF8ToString 將 number 數組類型轉換爲 js 的 string 類型。 以下所示
const s1 = 'hello';
const s2 = 'world';
const res = Module._concat_str(Module.allocateUTF8(s1),Module.allocateUTF8(s2)); console.log('res:', Module.UTF8ToString(res)) // 'hello world'
複製代碼
emscripten 更進一步的咱們封裝了兩個函數用於作參數類型轉換,cwrap 和 ccall
這樣上述代碼便可簡化爲
const s1 = 'hello';
const s2 = 'world';
const res = Module.ccall('concat_string','string,['string','string'],[s1,s2])) console.log('res:',res);
複製代碼
若是函數須要屢次調用,咱們能夠採用 cwrap 進行一次封裝,能夠屢次調用
const concat = Module.cwrap('concat_string', 'string',['string','string']));
const r1 = concat(s1,s2); // 'hello world'
const r2 = concat(s2,s2); // 'world hello'
複製代碼
注意 emscripten 的這些內部函數默認是不導出的,若是要使用這些內部函數,須要編譯時經過 EXTRA_EXPORTED_RUNTIME_METHODS
將其導出
emcc -s \"EXTRA_EXPORTED_RUNTIME_METHODS=['cwrap','ccall']\" hello.c -o hello.js // 導出cwrap和ccall
複製代碼
emscripten 默認認爲的執行環境是 browser,所以其導出的對象其實是掛在全局的 Module 對象,且其加載是異步的,須要在 onRuntimeInitialized 事件回調中才能獲取完整的導出模塊,保證模塊導出方法的正常運行。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Emscripten:Export1</title>
</head>
<body>
<script>
Module = {};
Module.onRuntimeInitialized = function() { //此時才能得到完整Module對象
console.log(Module._show_me_the_answer());
console.log(Module._add(12, 1.0));
}
</script>
<script src="export1.js"></script>
</body>
</html>
複製代碼
emscripten 還提供了另外一種模塊化的導出方式,其導出一個返回 promise 的函數
emcc -s MODULARIZE=1 hello.cc -o hello.js // 導出返回promise的函數
複製代碼
這樣咱們就很方便的使用 Module 了
const _loadWasm = require('./hello.js');
async main(){
const Module = await _loadWasm();
return Module._add(1,2);
}
複製代碼
項目中的 C++ 裏使用了不少系統的 API,主要是一些文件 IO,原本覺得 wasm 無法支持文件 IO,但實際上 emscripten 對文件 IO 有很好的封裝,emscripten 提供了一套虛擬文件系統,以兼容不一樣環境下文件 IO 的適配問題。
在最底層,Emscripten 提供了三套文件系統
MEMFS: 系統的數據徹底存儲在內存中,其很是相似於 webpack 的實現,在內存裏模擬了一套文件系統的操做,運行時寫入的文件不會持久化到本地
NODEFS: Node.js 文件系統,該系統能夠訪問本地文件系統,能夠持久化存儲文件,但只能用於 Node.js 環境
IDBFS: indexDB 文件系統,該系統基於瀏覽器的 IndexDB 對象,能夠持久化存儲,但只用於瀏覽器環境
咱們最先嚐試使用了 NODEFS 來處理,早期的 NODEFS 有個很大的限制,使用本地文件系統前須要先將須要操做的本地的文件夾進行掛載。
void setup_nodefs() {
EM_ASM(FS.mkdir('/data'); FS.mount(NODEFS, {root : '.'},'/data'); // 將當前文件夾掛載到/data目錄下
);
}
int main() {
setup_nodefs(); // 先進行文件系統的掛載
FILE *fp = fopen("/data/nodefs_data.txt", "r+t"); // 訪問當前文件下須要拼接上掛載前綴
if (fp == NULL)
fp = fopen("/data/nodefs_data.txt", "w+t");
int count = 0;
if (fp) {
fscanf(fp, "%d", &count);
count++;
fseek(fp, 0, SEEK_SET);
fprintf(fp, "%d", count);
fclose(fp);
printf("count:%d\n", count);
} else {
printf("fopen failed.\n");
}
return 0;
}
複製代碼
這種作法雖然可行,可是須要對咱們原有的代碼進行較大改動,emscripten 爲了解決這個問題,提供了 NODERAWFS=1, 即在無需掛載文件系統的狀況下,能夠直接操做 NODEJS api,這樣就避免對原有的代碼進行改動
emcc -s NODERAWFS=1 hello.c -o hello.js
複製代碼
當咱們將 c++ 轉出 wasm 的時候,第一次運行的發現出現了較爲嚴重的內存泄漏問題,經排查發現是因爲 emscripten 默認生成的 js glue 代碼會帶上一些異常處理代碼。
咱們每次調用該 js gule 代碼的時候,都會綁定一個事件,而且綁定的事件會捕獲閉包上分配的 buffer, 致使累計捕獲的 buffer 愈來愈多,致使內存泄漏。emscripten 也存在相似的 issue github.com/emscripten-…
很幸運的是 emscripten 也提供方式禁用該捕捉行爲 github.com/emscripten-…
emcc build/liblepus.a -s NODEJS_CATCH_EXIT=0 -s NODEJS_CATCH_REJECTION=0 // 禁用nodejs的異常捕獲
複製代碼
這樣就避免了每次執行都會執行異常捕捉的綁定。 這樣雖然避免了 uncaughtException 和 unhandleRejection 的重複綁定,可是仍然可能存在其餘事件被重複綁定。所以咱們須要保證 js glue 代碼只執行一次
const _loadWasm = require('./js_glue.js') //
let task = null;
function loadWasm(){ // 保證在併發場景下_loadWasm也執行一次
if(!task){
task = _loadWasm();
}
return task;
}
export async function encode(){
const wasm = loadWasm();
return wasm.encode('src','dist');
}
複製代碼
emscripten 默認給 wasm 分配的內存是 16M,有時候這並不能知足需求,一樣也可能形成 OOM, 有兩種解決方式
經過 -s INITIAL_MEMORY=X
調整爲更大的內存
經過-s ALLOW_MEMORY_GROWTH=1
容許 wasm 動態增加所需內存
目前最新的 chrome 和 firefox 已經支持了 wasm 自己的調試
儘管咱們能夠在 wasm 上進行斷點調試,可是對於複雜的應用,這種彙編級別的調試仍然難以知足咱們的需求。咱們更指望在源碼層面上實現調試功能
很幸運的是 emscripten 已經支持了 sourcemap 調試,這樣在執行代碼的時候,能定位到其相對的源碼位置。
$ emcc -g4 hello.cc --source-map-base / -o index.html // g4開啓sourcemap調試
複製代碼
咱們能夠看到以下圖所示,咱們成功的將斷點斷在了 c++ 源碼的位置。
然而這種方式仍然存在必定的限制,咱們看到 sourcemap 只處理了代碼行數的映射關係,並無處理 c++ 變量到 wasm 寄存器變量的映射關係,所以對於複雜的應用,sourcemap 調試仍然捉襟見肘。
除了 sourcemap 能處理源碼和編譯後的代碼的映射關係外,dwarf 也是一種比較通用的調試數據格式 (debugging data format), 其普遍運用於 c|c++ 等 system programing language 上。其爲調試提供了代碼位置映射,變量名映射等功能。 emscripten 目前已經能夠爲生成的 wasm 代碼帶上 dwarf 信息。
$ emcc hello.cc -o hello.wasm -g // 帶上dwarf信息 咱們使用lldb和wasmtime進行調試
$ lldb -- wasmtime -g hello.wasm
複製代碼
咱們能夠清楚的看出來 wasm 映射到 c++ 代碼,而且變量也成功映射到 c++ 的變量裏。
lldb 對 wasm 的 dwarf 調試依賴了 llvm 的 jit 功能的,而 lldb 在 jit 功能在 MacOSX 上是默認關閉的 (lldb 的 jit 在 linux 上開啓的,gdb 的 jit 功能在 MacOSX 上也是開啓的。所以咱們須要手動的在 MacOS 上開啓 lldb 的 jit 功能, 只須要在. lldbinit 上配置
settings set plugin.jit-loader.gdb.enable on
便可
咱們能夠進一步的在 vscode 上依賴 codelldb 插件調試 wasm 程序,一樣也須要進行 jit 的配置, 只須要在 settings.json 裏配置 lldb 的 initCommands 便可
調試效果以下
咱們雖然能夠在經過 lldb 調試 wasm 應用,可是在瀏覽器上並無法執行 lldb,幸運的是瀏覽器已經開始嘗試支持 wasm 的 dwarf 調試了, 最新的 chrome 能夠開啓 dwarf 調試功能的實驗特性
粗淺的試了下,貌似仍是有 bug。。。並不能處理變量映射,顯示的仍然是寄存器變量
目前 node 對於 wasm 的 debug 支持程度貌似仍然有限,相關斷點並未生效。
實際的遷移過程比預想中的要簡單不少,emscripten 的整個工具鏈很是完善,大部分的問題都有解決方案,實際上咱們整個遷移過程,對 C++ 代碼沒有任何改動,只是一些編譯工具的改動。這很大的擴展了前端的領域,咱們的第三方庫不再侷限於 npm,咱們能夠將衆多的 C++ 庫先編譯爲 wasm,從而爲我所用。