從 C 到 WebAssembly


WebAssembly?怎麼會來玩這個?就大部份開發者而言,直接玩 WebAssembly 沒什麼太大意義,基本上,未來應該會有更多便利的工具,可以從你熟悉的某語言,編譯至 WebAssembly。

現今常見的例子就是 Go、Kotlin、Rust 等語言,在〈Awesome WebAssembly Languages〉有個清單,則列出了可以編譯為 WebAssembly,或虛擬機可支援 WebAssembly 的語言。

對於前端開發者而言,甚至還有 AssemblyScript,它的語法與 TypeScript 一致,雖然有更嚴格的型態限制,然而對於熟悉 JavaScript 的開發者而言,仍然是較易入門與編寫程式碼,並編譯為 WebAssembly 的語言選項。

會玩 WebAssembly,是因為我土炮過 ToyLang,它直接剖析、運行在 JavaScript 環境中,未來若有機會實作其他語言,會想試試將之編譯為位元碼,我想漸漸熟悉位元碼的編寫邏輯,而 WebAssembly 有文字格式,算是主要運算在堆疊上進行的語言,是個不錯的學習對象。

總之,想玩 WebAssembly,現在已經有許多選項,就連工具鏈也是,可以將 C 編譯為 asm.js,基於 LLVM 的 Emscripten,現在也可以編譯為 WebAssembly 了。

將 C 編譯為 WebAssembly 是最常見的,現在更有些線上網站,可以直接輸入 C,編譯為 WebAssembly 後提供下載,例如 WasmFiddle

從 C 到 WebAssembly

你可以在左上輸入 C 程式碼,按下「Build」之後會編譯為 WebAssembly,左下是對應於 WebAssembly 的文字格式,這也會是後面我想要學習的主要對象,右上是呼叫 WebAssembly 時的 JavaScript API,在第 3 行的部份,呼叫了 C 程式碼定義的 add 函式,可以按下「Run」觀看執行結果。

將 C 編譯為 WebAssembly,會讓人誤以為 WebAssembly 執行是很快速的,這是 WebAssembly 的理想,還不是現況,不管你原始撰寫的語言有多快,最後執行的只是 WebAssembly。

WasmFiddle 右上提供的「JS」窗格可以直接編寫 JavaScript,若想自行編寫網頁,可以在 WasmFiddle 按下「Wasm」下載已經編譯完成的位元碼,預設會是 program.wasm 檔名,然後撰寫一個 HTML:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
    <script>    
    WebAssembly.instantiateStreaming(fetch('program.wasm'))
               .then(prog => {
                   console.log(prog.instance.exports.add(1, 2)); 
               });
    </script>
  </body>
</html>

就目前看到的文件來說,未來的 script 標籤,可以使用 type = "module" 來載入 WebAssembly(因為 WebAssembly 是以模組為單位),就現在來說,最簡單的方式是使用 Fetch API(基本上,要使用 XMLHttpRequest 也可以),接著可以直接將 Fetch 取得的結果,交給 WebAssembly JavaScript API 的 WebAssembly.instantiateStreaming 函式來編譯位元組碼,並使用編譯後的結果建立可執行的實例。

WebAssembly.instantiateStreaming 傳回 Promise,在處理完成後會得到一個物件,其中有 moduleinstance 兩個特性,前者是 WebAssembly.Module 的實例,代表編譯過後的位元組碼,後者是 WebAssembly.Instance 的實例,代表著模組的可執行形式,具體而言就是包含了模組中被匯出、可被呼叫的函式。

WebAssembly.Instanceprototype 並不是 WebAssembly.Module 實例,只是建構 WebAssembly.Instance 實例時,是根據 WebAssembly.Module 實例的資訊。)

在這邊的範例中,主要關心 add 函式,可以透過 WebAssembly.Instance 實例的 exports 取得模組匯出的函式,因此上面的範例,會在主控台中顯示 3 的結果,記得,因為使用 Fetch API 來取得 .wasm 檔案,你必須有個簡單的 HTTP 伺服器,使用瀏覽器連接伺服器上的 HTML 網頁來執行。

WebAssembly 也可以呼叫 JavaScript 環境匯入之函式,例如,可以在 WasmFiddle 中輸入程式碼:

void log(int n);
int add(int n1, int n2) {
  int result = n1 + n2;
  log(result);
  return result;
}

在按下「Build」之後,留意右下,它會產生 WebAssembly 的文字格式:

(module
 (type $FUNCSIG$vi (func (param i32)))
 (import "env" "log" (func $log (param i32)))
 (table 0 anyfunc)
 (memory $0 1)
 (export "memory" (memory $0))
 (export "add" (func $add))
 (func $add (; 1 ;) (param $0 i32) (param $1 i32) (result i32)
  (call $log
   (tee_local $1
    (i32.add
     (get_local $1)
     (get_local $0)
    )
   )
  )
  (get_local $1)
 )
)

目前只需要先關心 import 那行,其中 "env" 表示匯入物件的名稱,"log" 表示匯入之函式名稱,"env" 這名稱其實是可以自訂的,不過因為現在是透過 WasmFiddle,從 C 編譯為 WebAssembly,暫時得使用 "env" 這名稱。

因此在下載 program.wasm 之後,你的 HTML 頁面必須撰寫:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
    <script>
    const importObj = {
        env: {
            log: n => console.log(n)
        }
    };

    WebAssembly.instantiateStreaming(fetch('program.wasm'), importObj)
               .then(prog => {
                   console.log(prog.instance.exports.add(1, 2)); 
               });
    </script>
  </body>
</html>

這麼一來,執行後你的主控台就會顯示兩個 3,一個是在 add 函式中呼叫 log 函式的輸出,一個是 add 函式執行後,傳回給 console.log 的輸出。

從 C 到 WebAssembly 的這個過程,主要是用來瞭解使用 WebAssembly 的基本流程:

  • 使用某語言撰寫程式
  • 編譯為 .wasm 檔案
  • 瀏覽器載入 .wasm 檔案
  • WebAssembly JavaScript API 編譯與實例化
  • 使用 JavaScript 與模組互操作

後續會使用 WebAssembly 文字格式來撰寫程式,然而,使用 C 的這個過程還是有用的,因為有時,可以觀察 C 轉換後的 WebAssembly 文字格式,來瞭解到某個 WebAssembly 指令如何撰寫。

現代瀏覽器如 Chrome、Firefox 已支援 WebAssembly 的除錯,除錯時可轉為 WebAssembly 文字格式,這也是要認識 WebAssembly 文字格式的原因之一,後續的文件中也會談到。