將 C 程式庫編寫至 Wasm

有時候,您可能會想使用僅以 C 或 C++ 程式碼形式提供的程式庫。一般來說,這時你會放棄。不過,現在我們有 EmscriptenWebAssembly (或 Wasm),所以這項問題已解決!

工具鍊

我自行設定了一個目標,就是瞭解如何把一些現有的 C 程式碼編譯成 Wasm。LLVM 的 Wasm 後端出現了一些雜訊,因此我開始深入研究。雖然您可以透過這種方式取得要編譯的簡單程式,但如果您想使用 C 標準程式庫,甚至編譯多個檔案,可能會遇到問題。這帶我學到了 我學到的一大重點

雖然 Emscripten 曾經是 C 到 asm.js 的編譯器,但它已進化為以 Wasm 為目標,並在內部切換至官方 LLVM 後端。Emscripten 也提供與 Wasm 相容的 C 標準程式庫實作。使用 Emscripten。它會執行許多隱藏的工作,模擬檔案系統、提供記憶體管理,並將 OpenGL 與 WebGL 包裝在一起,這些都是您實際上不需要親自開發的東西。

在聽起來看來,您可能得擔心廣告膨脹,但我更擔心的是,Emscripten 編譯器會移除所有不需要的項目。在我的實驗中,產生的 Wasm 模組會根據所含邏輯提供適當大小,而 Emscripten 和 WebAssembly 團隊也正在努力降低日後的規模。

您可以依照該機構的網站或 Homebrew 提供的指示,取得 Emscripten。如果您很喜歡跟我一樣熟悉 Docker 化的指令,並且不想在系統上安裝 WebAssembly,可以改為使用一個維護良好的 Docker 映像檔

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

編譯簡單的程式

以下舉例說明如何在 C 中編寫函式,計算第 nth 個費波那契數:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

如果您也知道 C,函式本身不應過於驚人。即使您不懂 C 語言,但如果懂 JavaScript,也應該能理解這裡發生了什麼事。

emscripten.h 是 Emscripten 提供的標頭檔案。我們只需要這個函式,才能存取 EMSCRIPTEN_KEEPALIVE 巨集,但它提供更多功能。這個巨集會指示編譯器不要移除未使用的函式。如果我們省略該巨集,編譯器會將函式最佳化,因為沒有人會使用該函式。

將所有內容儲存在名為 fib.c 的檔案中。如要將其轉換為 .wasm 檔案,我們需要轉向 Emscripten 的編譯器指令 emcc

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

讓我們來分析這個指令。emcc 是 Emscripten 的編譯器。fib.c 是我們的 C 檔案。到目前為止都很順利。-s WASM=1 會指示 Emscripten 提供 Wasm 檔案,而非 asm.js 檔案。-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' 會指示編譯器讓 JavaScript 檔案保留 cwrap() 函式,這個函式稍後會進一步說明。-O3 會指示編譯器主動進行最佳化。您可以選擇較低的數字來縮短建構時間,但這麼做也會使產生的套件變得較大,因為編譯器可能不會移除未使用的程式碼。

執行指令後,您應該會得到名為 a.out.js 的 JavaScript 檔案和名為 a.out.wasm 的 WebAssembly 檔案。Wasm 檔案 (或「模組」) 包含已編譯的 C 程式碼,因此應相當小。JavaScript 檔案會負責載入及初始化 Wasm 模組,並提供更完善的 API。如有需要,它也會負責設定堆疊、堆積和其他功能,這些功能通常是作業系統在編寫 C 程式碼時提供的。因此 JavaScript 檔案比較大,以 19 KB (約 5 KB gzip ) 為重。

執行簡單的工作

如要載入及執行模組,最簡單的方法是使用產生的 JavaScript 檔案。載入該檔案後,就能擁有 Module 全域。使用 cwrap 建立 JavaScript 原生函式,該函式能將參數轉換為適合 C 語言的內容,並叫用已包裝的函式。cwrap 依序使用函式名稱、傳回類型和引數類型做為引數:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

如果您執行此程式碼,控制台中應該會顯示「144」,也就是第 12 個費波那契數。

聖杯:編譯 C 程式庫

到目前為止,我們編寫的 C 程式碼都是以 Wasm 為考量。不過,WebAssembly 的核心用途是採用現有的 C 程式庫生態系統,並允許開發人員在網路上使用這些程式庫。這些程式庫通常依附於 C 的標準程式庫、作業系統、檔案系統和其他項目。Emscripten 可提供大部分的上述功能,但仍有一些限制

讓我們回到原本的目標:編譯 WebP 的編碼器至 WasmWebP 編解碼的原始碼是以 C 語言編寫,可在 GitHub 上取得,並提供一些詳細的 API 說明文件。這很好的起點。

    $ git clone https://github.com/webmproject/libwebp

首先,請編寫名為 webp.c 的 C 檔案,嘗試從 encode.h 向 JavaScript 公開 WebPGetEncoderVersion()

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

這個簡單的程式可用來測試能否編譯 libwebp 的原始碼,因為我們不需任何參數或複雜的資料結構就能叫用這個函式。

如要編譯這個程式,我們需要使用 -I 標記,告訴編譯器 libwebp 的標頭檔案所在位置,並傳遞所需的所有 libwebp C 檔案。坦白說,我只把我「所有」找到的 C 檔案提供給它,然後依賴編譯器來刪除不必要的項目。似乎運作得很好!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

接下來,我們只需要一些 HTML 和 JavaScript 就能載入全新的模組:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

並在 output 中看到修正版本號碼:

開發人員工具控制台的螢幕截圖,顯示正確的版本號碼。

將圖片從 JavaScript 取得並放入 Wasm

取得編碼器的版本號碼固然很棒,但編碼實際圖片會更令人驚豔,對吧?那麼,我們就這麼做吧。

第一個問題是:如何將圖片推向瓦斯姆陸地? 查看 libwebp 的編碼 API 時,它預期的是 RGB、RGBA、BGR 或 BGRA 的位元組陣列。幸運的是,Canvas API 有 getImageData(),可提供 Uint8ClampedArray,其中包含 RGBA 中的圖片資料:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

現在,可以「只」將資料從 JavaScript 落地複製到 Wasm 陸地。為此,我們需要公開另外兩個函式。其中一個會為 Wasm 土地中的圖片配置記憶體,再釋出記憶體再次釋放:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer 會為 RGBA 圖片分配緩衝區,因此每個像素為 4 個位元組。malloc() 傳回的指標是該緩衝區第一個記憶體儲存格的位址。當指標傳回 JavaScript 時,系統會將其視為數字。使用 cwrap 向 JavaScript 公開函式後,我們可以使用該數字來尋找緩衝區的開頭,並複製圖片資料。

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

最終樂章:將圖片編碼

圖片現已可在 Wasm 環境中使用。是時候呼叫 WebP 編碼器來執行工作了!根據 WebP 說明文件WebPEncodeRGBA 似乎是最佳選擇。這個函式會取得輸入圖片和其尺寸的指標,以及介於 0 和 100 之間的品質選項。此工具也會為我們配置輸出緩衝區,且在處理 WebP 圖片後,我們便需要釋出 WebPFree()

編碼作業的結果是輸出緩衝區和長度。由於 C 中的函式無法將陣列設為傳回類型 (除非我們動態配置記憶體),因此我改用靜態全域陣列。我知道 C 並不是簡潔的 C (實際上,它仰賴 Wasm 指標的寬度為 32 位元),但為了保持簡單明瞭,我認為這是相當快速的做法。

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

有了這些元素,我們就可以呼叫編碼函式、擷取指標和圖片大小,將其放入自己的 JavaScript 地帶緩衝區,並釋出過程中分配的所有 Wasm 地帶緩衝區。

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

視圖片大小而定,您可能會遇到 Wasm 無法擴充足夠的記憶體來容納輸入和輸出圖片的錯誤:

顯示錯誤的開發人員工具控制台螢幕截圖。

幸好,錯誤訊息提供這個問題的解決方法!我們只需要將 -s ALLOW_MEMORY_GROWTH=1 新增至編譯指令。

這樣就大功告成囉!我們編譯了一個 WebP 編碼器,並將 JPEG 圖片轉碼為 WebP。如要證明結果緩衝區有效,我們可以將結果緩衝區轉換為 blob,並用於 <img> 元素:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

敬請欣賞全新 WebP 圖片的榮耀

開發人員工具的網路面板和產生的圖像。

結論

讓 C 程式庫在瀏覽器中運作並非易事,但一旦瞭解整體程序和資料流程運作方式,這項工作就會變得簡單,而且結果也可能令人驚艷。

WebAssembly 為網路帶來了許多新的處理、號碼和遊戲體驗。提醒您,Wasm 並非應適用於所有項目的銀級項目,但當您遇到其中一個瓶頸時,Wasm 是非常有幫助的工具。

額外內容:要簡單實現這一點

如要嘗試避免產生的 JavaScript 檔案,或許可以這麼做。讓我們回到 Fibonacci 的範例如要自行載入及執行,我們可以執行以下操作:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

由 Emscripten 建立的 WebAssembly 模組沒有記憶體可以使用,除非您提供記憶體。使用 imports 物件 (instantiateStreaming 函式的第二個參數) 提供 任何項目,提供 Wasm 模組。Wasm 模組可以存取匯入物件中的所有內容,但無法存取匯入物件以外的所有項目。按照慣例,由 Emscripting 編譯的模組預期載入 JavaScript 環境中必須注意幾個項目:

  • 首先,有 env.memory。Wasm 模組無法瞭解外部世界,因此需要取得一些記憶體才能運作。輸入 WebAssembly.Memory。代表一片線性記憶體 (可視需要擴充)。大小參數以「WebAssembly 頁面單位」表示,也就是說,上述程式碼會配置 1 個記憶體頁面,每個頁面的大小為 64 KiB。如果沒有提供 maximum 選項,則記憶體理論上就沒有限制 (Chrome 目前設有 2 GB 的硬性限制)。大多數 WebAssembly 模組不需要設定上限。
  • env.STACKTOP 會定義堆疊的開始位置。堆疊是用於進行函式呼叫,以及為本機變數分配記憶體。由於我們在 Fibonacci 程式中並未執行任何動態記憶體管理作業,因此可以使用整個記憶體做為堆疊,因此可以使用 STACKTOP = 0