
作者| 張翰(門柳)
出品|阿裡巴巴新零售淘系技術部
WebAssembly 是一個 W3C 推出的二進制指令格式,近日它的 1.0 版本也正式定稿成為了規範,關于它的基本概念這裡不再展開介紹了,網上已經有很多文章了,大家可以自行了解,推薦閱讀官方文檔、spec 倉庫、MDN 的教程、以及 Lin Clark 的文章,其他随意。
能編譯成 wasm 的語言有很多,C++ 和 Rust 是其中兩個比較成熟而且大量被使用的,本文以 C++ 為例,一步一步介紹如何把 C++ 代碼編譯成 wasm 并且運作起來。例子很簡單,相信不了解 C/C++ 開發的同學也能看懂。
說是 C++ 其實本文用到的代碼都是純 C 的。
文章用到的源碼和編譯腳本都在:
https://github.com/Hanks10100/cpp2wasmHello World!
首先,我們來編譯一個 C 語言的 Hello World,建立一個 hello.c 檔案:
編譯成可執行檔案
代碼就是輸出了一句 Hello World! ,使用 clang 或 gcc 或很多工具都可以把這段代碼編譯成可執行的二進制,找不到指令的話,可以在網上找教程配置一下。以 clang 為例:
clang hello.c -O3 -o out/hello
-O3 表示了優化級别, 生成的可執行檔案是 hello ,但是這個檔案隻能在特定平台上執行,在 windows 上編譯出來的檔案沒辦法跑在 mac 上(不絕對),在 32 位系統編譯出來的檔案無法跑在 64 位系統上。
然而如果把它編譯成 wasm 就可以跨平台分發了,這也是 wasm 的一大優勢。隻需要編譯一次,同一個 wasm 包,可以運作在浏覽器中、Node.js 中、各種獨立的 runtime 裡,但是要求目标平台具備執行 wasm 包的能力,而且符合規範。
WebAssembly 的編譯和運作流程
在編譯 WebAssembly 之前先了解一下它基本的編譯和運作流程,想要以何種方式運作 wasm 的包,決定了以何種方式來編譯它。
目前來看,大部分使用 WebAssembly 的例子都是運作在浏覽器中的,有一部分運作在 Node.js 裡,和 JS 的淵源很深,因為在标準裡定義了一套 JS API 來編譯、執行個體化 wasm 檔案,這部分 API 已經被 JS 引擎實作了,功能已經穩定可用。是以,wasm 最常見的是搭配 js 一起使用,這種場景下用 Emscripten 可以搞定,它在編譯 wasm 包的同時也會生成一份 js "glue" 代碼,把 wasm 包的初始化接口導入導出都封裝在 js 裡了,使用時引入這個 js 檔案即可。
Emscripten 也支援編譯成獨立的 wasm 包(不含 JS),但是想要運作這個 wasm 包需要宿主環境給它注入很多基礎的 API,而且這些 API 是非标準的。如果想在 JS 環境裡運作獨立 wasm 包的話,要用 JS 實作這些 API。
其實 WebAssembly 本質上和 JS 無關,完全可以運作在獨立的沙箱環境裡,通過标準化的 API (wasi) 來調用系統能力。現在已經有不少 wasm 的獨立運作時了,如 Wasmtime 和 wasm-micro-runtime ,它們都可以加載并獨立執行 wasm 檔案,并且實作了一緻的 wasi 接口。
關于 wasi,推薦閱讀《Standardizing WASI: A system interface to run WebAssembly outside the web》
https://hacks.mozilla.org/2019/03/standardizing-wasi-a-webassembly-system-interface/如上圖所示,面對自己的 C/C++ 代碼,想要把它運作在浏覽器或 Node.js 中,就使用 Emscripten 把它編譯成 wasm + js 檔案;想要把它運作在獨立的運作時裡,就使用 wasi-sdk 進行編譯,生成單獨的 wasm 包。(此結論簡單粗暴,為了友善了解,并不嚴謹)
使用 Emscripten 編譯
首先安裝官方文檔安裝 Emscripten (
https://emscripten.org/),安裝完成後指令行環境裡會有 emcc 指令,使用方式和 gcc 差不多,執行如下代碼就可以生成 wasm 的包:
emcc hello.c -O3 -o out/hello-emcc.wasm
但是,上面這個指令隐含了 -s STANDALONE_WASM 的配置 ,實際上觸發的是 WebAssembly Standalone build,隻生成了一個 wasm 的包,需要自己寫 loader 加載和執行。如果不想費這個勁,就可以使用如下指令直接生成 wasm + js 檔案:
emcc hello.c -O3 -o out/hello-emcc.js
該指令除了生成 js 檔案以外,還會生成同名的 hello-emcc.wasm 檔案,可以使用 WABT (WebAssembly Binary Toolkit) 提供的小工具把 wasm 檔案轉成對等的文本格式,友善閱讀。
wasm2wat out/hello-emcc.wasm -o out/hello-emcc.wat
代碼比較短,但是生成出來的 wasm 檔案有 2.1KB,js 檔案 16KB,主要是因為 stdio.h 頭檔案裡有很多依賴,在運作時是由 js 代碼來實作的。用 wasm 做 io 本身也不是個好的用法。
最後,直接在 Node.js 環境裡執行這個 js 檔案就行了,可以看到控制台輸出了 Hello World! 。
node out/hello-emcc.js
使用 wasi-sdk 編譯
首先根據自己的系統下載下傳相應的 wasi-sdk (
https://github.com/CraneStation/wasi-sdk/releases),配置好環境變量之後,就可以調用其中自帶的 clang 工具編譯生成 wasm 檔案:
clang hello.c -O3 -o out/hello-wasi.wasm
大機率跑不通…… 因為要配各種環境變量還要指定 sysroot 才行。假如你下載下傳的是 8.0 版本,放到了個人目錄之下,可以用下面這個指令編譯代碼,不需要配置環境變量:
~/wasi-sdk-8.0/bin/clang --sysroot ~/wasi-sdk-8.0/share/wasi-sysroot hello.c -O3 -o out/hello-wasi.wasm
如果是 Mac 電腦,遇到安全提示,在【系統偏好設定】-【安全與隐私】-【通用】裡,找到“允許以下位置下載下傳的App”的配置,下方應該有提示資訊,點選允許就可以了。
打出來包之後,可以用 file out/hello-wasi.wasm 指令檢查一下生成的包格式對不對,有如下輸出才是正确的,否則你打出來的很可能是個原生的二進制檔案。
hello-wasi.wasm: WebAssembly (wasm) binary module version 0x1 (MVP)
同樣,可以用 wasm2wat 工具生成可讀的文本格式,友善看代碼:
wasm2wat out/hello-wasi.wasm -o out/hello-wasi.wat
想要運作這個 wasm 包,需要有獨立的運作時,理論上講,所有實作了标準 wasi 接口的 runtime 都可以執行這個包。以 Wasmtime (
https://wasmtime.dev/)為例,安裝好後,用下方指令就可以執行這個 wasm 檔案,會看到控制台有 Hello World! 的輸出:
wasmtime out/hello-wasi.wasm
另外,如果不想在電腦上裝這些獨立運作時,還有個神奇的網站(
https://wasi.dev/polyfill/)可以線上運作基于 wasi 接口的 wasm 包。這個網站在浏覽器環境裡實作了一份 polyfill,對等實作了原生 wasm 運作時的一部分功能。把剛編譯好的 hello-wasi.wasm 檔案傳上去,也可以看到 Hello World! 的輸出。
編譯獨立子產品
然而在實際情況中,并不是所有包都想自執行,不一定都有 main 函數,大部分 wasm 包是想提供一些 api 供外部調用。自己列印 Hello World 沒有任何意義,要和宿主環境有互動才行。
下面以斐波那契數列為例,介紹如何編譯一個獨立的 wasm 子產品。C 語言代碼如下:
int fib (int n) {
if (n <= 0) return 0;
if (n <= 2) return 1;
return fib(n - 2) + fib(n - 1);
}
這次代碼裡沒了 main 函數,隻有一個 fib 函數,而 Emscripten 預設隻導出 main 函數,是以在編譯時加上 EXPORTED_FUNCTIONS 的配置指定導出的接口,其他同上:
emcc fib.c -s EXPORTED_FUNCTIONS='["_fib"]' -O3 -o out/fib-emcc.wasm
編譯 C/C++ 的時候函數名會預設加上 _ 字首,是以導出的接口名是 _fib 而不是 fib 。
這次生成的包很小,把它轉成文本格式後隻有 27 行,代碼如下:
可以看到這個包隻導出一個了 _fib 函數,函數接受 i32 數字為參數,傳回一個 i32 數字。想要在 JS 環境裡運作起來這個包,需要用 js 代碼來加載執行這個包,可以封裝如下函數:
// 編譯并執行個體化 wasm 子產品,傳回導出的接口
async function loadWebAssembly (filename, env) {
const filePath = path.resolve(__dirname, filename)
// 讀入 wasm 檔案的二進制代碼
const buffer = fs.readFileSync(filePath)
// 将 wasm 包執行個體化并傳入外部接口,因為沒有外部依賴,不傳 env 也可以的
const results = await WebAssembly.instantiate(buffer, {
env: Object.assign({
'__memory_base': 0,
'__table_base': 0,
memory: new WebAssembly.Memory({ initial: 256, maximum: 256 }),
table: new WebAssembly.Table({ initial: 0, maximum: 128, element: 'anyfunc' })
}, env)
})
// 傳回執行個體化好之後的接口
if (results && results.instance) {
return results.instance.exports
}
}
然後使用這個函數加載 wasm 檔案:
loadWebAssembly('./out/fib-emcc.wasm').then(apis => {
console.log(apis._fib(13)) // 輸出 233
})
完整代碼在:
https://github.com/Hanks10100/cpp2wasm/blob/master/loader.js這次 wasi-sdk 的編譯選項稍微複雜了一些:
~/wasi-sdk-8.0/bin/clang --sysroot ~/wasi-sdk-8.0/share/wasi-sysroot fib.c \
-nostartfiles -fvisibility=hidden -Wl,--no-entry,--export=fib \
-O3 -o out/fib-wasi.wasm
第一行和第三行其實沒變,隻是加了第二行指定導出的接口并添加其他的優化編譯選項,具體每個字段的含義(我也不懂)我就不解釋了,感興趣的話自行搜尋吧。
生成的包也很小,轉成文本格式後代碼如下:
同樣是内部定義了 fib 函數并 export 出來,同時還定義了 table 和 memory 而且把 memory 導出,table 是用來存放間接調用的函數表,memory 定義了初始記憶體大小,這個例子裡并沒用到,可删掉。
運作這個包的方式和上面一樣,加上 --invoke 可以指定調用的接口,可以傳遞參數:
wasmtime out/fib-wasi.wasm --invoke fib 7
上面的指令會輸出 13 。
另外,因為這個例子沒有外部依賴,是以生成的包對環境沒什麼要求,上面用 Emscripten 生成的 fib-emcc.wasm 這個包,也是可以用 Wasmtime 來執行的,調用方法一緻:
wasmtime out/fib-emcc.wasm --invoke _fib 13
簡單分析
一個簡單的 hello world 為什麼編譯出來這麼多代碼?編譯工具到底幹了啥?為什麼還要有個 js 檔案?
這些問題與 WebAssembly 的技術特點有關。
WebAssembly 本質上講就是一種二進制格式而已,一個 wasm 檔案可以認為就是一個獨立的子產品,子產品的包格式如下:
開頭是個固定的寫死,然後是各種 section,所有 section 都是可選的,其中 type section 是聲明函數類型的,還有 function, code, data 這些 section 都是封裝内部邏輯的,start section 聲明了自執行的函數,另外還有 table, global, memory, element 等。最需要外部關注的,與外界環境互動的是 import section 和 export section,分别定義了導入和導出的接口。
簡單粗暴點講 WebAssembly 隻定義了導入和導出的接口和内部的運算邏輯,想要使用到宿主環境的能力,隻能聲明出依賴的接口,然後由宿主環境來注入。例如發送網絡請求、讀寫檔案、打日志等等,不同宿主環境中的接口是不一樣的,wasm 包裡聲明了一套自己想要的接口,宿主環境在執行個體化 wasm 子產品的時候,按照 wasm 自己定義的格式,把目前環境的真實接口傳遞給它。
以 hello.c 為例,它有對 頭檔案的依賴,雖然代碼沒有讀寫檔案,但是頭檔案裡包含了這類接口,是以生成的 wasm 包裡聲明了需要導入 io 相關的接口。下面是 Emscripten 生成的 wasm 包對應的文本描述(不含 -O3 優化),hello-emcc.wat 檔案開頭的一部分:
可以看到它依賴宿主環境注冊大量 env 接口,隻有正确注入了這些接口才能確定 wasm 包可以正确的運作起來。在 Emscripten 同時生成的那個 js 檔案裡,就包含了這些接口的實作,在執行個體化 wasm 的時候自動注入進來,是它的内部邏輯,外部使用的時候不必關心。
這些接口都是什麼玩意兒……?看起來像非标準的東西, __syscall140 和 __syscall6 分别是幹啥的?不知道函數功能也不知道參數含義,不用 Emscripten 生成的 js 檔案,完全不知道該怎麼執行個體化這個 wasm 包,是以在運作 wasm 的時候就必須帶上一份厚重的“js glue”。
再來看一下 wasi-sdk 生成的檔案,需要導入的接口就可讀多了:
裡面的 proc_exit 和 fd_write 等接口,就是 wasi 定義的标準接口,隻要宿主環境按照規範實作了這些接口,就可以運作這個 wasm 包。而且這些 wasi 接口也不是用 js 實作的,性能更好一些,也完全不依賴 js 引擎。
其實在 Emscripten 裡生成的 __syscall140 就是要查找檔案,基本等價于 fd_seek , __syscall6 基本等價于 fd_close ,但是前者沒有語義而且非标準,強依賴 Emscripten 生成的 js 檔案才能運作,而 wasi 接口就具備了更好的性能和跨平台能力。這就是标準化的力量,也是 WebAssembly 的一個發展方向。
性能對比
就不到十行代碼還好意思做性能對比…… 我覺得低于 200 行代碼跑出來的性能測試都不太靠譜。而且執行 js 和執行 wasm 的鍊路不一樣,編譯工具的優化程度不一樣,編譯出來的包依賴的接口也不一樣,太多不确定性,測出的資料裡都是噪聲。在之後的文章裡,我會用複雜的例子來測試 WebAssembly 的性能。(挖坑)
接下來幹什麼
文章寫的很淺顯,目的是讓不懂 C/C++ 不懂 WebAssembly 可以快速入門。我覺得 WebAssembly 目前的一個問題是沒有很明确、很具體的使用場景,大部分人都或多或少了解這個技術,知道整體的發展方向,但是覺得無從下手,最多是在某個環節中做小規模嘗試。
我也嘗試着把一個完整的 C++ 項目(約 2W+行代碼)編譯成了 WebAssembly,并且能在浏覽器和 Node.js 環境裡跑起來,隻是為了深入研究 WebAssembly 這項技術,未必是一個很适合使用 WebAssembly 的場景。寫 demo 和真的把 WebAssembly 用起來,中間的差距還是很大的,這篇文章是一個引子,我在下一篇文章裡詳細介紹一下我在過程中遇到的問題和解決方案。
One More Thing
淘系技術部依托淘系豐富的業務形态和海量的使用者,我們持續以技術驅動産品和商業創新,不斷探索和衍生颠覆型網際網路新技術,以更加智能、友好、普惠的科技深度重塑産業和使用者體驗,打造新商業。我們不斷吸引使用者增長、機器學習、視覺算法、音視訊通信、數字媒體、移動技術、端側智能等領域全球頂尖專業人才加入,讓科技引領面向未來的商業創新和進步。
請投遞履歷至郵箱:[email protected]
了解更多職位詳情:2684億成交!每秒訂單峰值54.4W!這樣的團隊你想加入嗎?
更多技術幹貨,關注「淘系技術」微信公衆号~