有時您會想使用僅以 C 或 C++ 程式碼形式提供的程式庫。一般來說,您會在這裡放棄。但現在我們有了 Emscripten 和 WebAssembly (或 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);
結論
在瀏覽器中讓 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
。