將 C 程式庫編寫至 Wasm

有時候,您可能會想使用僅以 C 或 C++ 程式碼形式提供的程式庫。依照慣例,大家都可以放棄。不過,現在已有 EmscriptenWebAssembly (或 Wasm),因此現在已不再適用!

工具鍊

我設想著如何將現有的 C 程式碼編譯至 Waasm。LLVM 的 Wasm 後端似乎有些雜訊,所以我開始深入研究了。雖然您可以透過這種方式讓簡易程式編譯,但第二點要使用 C 的標準程式庫或甚至編譯多個檔案,但可能會發生問題。這引導了我學到的重要課程:

雖然 Emscripten 使用是 C-to-asm.js 編譯器,但目前已逐漸成熟地以 Wasm 為目標,且正在在內部切換至官方 LLVM 後端。Emscripten 也提供與 Wasm 相容的 C 標準程式庫實作。使用 Emscripten。這項工具會執行許多隱藏工作,也就是模擬檔案系統、提供記憶體管理,以及使用 WebGL 納入 OpenGL,您就有很多實際功能,其實不是親自體驗開發的功能。

雖然聽起來這聽起來可能有您什麼吃力,但我擔心的是, 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 個 fibonacci 編號:

    #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 個 Fibonacci 編號。

聖地牙哥:編譯 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 檔案,能夠用掉不需要的全部項目。似乎可以完美運作!

    $ 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(),可提供內含 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-land 緩衝區。

    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 模組沒有記憶體可用,除非您提供記憶體。提供具有「任何項目」的 Wasm 模組的方法,就是使用 imports 物件 (instantiateStreaming 函式的第二個參數)。Wasm 模組可以存取匯入物件內部的所有內容,但無法存取其他內容。按照慣例,使用 Emscript 編譯的模組會預期載入 JavaScript 環境有一些事情:

  • 首先是 env.memory。Wasm 模組對外界無從得知,因此需要記憶體,因此需要一些記憶體才能運作。輸入 WebAssembly.Memory。代表一個 (可選用) 的線性記憶體片段。大小參數位於「以 WebAssembly 頁面為單位」,這表示上述程式碼會分配 1 頁的記憶體,而每頁大小為 64 KiB。如果沒有提供 maximum 選項,記憶體在理論上仍不受限 (Chrome 目前設有 2 GB 的硬性限制)。大多數 WebAssembly 模組都不需要設定最大值。
  • env.STACKTOP 會定義堆疊的起始位置。需要堆疊才能發出函式呼叫,並為本機變數分配記憶體。由於我們沒有在 Firebonacci 這個小型程式中執行任何動態記憶體管理管理,所以可以將整個記憶體做為堆疊使用,因此 STACKTOP = 0