建立和使用 WebAssembly 元件

希裡花斯發表於2017-03-21

這是 WebAssembly 系列文章的第四部分。如果你還沒閱讀過前面的文章,我們建議你從頭開始

WebAssembly 是一種不同於 JavaScript 的在 web 頁面上執行程式語言的方式。以前當你想在瀏覽器上執行程式碼來實現 web 頁面不同部分的互動時,你唯一的選擇就是 JavaScript。

因此當人們談論 WebAssembly 執行迅速時,合理的比較物件就是 JavaScript。但這並不意味著你必須在 WebAssembly 和 JavaScript 二者中選擇一個使用。

事實上我們希望開發者在同一應用中同時使用 WebAssembly 和 JavaScript。即使你不親自寫 WebAssembly 程式碼,你也可以使用它。

WebAssembly 元件定義的函式可以在 JavaScript 中使用。因此,就像現在你可以從 npm 上下載一個 lodash 這樣的元件並且根據它的 API 呼叫方法一樣,在未來你同樣可以下載 WebAssembly 元件。

那麼讓我們看看如何建立 WebAssembly 元件,以及如何在 JavaScript 中使用這些元件吧。

WebAssembly 處於哪個環節?

在上一篇關於彙編的文章裡,我談到過編譯器怎麼提取高階程式語言並且把它們翻譯成機器碼。

建立和使用 WebAssembly 元件
Diagram showing an intermediate representation between high level languages and assembly languages, with arrows going from high level programming languages to intermediate representation, and then from intermediate representation to assembly language

WebAssembly 對應這張圖片的哪個部分?

你可能認為它只不過是又一個目標組合語言。某種程度上是對的,不同之處在於那些語言(x86,ARM)中每個都對應一個特定的機器架構。

當你通過 web 向使用者的機器上傳送要執行的程式碼時,你並不知道你的程式碼將要在哪種目標架構上執行。

所以 WebAssembly 和其他的彙編有些細微的差別。它是概念機的機器語言,而非真實的物理機。

正因如此,WebAssembly 指令有時也被稱為虛擬指令。它們比 JavaScript 原始碼有更直接的機器碼對映。它們代表一類可以在常見的流行硬體上高效執行的指令集合。但是它們並不直接對映某一具體硬體的特定機器碼。

建立和使用 WebAssembly 元件
Same diagram as above with WebAssembly inserted between the intermediate representation and assembly

瀏覽器下載 WebAssembly 後,它就能從 WebAssembly 轉成目標機器的彙編碼。

編譯成 .wasm

LLVM 是當前對 WebAssembly 支援最好的編譯工具鏈。很多前後端編譯工具都可以嵌入 LLVM 中。

注:大部分 WebAssembly 元件開發者用 C 和 Rust 這樣的語言編寫程式碼,然後編譯成 WebAssembly,但仍有其他的方法來建立 WebAssembly 元件。比如,有一個實驗性的工具幫你使用 TypeScript 構建 WebAssembly 元件,或者你可以直接在 WebAssembly 的文字表示上編碼

比如說我們想把 C 編譯成 WebAssembly。我們可以使用 clang 編譯器前端把 C 編譯成 LLVM 中介碼。一旦它處於 LLVM 的中間層,LLVM 編譯它,LLVM 就可以展現一些效能優化。

要把 LLVM IR(中介碼)編譯成 WebAssembly,我們需要一個後端支援。在 LLVM 專案中有一個這類後端正在開發中。這個後端專案已經接近完成並且應該很快就會定稿。然而,現在使用它還會有不少問題。

目前有一個稍微容易使用的工具叫 Emscripten。他有自己的後端,可以通過編譯成其他物件(稱為 asm.js)然後再轉換成 WebAssembly 的方式來產生 WebAssembly。好像它底層仍舊使用 LLVM,因此你可以在 Emscripten 中切換這兩種後端。

建立和使用 WebAssembly 元件
Diagram of the compiler toolchain

Emscripten 包含了許多附加工具和庫來支援移植整個 C/C++ 程式碼庫,因此它更像一個 SDK 而非編譯器。舉個例子,系統開發人員習慣於有一個檔案系統用來讀寫,所以 Emscripten 可以使用 IndexedDB 模擬一個檔案系統。

忽略你已經使用的工具鏈,最後得到的結果就是一個字尾名為 .wasm 的檔案。下面我將著重解釋 .wasm 檔案的結構。首先,我們先看看怎樣在JS中使用 .wasm 檔案。

在 JavaScript 中載入一個 .wasm 元件

這個 .wasm 檔案是一個 WebAssembly 元件,它可以在 JavaScript 中載入。在此情景下,載入過程稍微有些複雜。

functionfetchAndInstantiate(url, importObject) {
  return fetch(url).then(response =>
    response.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results =>
    results.instance
  );
}複製程式碼

你可以在我們的文件中深入瞭解這部分內容。

我們致力於讓這個過程變得更容易。我們期望改進工具鏈,整合已存在的像 webpack 這樣的模組打包工具以及類似 SystemJS 的動態載入器。我們相信載入 WebAssembly 元件可以像載入 JavaScript 元件一樣簡單。

不過,WebAssembly 元件和 JS 元件有一個顯著的區別。目前,WebAssembly 函式只能使用數字(整型或浮點型數字)作為引數和返回值。

建立和使用 WebAssembly 元件
Diagram showing a JS function calling a C function and passing in an integer, which returns an integer in response

對於更加複雜的資料型別,如字串,你必須使用 WebAssembly 元件儲存器。

像 C,C++,和 Rust 這些更高效能的語言傾向於手動管理記憶體。如果你大部分時間都在使用 JavaScript,也許對直接訪問儲存器的操作不熟悉。WebAssembly 元件儲存器模擬了你在這些語言中會看到的堆。

為了實現這個功能,它使用了 JavaScript 中的型別化陣列(ArrayBuffer)。型別化陣列是存放位元組的陣列。陣列的索引就是對應的儲存器地址。

如果想要在 JavaScript 和 WebAssembly 中傳遞字串,你需要把這些字元轉換成他們的字元碼常量。然後把這些寫入儲存器陣列。既然索引是整數,那麼單個索引值就可以傳入 WebAssembly 函式中。這樣字串中第一個字元的索引就可以被當成一個指標使用。

建立和使用 WebAssembly 元件
Diagram showing a JS function calling a C function with an integer that represents a pointer into memory, and then the C function writing into memory

幾乎所有想要開發供 web 開發者使用的 WebAssembly 元件的開發者,都會為元件建立一個包裝器。這樣以來,你作為元件的消費者並不需要了解記憶體管理。

如果想了解更多的話,檢視我們關於使用 WebAssembly 記憶體的文件。

.wasm 檔案結構

如果你使用高階語言來編寫程式碼然後把它編譯成 WebAssembly,你不必知道 WebAssembly 元件的結構。但是它可以幫助你理解其基本原理。

如果你之前沒有了解這些基本原理,我們建議你先閱讀 彙編文章 (part 3 of the series)。

下面是一個 C 函式,我們將把它轉成 WebAssembly:

int add42(int num) {
  return num + 42;
}複製程式碼

你可以使用 WASM Explorer 來編譯這個函式。

如果你開啟 .wasm 檔案(假設你的編輯器支援顯示),你將看到類似這樣的內容:

00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60
01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80
80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06
81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65
6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69
00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20
00 41 2A 6A 0B複製程式碼

這是元件的“二進位制”表示法。我把二進位制加上引號是因為它通常顯示的是十六進位制符號,但這很容易轉換成二進位制符號,或者人類可讀的格式。

舉個例子,下圖是 num + 42 的幾種表現形式。

建立和使用 WebAssembly 元件
Table showing hexadecimal representation of 3 instructions (20 00 41 2A 6A), their binary representation, and then the text representation (get_local 0, i32.const 42, i32.add)

程式碼如何執行:堆疊機

如果你想知道的話,下圖是執行的一些指令說明。

建立和使用 WebAssembly 元件
Diagram showing that get_local 0 gets value of first param and pushes it on the stack, i32.const 42 pushes a constant value on the stack, and i32.add adds the top two values from the stack and pushes the result

你可能注意到了 add 操作並沒有說明他的值應該從哪裡來。這是因為 WebAssembly 是堆疊機的一個範例。這意味著一個操作所需的所有值在操作執行之前都在棧中排隊。

例如 add 這類的操作指導它們需要多少值。如果 add 需要兩個值,它將從棧頂取出兩個值。這意味著 add 指令可以很短(單個位元組),因為指令不需要指定源或者目的暫存器。這減少了 .wasm 檔案的大小,也意味著下載的耗時更短。

即使 WebAssembly 就堆疊機而言是特定的,但那不是其在物理機上的工作方式。當瀏覽器把 WebAssembly 轉化成其執行機器上對應的機器碼時,將會用到暫存器。因為 WebAssembly 程式碼不指定暫存器,所以瀏覽器在當前機器上能更靈活的去使用最佳暫存器分配。

元件的 sections

除了 add42 函式自身,.wasm 檔案還有其他部分。那就是 sections。一些 sections 對任何元件都是必需的,而有一些是可選的。

必選項:

  1. 型別(Type)。包括在該元件中定義的函式簽名以及任何引入的函式。
  2. 函式(Function)。給每一個在該元件中定義的函式一個索引。
  3. 程式碼(Code)。該元件中定義的每一個函式的實際函式體。

可選項:

  1. 匯出(Export)。使函式,記憶體,表以及全域性變數對其他 WebAssembly 元件和 JavaScript 可用。這使獨立編譯的元件可以被動態連結在一起。這就是 WebAssembly 的 .dll 版本。
  2. 匯入(Import)。從其他 WebAssembly 元件或 JavaScript 中匯入指定的函式,記憶體,表以及全域性變數。
  3. 啟動(Start)。當 WebAssembly 元件載入時自動執行的函式(基本上類似一個主函式)。
  4. 全域性變數(Global)。為元件宣告全域性變數。
  5. 記憶體(Memory)。定義元件將使用到的記憶體空間。
  6. 表(Table)。使把值對映到 WebAssembly 元件外部成為可能,就像 JavaScript 物件那樣。這對於允許簡介函式呼叫相當有用。
  7. 資料(Data)。初始化匯入或本地記憶體。
  8. 元素(Element)。初始化匯入或本地的表。

更多關於 sections 的闡釋,這有一篇深度好文解釋這些 sections 如何執行

接下來

現在你知道怎樣使用 WebAssembly 元件了,讓我們看看為什麼 WebAssembly 這麼快

『系列文章之最終章』

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章