Categories
程式開發

基礎為零?如何將 C++ 編譯成 WebAssembly


WebAssembly 是一個W3C 推出的二進制指令格式,近日它的1.0 版本也正式定稿成為了規範,關於它的基本概念這裡不再展開介紹了,網上已經有很多文章了,大家可以自行了解,推薦閱讀官方文檔①、spec 倉庫②、MDN 的教程③、以及Lin Clark 的文章④,其他隨意。

能編譯成 wasm 的語言有很多,C++ 和 Rust 是其中兩個比較成熟而且大量被使用的,本文以 C++ 為例,一步一步介紹 如何把 C++ 代碼編譯成 wasm 並且運行起來。例子很簡單,相信不了解 C/C++ 開發的同學也能看懂。

說是 C++ 其實本文用到的代碼都是純 C 的。

文章用到的源碼和編譯腳本都在https://github.com/Hanks10100/cpp2wasm

Hello World!

首先,我們來編譯一個 C 語言的 Hello World,創建一個 hello.c 文件:

#include 

int main() {
  printf("Hello World!n");
  return 0;
}

編譯成可執行文件

代碼就是輸出了一句 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 包需要宿主環境給它注入很多基礎的 A​​PI,而且這些 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++ 編譯成 WebAssembly 1

如上圖所示,面對自己的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 ,配置好環境變量之後,就可以調用其中自帶的 clang 工具編譯生成 wasm 文件:

clang hello.c -O3 -o out/hello-wasi.wasm

大概率跑不通…… 因為要配各種環境變量還要指定 sysroot 才行。假如你下載的是 8.0 版本,放到了個人目錄之下,可以用下面這個命令編譯代碼,不需要配置環境變量:

如果是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! 的輸出。

基礎為零?如何將 C++ 編譯成 WebAssembly 2

編譯獨立模塊

然而在實際情況中,並不是所有包都想自執行,不一定都有 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);
}

使用 Emscripten 編譯

這次代碼裡沒了 main 函數,只有一個 fib 函數,而 Emscripten 默認只導出 main 函數,所以在編譯時加上 EXPORTED_FUNCTIONS 的配置指定導出的接口,其他同上:

emcc fib.c -s EXPORTED_FUNCTIONS='("_fib")' -O3 -o out/fib-emcc.wasm

編譯 C/C++ 的時候函數名會默認加上 _ 前綴,所以導出的接口名是 _fib 而不是 fib 。

這次生成的包很小,把它轉成文本格式後只有 27 行,代碼如下:

基礎為零?如何將 C++ 編譯成 WebAssembly 3

可以看到這個包只導出一個了 _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 文件:

  console.log(apis._fib(13))  // 输出 233
})

完整代碼https://github.com/Hanks10100/cpp2wasm/blob/master/loader.js

使用 wasi-sdk 編譯

這次 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

第一行和第三行其實沒變,只是加了第二行指定導出的接口並添加其他的優化編譯選項,具體每個字段的含義(我也不懂)我就不解釋了,感興趣的話自行搜索吧。

生成的包也很小,轉成文本格式後代碼如下:

基礎為零?如何將 C++ 編譯成 WebAssembly 4

同樣是內部定義了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 文件可以認為就是一個獨立的模塊,模塊的包格式如下:

基礎為零?如何將 C++ 編譯成 WebAssembly 5

開頭是個固定的硬編碼,然後是各種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 文件開頭的一部分:

基礎為零?如何將 C++ 編譯成 WebAssembly 6

可以看到它依賴宿主環境註冊大量 env 接口,只有正確注入了這些接口才能確保 wasm 包可以正確的運行起來。在 Emscripten 同時生成的那個 js 文件裡,就包含了這些接口的實現,在實例化 wasm 的時候自動注入進來,是它的內部邏輯,外部使用的時候不必關心。

這些接口都是什麼玩意兒……?看起來像非標準的東西, __syscall140 和 __syscall6 分別是乾啥的?不知道函數功能也不知道參數含義,不用 Emscripten 生成的 js 文件,完全不知道該怎麼實例化這個 wasm 包,所以在運行 wasm 的時候就必須帶上一份厚重的“js glue”。

再來看一下 wasi-sdk 生成的文件,需要導入的接口就可讀多了:

基礎為零?如何將 C++ 編譯成 WebAssembly 7

裡面的 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 用起來,中間的差距還是很大的,這篇文章是一個引子,我在下一篇文章裡詳細介紹一下我在過程中遇到的問題和解決方案。

文中鏈接:

1 https://webassembly.org/

2 https://github.com/WebAssembly/spec

3 https://developer.mozilla.org/en-US/docs/WebAssembly

4 https://hacks.mozilla.org/author/lclarkmozilla-com/

5 https://emscripten.org/index.html

6 https://wasi.dev/

7 https://github.com/bytecodealliance/wasmtime

8 https://github.com/bytecodealliance/wasm-micro-runtime

9 https://github.com/CraneStation/wasi-sdk

10 https://github.com/emscripten-core/emscripten/wiki/WebAssembly-Standalone

11 https://github.com/WebAssembly/wabt

12 http://webassembly.github.io/spec/core/binary/modules.html#sections

13 https://github.com/bytecodealliance/wasmtime/blob/master/docs/WASI-api.md

本文轉載自公眾號淘系技術(ID:AlibabaMTT)。

原文鏈接

https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ==&mid=2650404876&idx=1&sn=713633ee025f1a12a0ea657d28af1e91&chksm=83953014b4e2b902388d2f144ae348bae5ba1700ea4c7c71bb867a4a143b399340ede018db7d&scene=27#wechat_redirect