將 C 程式庫編寫至 Wasm

有時您會想使用僅以 C 或 C++ 程式碼形式提供的程式庫。一般來說,您會在這裡放棄。但現在我們有了 EmscriptenWebAssembly (或 Wasm),情況已大不相同!

工具鍊

我的目標是找出如何將現有的 C 程式碼編譯為 Wasm。LLVM 的 Wasm 後端有一些雜訊,因此我開始深入研究。雖然您可以透過這種方式編譯簡單的程式,但只要想使用 C 的標準程式庫,甚至是編譯多個檔案,就可能會遇到問題。這讓我學到一個重要教訓:

Emscripten 過去是 C 語言到 asm.js 的編譯器,但現在已發展成熟,可做為 Wasm 的目標,並逐步切換至內部官方 LLVM 後端。Emscripten 也提供 C 標準程式庫的 Wasm 相容實作。使用 Emscripten。它包含許多隱藏工作、模擬檔案系統、提供記憶體管理功能,並以 WebGL 包裝 OpenGL,您不必親自體驗這些功能。

這聽起來好像會造成膨脹,我當然也很擔心,但 Emscripten 編譯器會移除所有不需要的項目。在我的實驗中,產生的 Wasm 模組大小適中,符合所含邏輯的需求,而 Emscripten 和 WebAssembly 團隊也正努力讓模組在未來變得更小。

如要取得 Emscripten,請按照網站上的操作說明或使用 Homebrew。如果您和我一樣喜歡 Docker 化指令,而且不想在系統上安裝任何東西,只想試用 WebAssembly,可以使用維護良好的 Docker 映像檔

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

編譯簡單項目

我們來看看這個幾乎是標準的範例,也就是在 C 中編寫函式,計算第 n費波那契數:

    #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"]' 會告知編譯器將 cwrap() 函式保留在 JavaScript 檔案中,稍後會詳細說明這個函式。-O3 會指示編譯器積極進行最佳化。您可以選擇較小的數字來縮短建構時間,但這也會導致編譯器無法移除未使用的程式碼,進而使產生的套件變大。

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

執行簡單的項目

載入及執行模組最簡單的方式,就是使用產生的 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 編碼器編譯為 Wasm。WebP 編解碼器的來源是以 C 語言編寫,可在 GitHub 上取得,並提供一些詳盡的 API 說明文件。這是很不錯的起點。

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

先從簡單的開始,讓我們嘗試透過編寫名為 webp.c 的 C 檔案,將 WebPGetEncoderVersion()encode.h 公開至 JavaScript:

    #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>

我們會在輸出中看到修正後的版本號碼:

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

將 JavaScript 中的圖片匯入 Wasm

取得編碼器的版本號碼固然很棒,但編碼實際圖片會更令人印象深刻,對吧?那就開始吧。

我們必須回答的第一個問題是:如何將圖片移到 Wasm 領域?查看 libwebp 的編碼 API,該 API 預期會收到 RGB、RGBA、BGR 或 BGRA 的位元組陣列。幸好 Canvas API 有 getImageData(),可提供包含 RGBA 圖片資料的 Uint8ClampedArray

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 語言 (事實上,這取決於 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 檔案,或許可以這麼做。讓我們回到費波那契數列的例子。如要自行載入及執行,請按照下列步驟操作:

<!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 模組可以存取 imports 物件內的所有項目,但無法存取該物件外的任何項目。按照慣例,Emscripten 編譯的模組會預期載入 JavaScript 環境提供下列項目:

  • 首先是env.memory。Wasm 模組可說是無法感知外部世界,因此需要取得一些記憶體才能運作。輸入「WebAssembly.Memory」。代表 (可選擇性成長的) 一段線性記憶體。大小參數是以「WebAssembly 頁面單位」表示,也就是說,上述程式碼會分配 1 頁的記憶體,每頁大小為 64 KiB。如果不提供 maximum 選項,記憶體理論上會無限成長 (Chrome 目前的硬性限制為 2 GB)。大多數 WebAssembly 模組都不需要設定上限。
  • env.STACKTOP 定義堆疊應開始成長的位置。堆疊是進行函式呼叫及為區域變數分配記憶體時的必要條件。由於我們的小型費波那契程式不會進行任何動態記憶體管理,因此可以將整個記憶體當做堆疊使用,也就是 STACKTOP = 0