有時您可能想使用僅提供 C 或 C++ 程式碼的程式庫。一般來說,這時你就會放棄。現在,我們有了 Emscripten 和 WebAssembly (或 Wasm),情況已有所不同!
工具鍊
我給自己設定的目標是,找出如何將現有的 C 程式碼編譯成 Wasm。LLVM 的 Wasm 後端有一些雜訊,所以我開始深入探討。雖然您可以透過這種方式編譯簡單的程式,但只要您想使用 C 的標準程式庫,甚至編譯多個檔案,就可能會遇到問題。這讓我學到重要的一課:
Emscripten 過去是 C-to-asm.js 編譯器,但現在已發展成熟,可指定 Wasm,並正在切換至官方 LLVM 後端。Emscripten 也提供與 Wasm 相容的 C 標準程式庫實作。使用 Emscripten。它包含許多隱藏工作,例如模擬檔案系統、提供記憶體管理、使用 WebGL 包裝 OpenGL 等,這些都是您在開發時不需要親自處理的事項。
雖然這聽起來可能需要擔心膨脹問題 (我確實擔心),但 Emscripten 編譯器會移除所有不需要的內容。在我的實驗中,產生的 Wasm 模組大小適中,適合其包含的邏輯,而 Emscripten 和 WebAssembly 團隊正努力在未來縮小這些模組。
您可以按照網站上的操作說明或使用 Homebrew 取得 Emscripten。如果您和我一樣喜歡 Dockerized 指令,且不想在系統上安裝任何項目,只要使用 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 編碼器編譯為 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(),可提供包含 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-land 緩衝區,並釋放在此過程中分配的所有 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);

結論
在瀏覽器中執行 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 模組沒有可用的記憶體。如要為 Wasm 模組提供任何內容,請使用 imports 物件,也就是 instantiateStreaming 函式的第二個參數。Wasm 模組可以存取匯入物件中的所有內容,但無法存取其他內容。按照慣例,Emscripting 編譯的模組會預期載入 JavaScript 環境時,會發生以下幾件事:
- 首先是
env.memory。Wasm 模組無法感知外部世界,因此需要取得一些記憶體才能運作。輸入WebAssembly.Memory。代表 (可選擇是否擴充) 線性記憶體。大小參數以「WebAssembly 頁面」為單位,也就是說,上述程式碼會分配 1 頁記憶體,每頁大小為 64 KiB。如果不提供maximum選項,記憶體在理論上會無限成長 (Chrome 目前有 2GB 的硬性限制)。大多數 WebAssembly 模組都不需要設定上限。 env.STACKTOP定義堆疊應從何處開始成長。堆疊用於呼叫函式,以及為區域變數分配記憶體。由於我們在小小的費氏數列程式中,並未執行任何動態記憶體管理,因此可以將整個記憶體當做堆疊使用,也就是STACKTOP = 0。