1601 字
8 分鐘
【WebAssembly】初探 WebAssembly——調用用 C 寫的程式

  聽過 WebAssembly 這個技術好一陣子了,不過現在瀏覽器裡的原生 HTML 與 JavaScript 已經非常強大,甚至於我之前遇過一個圖片處理需求、在選技術時,發現其實靠 canvas 元素就能達成,所以那時就沒再深入研究;最近剛好又遇上一個碰得著邊的需求,趁此機會來好好認識一下。

WebAssembly 是什麼?#

  是否有想過,把用其他程式語言寫的程式跑在瀏覽器裡?
  待會再討論為什麼要這麼做,總之 WebAssembly 技術就是為此而生——只要把其他語言寫出的程式編譯為 WebAssembly 標準格式,就能在瀏覽器執行環境裡透過 JavaScript 調用其他語言寫出的 function!

什麼情境適合用 WebAssembly?#

  如前言,現在的 JavaScript 大概已經能做到大部分的事情,但它身為直譯式語言、高階語言,效能方面仍有其天生條件的侷限性;有些情境用 C、Rust 等更接近底層、能精準控制記憶體的語言來實作,可以讓效能再進一步提升。

  這個差距尤其體現在需要逐個 byte 處理的情境,較常遇到的就是圖像處理、更進一步的則是網頁遊戲的畫面渲染,像是一些跑在瀏覽器上、用 Unity 引擎寫的遊戲,其「Unity 遊戲引擎」的部分就是大量用到 WebAssembly 技術而得以在瀏覽器裡流暢地運行。

C 編譯為 WebAssembly 的編譯器:Emscripten#

  稍早提到的「WebAssembly 標準格式」是副檔名為 wasm 的檔案,裡面是 WASM bytecode,因此無論原本用什麼程式語言,只要能編譯為 WASM bytecode、瀏覽器就都能執行。

  要想把 C 編譯為這種檔案靠的不是 MinGW gcc、而是需要另一套專門的編譯器,目前最主流的是 Emscripten。

在 Windows 安裝 Emscripten#

  官方的安裝說明文件在此:Download and install — Emscripten documentation,這安裝方法很 developer——從 GitHub clone 腳本庫下來執行 XD
  另外官方也有提供 docker image 的使用方式,熟悉容器化操作的話可能還比較輕鬆簡潔。

  一般安裝流程有些眉角要注意,尤其是在 Windows 環境——比如開發者有為 Windows 的 cmd 與 PowerShell 分別寫了 bat 與 ps1 腳本檔,因此要根據 terminal 執行不同腳本,以下我用的是 cmd。
  以下以 <path> 代指實際執行 git clone 的路徑。

  1. 確保已安裝 Python

    Terminal window
    python -v
  2. 在本機指定目錄下安裝所需工具

    Terminal window
    git clone https://github.com/emscripten-core/emsdk.git
    cd emsdk
    git pull
    REM 安裝所需工具
    emsdk.bat install latest
    • 所需工具都是裝在 <path>\emsdk 目錄下、不影響全域環境;未來如果想刪除這個 emsdk,也是很簡單地直接把整個 emsdk 目錄刪掉即可。

把 c 檔編譯為 wasm 檔#

  1. 在要執行編譯的 terminal,先執行以下指令、以把所需 terminal 變數設好
    Terminal window
    <path>\emsdk\emsdk.bat activate latest
  2. 把 c 檔編譯為 wasm 檔
    Terminal window
    emcc demo.c -o demo.js -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 -s "EXPORTED_RUNTIME_METHODS=['HEAPU8']" -s ALLOW_MEMORY_GROWTH=1
    • -s MODULARIZE=1 -s EXPORT_ES6=1:編譯為 ES6 module 的 js 檔
    • -s "EXPORTED_RUNTIME_METHODS=['HEAPU8']":匯出 HEAPU8 物件,型別為 Uint8Array、對應到 Linear Memory
    • -s ALLOW_MEMORY_GROWTH=1:允許在 Heap 記憶體空間不足時自動擴張
  3. 這樣會產生兩個檔案:「demo.wasm」和「demo.js」,其中 JS 檔已經幫我們包裝好 export default 一個 async function,在要使用的 JS import 後呼叫、其回傳值是 Emscripten Module 物件,裡面包含了我們編譯時指定要匯出的 function 與物件。

從 C 把 function 匯出到 JS#

  在 C,要引入 #include <emscripten.h> 以讓 Emscripten 能夠編譯;在要匯出的 function 上加上一行 EMSCRIPTEN_KEEPALIVE,編譯時就不用顯式指定每個要匯出的 function 名稱。編譯就可以在 JS 調用,function 名稱會是原本 C 的名稱前面再加上一個底線。

  假設我們在 C 寫了一個 function int add(int a, int b),以下是一個最基礎的調用範例:

import createModule from './image-process.js';
createModule().then(module => {
console.log(module._add(1, 2)); // 輸出:3
});

WebAssembly 的 Linear Memory#

  WebAssembly 是把其他語言寫出的 function 匯出到 JS、可以從 JS 調用,如果參數簡單、像是 add(int a, int b),那就直接傳參數呼叫即可;但如果是要做圖片處理、參數是 array,C function 沒有辦法直接接 array、只能接指標,那怎麼辦?

  瀏覽器執行環境會給 WebAssembly 一塊以 byte 為單位的線性連續記憶體空間,在 C 的情境下就是 heap,可以像平常一樣用 mallocfree;而在這塊記憶體裡 malloc 取得的指標實際上都是整數,代表相對於這塊記憶體空間開頭的偏移量。

  如果照前一步加了那些參數給 Emscripten 編譯,編譯出的 js 檔內會給我們一個型別為 Uint8Array 的 array HEAPU8,讓 JS 可以透過此 array 存取這塊記憶體空間;又因為它是「線性」、Uint8Array 的單位是一個 byte 就和這塊記憶體的單位相同,malloc 取得的是「偏移量」,那麼在 HEAPU8 裡用偏移量當作 array 的 index 來操作就可以了!

  順帶一提,其實不只 HEAPU8,根據編譯參數、還可以匯出型別為 Int32ArrayHEAP32 等對應這塊記憶體不同型別的 array,不過如果 array 的單位不是一個 byte、就不能這麼簡單的直接拿偏移量當 array index,需要經過一些計算,如 Int32Array 的一個元素是 4 bytes 的數值、所以偏移量除以 4 才是對應的 array index。

WebAssembly 的 JS object 與 C struct#

  很可惜,這兩者無法直接轉換,只能自己在 JS 序列化、透過 HEAP32 這類 array 放到正確的偏移量,再傳指標給 C function;而且 C 的 struct 還要特別注意 struct 的 padding 機制。

  題外話,C++ 和 Rust 就有對應的轉換工具如 Embind 了。

後記閒聊#

  寫到舉運用 WebAssembly 實現的例子時,第一個想到的是「Photopea」、網頁版且免費的 PhotoShop 替代,想說它如此強大的功能應該就是靠這個實現的吧;為求正確去搜尋了一下,結果發現了作者在 Reddit 上的回覆、本體竟然是純 JavaScript 😮

參考資料#