將 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 中編寫用於計算第 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"]' 會告知編譯器讓 JavaScript 檔案中的 cwrap() 函式可供使用,我們稍後會進一步說明這個函式。-O3 會指示編譯器進行積極最佳化。您可以選擇較低的數字來縮短建構時間,但這樣產生的套件也會變大,因為編譯器可能不會移除未使用的程式碼。

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

執行簡單的程式

如要載入及執行模組,最簡單的方法是使用產生的 JavaScript 檔案。載入該檔案後,您就可以使用 Module global。使用 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 檔案,將 encode.h 中的 WebPGetEncoderVersion() 公開至 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,會預期 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 (事實上,這會依賴 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 模組可以存取匯入物件內的所有內容,但無法存取匯入物件以外的任何內容。根據慣例,由 Emscripting 編譯的模組會預期載入 JavaScript 環境中的幾項內容:

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