WebAssembly 是什麼?來源為何?

自從網路成為不只用於文件,也用於應用程式的平台後,部分最先進的應用程式已將網路瀏覽器推向極限。許多高階語言都會採用「更貼近金屬」的做法,透過與低階語言互動來提升效能。舉例來說,Java 有 Java Native Interface。就 JavaScript 而言,這個低階語言就是 WebAssembly。本文將說明組合語言的概念,以及為何在網路上使用組合語言,接著說明如何透過 asm.js 的暫時性解決方案建立 WebAssembly。

組合語言

您是否曾使用組合語言編寫程式?在電腦程式設計中,組合語言 (通常簡稱為組合語,通常縮寫為 ASM 或 asm) 是任何低階程式設計語言,其語言指令與架構的機器碼指令之間有非常強的對應關係。

舉例來說,如果查看 Intel® 64 和 IA-32 架構 (PDF),MUL 指令 (適用於「等量」mul簡化) 會執行第一個運算元 (目的地運算元) 和第二個運算元 (來源運算元) 的無正負號乘法,並將結果儲存在目的地運算元中。簡單來說,目的地運算元是位於註冊 AX 中的默示運算元,來源運算元則位於一般用途暫存器中,例如 CX。結果會再次儲存在註冊 AX 中。請參考以下 x86 程式碼範例:

mov ax, 5  ; Set the value of register AX to 5.
mov cx, 10 ; Set the value of register CX to 10.
mul cx     ; Multiply the value of register AX (5)
           ; and the value of register CX (10), and
           ; store the result in register AX.

舉例來說,如果目標是將 5 和 10 相乘,您可能會在 JavaScript 中編寫類似下列的程式碼:

const factor1 = 5;
const factor2 = 10;
const result = factor1 * factor2;

採用彙整路徑的優點是,這種低階且經過機器最佳化的程式碼,比高階且經過人類最佳化的程式碼更有效率。在上述情況中,這並無差別,但您可以想像,對於更複雜的作業,差異可能會相當大。

顧名思義,x86 程式碼會依賴 x86 架構。如果有一種方法可以編寫不依賴特定架構的彙整程式碼,但會繼承彙整的效能優勢,那麼該怎麼辦?

asm.js

如要編寫不含架構相依性的彙整程式碼,第一步就是使用 asm.js,這是 JavaScript 的嚴格子集,可用於編譯器的低階、高效的目標語言。這個子語言有效描述了沙箱虛擬機器,適用於 C 或 C++ 等不安全的記憶體。混合使用靜態和動態驗證,讓 JavaScript 引擎能預先 (AOT) 針對有效的 asm.js 程式碼利用最佳化編譯策略。使用靜態型別語言 (例如 C) 手動管理記憶體的程式碼,會由源到源編譯器 (例如 早期 Emscripten,以 LLVM 為基礎) 進行轉譯。

我們將語言功能限制為可使用 AOT 的功能,藉此改善效能。Firefox 22 是第一個支援 asm.js 的瀏覽器,名為 OdinMonkey。Chrome 在 61 版中新增了 asm.js 支援。雖然 asm.js 仍可在瀏覽器中運作,但已被 WebAssembly 取代。目前使用 asm.js 的原因,是因為它是針對不支援 WebAssembly 的瀏覽器提供的替代方案。

WebAssembly

WebAssembly 是類似低階組件的語言,具備精簡的二進位格式,可在接近原生效能的情況下運作,並提供 C/C++ 和 Rust 等語言,以及許多具有編譯目標,以便在網路上執行。我們正在努力支援記憶體管理語言,例如 Java 和 Dart,應該很快就會推出,或是已經推出,例如 Kotlin/Wasm。WebAssembly 可與 JavaScript 搭配運作。

除了瀏覽器之外,WebAssembly 程式還能在其他執行階段中執行,藉助於 WASI,WebAssembly 系統介面 (WebAssembly 的模組化系統介面)WASI 是專為可跨作業系統移動而設計,其目標是確保安全,且能夠在沙箱環境中執行。

WebAssembly 程式碼 (二進位碼,也就是位元碼) 預計在可攜式虛擬堆疊機器 (VM) 上執行。位元碼的設計目的是比 JavaScript 更快地剖析及執行,並提供精簡的程式碼表示法。

概念上,指令的執行程序會透過傳統的程式計數器,依序執行指令。實際上,大多數 Wasm 引擎會將 Wasm 位元碼編譯為機器碼,然後執行該程式碼。指令分為兩類:

  • 控制指示語會形成控制結構體,並將引數值彈出堆疊,可能會變更程式計數器,並將結果值推送至堆疊。
  • 簡單指令:從堆疊中彈出引數值、對值套用運算子,然後將結果值推送至堆疊,接著隱含地推進程式計數器。

回到先前的範例,下列 WebAssembly 程式碼等同於文章開頭的 x86 程式碼:

i32.const 5  ; Push the integer value 5 onto the stack.
i32.const 10 ; Push the integer value 10 onto the stack.
i32.mul      ; Pop the two most recent items on the stack,
             ; multiply them, and push the result onto the stack.

雖然 asm.js 是完全在軟體中實作,也就是其程式碼可在任何 JavaScript 引擎中執行 (即使未經最佳化),但 WebAssembly 需要所有瀏覽器供應商同意的新功能。WebAssembly 於 2015 年宣布推出,並於 2017 年 3 月首次發布,並於 2019 年 12 月 5 日成為 W3C 建議。W3C 堅持貫徹所有主要瀏覽器廠商和其他相關人士的參與。自 2017 年起,瀏覽器支援功能已全面開放。

WebAssembly 有兩種表示法:文字二進位。上方顯示的是文字表示法。

文字表示

文字表示法是以 S 運算式為基礎,通常會使用副檔名 .wat (適用於 WebAsembly text 格式)。如果您真的想要,也可以手動寫。以上述乘法範例為例,如果不再將因數硬式編碼,就能讓程式碼更實用,您可能會理解以下程式碼:

(module
  (func $mul (param $factor1 i32) (param $factor2 i32) (result i32)
    local.get $factor1
    local.get $factor2
    i32.mul)
  (export "mul" (func $mul))
)

二進位表示法

使用 .wasm 副檔名的二進位格式不適合人類使用,更不用說人類創作。您可以使用 wat2wasm 等工具,將上述程式碼轉換為以下二進位表示法。(這些註解通常不是二進位表示法的一部分,而是透過 wat2wasm 工具新增,以使其更易於理解。)

0000000: 0061 736d                             ; WASM_BINARY_MAGIC
0000004: 0100 0000                             ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01                                    ; section code
0000009: 00                                    ; section size (guess)
000000a: 01                                    ; num types
; func type 0
000000b: 60                                    ; func
000000c: 02                                    ; num params
000000d: 7f                                    ; i32
000000e: 7f                                    ; i32
000000f: 01                                    ; num results
0000010: 7f                                    ; i32
0000009: 07                                    ; FIXUP section size
; section "Function" (3)
0000011: 03                                    ; section code
0000012: 00                                    ; section size (guess)
0000013: 01                                    ; num functions
0000014: 00                                    ; function 0 signature index
0000012: 02                                    ; FIXUP section size
; section "Export" (7)
0000015: 07                                    ; section code
0000016: 00                                    ; section size (guess)
0000017: 01                                    ; num exports
0000018: 03                                    ; string length
0000019: 6d75 6c                          mul  ; export name
000001c: 00                                    ; export kind
000001d: 00                                    ; export func index
0000016: 07                                    ; FIXUP section size
; section "Code" (10)
000001e: 0a                                    ; section code
000001f: 00                                    ; section size (guess)
0000020: 01                                    ; num functions
; function body 0
0000021: 00                                    ; func body size (guess)
0000022: 00                                    ; local decl count
0000023: 20                                    ; local.get
0000024: 00                                    ; local index
0000025: 20                                    ; local.get
0000026: 01                                    ; local index
0000027: 6c                                    ; i32.mul
0000028: 0b                                    ; end
0000021: 07                                    ; FIXUP func body size
000001f: 09                                    ; FIXUP section size
; section "name"
0000029: 00                                    ; section code
000002a: 00                                    ; section size (guess)
000002b: 04                                    ; string length
000002c: 6e61 6d65                       name  ; custom section name
0000030: 01                                    ; name subsection type
0000031: 00                                    ; subsection size (guess)
0000032: 01                                    ; num names
0000033: 00                                    ; elem index
0000034: 03                                    ; string length
0000035: 6d75 6c                          mul  ; elem name 0
0000031: 06                                    ; FIXUP subsection size
0000038: 02                                    ; local name type
0000039: 00                                    ; subsection size (guess)
000003a: 01                                    ; num functions
000003b: 00                                    ; function index
000003c: 02                                    ; num locals
000003d: 00                                    ; local index
000003e: 07                                    ; string length
000003f: 6661 6374 6f72 31            factor1  ; local name 0
0000046: 01                                    ; local index
0000047: 07                                    ; string length
0000048: 6661 6374 6f72 32            factor2  ; local name 1
0000039: 15                                    ; FIXUP subsection size
000002a: 24                                    ; FIXUP section size

編譯為 WebAssembly

如您所見,.wat.wasm 都不是很人性化。這時就需要 Emscripten 這類編譯器的協助。您可以使用這項工具,從 C 和 C++ 等較高層級語言進行編譯。此外,還有許多其他編譯器可用於 Rust 等其他語言。請考慮以下 C 程式碼:

#include <stdio.h>

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

通常,您會使用編譯器 gcc 編譯此 C 程式。

$ gcc hello.c -o hello

安裝 Emscripten 後,即可使用 emcc 指令和幾乎相同的引數,將其編譯為 WebAssembly:

$ emcc hello.c -o hello.html

這會建立 hello.wasm 檔案和 HTML 包裝函式檔案 hello.html。從網路伺服器提供檔案 hello.html 時,開發人員工具控制台會顯示 "Hello World"

您也可以不使用 HTML 包裝函式,直接編譯為 WebAssembly:

$ emcc hello.c -o hello.js

這會建立 hello.wasm 檔案,但這次是 hello.js 檔案,而不是 HTML 包裝函式。如要進行測試,請使用 Node.js 之類的工具執行產生的 JavaScript 檔案 hello.js

$ node hello.js
Hello World

瞭解詳情

這篇文章簡要介紹 WebAssembly,但這只是冰山一角。如要進一步瞭解 WebAssembly,請參閱 MDN 上的 WebAssembly 說明文件,並參閱 Emscripten 說明文件。老實說,使用 WebAssembly 時,你可能會覺得有點像是畫貓頭鷹迷因的方法,尤其是當網頁開發人員熟悉 HTML、CSS 和 JavaScript 時,不一定會熟悉 C 等要經過編譯的語言。幸好,有許多管道 (例如 StackOverflow 的 webassembly 標記) 可讓您向專家尋求協助,只要您禮貌提問,專家通常都很樂意提供協助。

特別銘謝

本文由 Jakob KummerowDerek SchuffRachel Andrew 共同審查。