有時候,您可能會想使用僅以 C 或 C++ 程式碼形式提供的程式庫。一般來說,這時你會放棄。不過,現在我們有 Emscripten 和 WebAssembly (或 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);
結論
讓 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
。