ReferenceFinder 當然最大的架構特點就是它的核心是用 C++ 寫成的。確實,如同我在第一篇提到過的,我在這之前也有過一些把 C++ 程式編譯成 WebAssembly 並整合到 web app 上的經驗,然而是直到 ReferenceFinder 專案、我才真的非常深入地把整個 codebase 與開發工具鏈都做了一次大整頓。本篇就先來從最重要的 Emscripten 講起。

Emscripten 無疑是用來把 C/C++ 寫成的程式編譯成 WebAssembly 的最成熟方案。它除了實作 C/C++ 標準庫的 WASM 版本之外,也提供了與 JavaScript 互動的能力和虛擬檔案系統等等。除非一個 C/C++ 程式牽涉到一些針對特定硬體和平台撰寫的低階功能,不然基本上都不會需要太多功夫就可以用 Emscripten 來將它移植到網頁上來執行。不過,所謂「不用太多功夫」當然是指相對於「整個重新針對網頁寫一遍」而言啦;單看它本身的話,它的學問也實在是還滿不小的。底下就大概分享一些使用心得。

初步編譯

關於 Emscripten 的安裝,直接照著官方文件去做即可;雖然它的安裝方式不算是特別親民,但是基本上也沒什麼坑。安裝好了之後,確定例如 emcc 等指令有在路徑當中,我們就可以開始來試著初步把手邊的專案編譯成 WASM 了。

很多 C/C++ 的專案都會使用 Make 1 或者 CMake 來進行建置,於是這部份 Emscripten 有提供對應的無腦工具 emmakeemcmake。這兩個指令做的事情其實就是設置一些環境魔法,來讓 Make 或 CMake 在執行到特定東西的時候自動切換成 Emscripten 提供的對等工具(例如把 gcc 換成 emcc,諸如此類的)。於是,假設一個 C/C++ 專案的建置步驟是先執行 cmake 然後再 make,那麼對應過來就是先執行 emcmake cmake 然後接 emmake make;運氣好的話,這樣執行就有可能直接搞定——

——或者至少理論上是如此啦,但我就目前為止的經驗來說,運氣那麼好的例子似乎不是很多(Nlopt 是其中的一個)。大部分的專案的 makefile 檔案常常都用了太多複雜的咒語,使得 emmake 也無法搞定。這種時候,通常就要花點時間去研究專案的 makefile 到底玩了什麼把戲、見招拆招了。大部分的情況中,其實我們並不需要去修改 makefile 本身,而是只要在執行的時候從命令列去重新定義腳本裡面會用到的一些變數就可以了。例如,makefile 裡面可能會定義變數 CC = gcc 然後在執行編譯的時候呼叫 $(CC),如此一來我們只要在執行 make 的時候打 make CC=emcc 就可以把變數換掉了;而編譯指定的一堆旗標(flag)、以及建置目標的設定,常常也可以比照辦理。

如果這樣也還是不行,那可能就要更深入改寫 makefile 的內容、或者甚至自己寫一套了。以 ReferenceFinder 來說,確實它原本的專案裡面針對 Linux 平台的部份有一個 makefile,但是裡面是把包含 GUI 的部份都建置下去了,我則是只需要建置運算核心的部份就可以了,所以我就只能自己寫一個 makefile 去編譯它了。

選項參數

在撰寫建置腳本的時候,最關鍵的當然就是傳入給 emcc(或 em++)的選項參數了。這個部份的文件是分散在兩個地方的:一個是官方網站上的文件,另一個是針對 -sXXX 類型的建置選項參數說明的 settings.js 原始檔之註解。我自己在 ReferenceFinder 當中用到的如下:

# 指定 C++ 版本
-std=c++17

# 設定建置成最高度優化的 WASM
-O3

# 上面這兩個選項是編譯器跟鏈結器都要用的,底下這些則是只有鏈結器使用

# 預設的檔案系統只有純記憶體的,要採用其它實作的話就要引入這個
-lidbfs.js

# 設定初始的記憶體大小
-sINITIAL_MEMORY=50MB

# 承上,允許記憶體自動增大配置
-sALLOW_MEMORY_GROWTH

# 承上,設定記憶體大小上限為 4G(這也是 WASM32 的上限,而 WASM64 則尚未普及)
-sMAXIMUM_MEMORY=4GB

# 啟用 Emscripten 的 Asyncify,把非同步功能包裝成同步程式碼給 C/C++ 使用(見後文)
-sASYNCIFY=1

# 設定目標支援的 Safari 版本(否則 Emscripten 預設會用比較新的 JavaScript 語法)
-sMIN_SAFARI_VERSION=120000

# 後面不指定值表示 0,也就是說停用一些執行階段檢查的功能,以增進效能
-sASSERTIONS

# 把建置出來的 JavaScript 輸出成 ES6 module 的形式,方便打包
-sEXPORT_ES6=1

# 指定運作的環境為 web worker,這能讓生成的 JavaScript 省下一些判別環境的程式碼
-sENVIRONMENT=worker

# 指定輸出的檔名為「ref.js」,Emscripten 會根據副檔名來產生 JS 與 WASM 檔案。
# 亦可指定 .html 的副檔名來把全套可執行的網頁範本生成出來。
-o ref.js

Asyncify 與 JavaScript 互動

假如今天我們的 C/C++ 專案是一個純文字的 console 程式,然後上面的選項當中把 -sENVIRONMENT 的部份拿掉(或是設為 node),那麼編譯出來的 JS + WASM 檔案組合就是真的可以在 Node.js 當中執行的 CLI 程式,而且也一樣可以接收使用者的文字輸入。但是之所以可以這麼順利,一個重點在於,在 CLI 環境之中、使用者輸入文字的操作(例如透過 std::cin)是完全同步的。而當我們回到網頁上頭來的時候,就有一點麻煩了:網頁上幾乎所有的使用者操作都是非同步的、沒辦法跟 std::cin 的同步執行方式串接起來,只有一個例外是網頁上頭古老的 prompt() 方法——就只有它是同步地讓使用者輸入文字的,所以 Emscripten 預設的 stdin 實作用的也是它。但當然,prompt() 的彈跳視窗又醜、使用體驗又差、而且也沒辦法用在 web worker 裡面(如果間接請主執行緒代為執行,那整個架構一樣是非同步的)。

於是,為了要能夠讓同步的 C/C++ 程式碼能夠在網頁上做到「執行期間可以繼續接受使用者的輸入」,就要啟用 Emscripten 的 Asyncify 功能了。它會把 WASM 編譯成一種可以被「暫停」和「繼續」執行的形式,於是暫停的期間我們就可以進行非同步的操作。Asyncify 同時會自動幫我們管理這整個暫停與繼續的機制,使得我們可以在 C/C++ 當中把程式碼寫得好像是同步程式碼一樣。

那具體來說要怎麼讓 C++ 接收一個來自 JS 的資料,例如浮點數呢?首先我們需要宣告像這樣的工具函數:

#pragma once
#include <emscripten.h>

EM_ASYNC_JS(const double, emscripten_utils_get_double, (), {
    return await Module.get();
});

其中 EM_ASYNC_JS 巨集後面大括號寫的東西是 JavaScript 程式碼喔!這是 Emscripten 提供的一個語法糖,可以讓我們宣告一個 const double emscripten_utils_get_double() 函數(但是其內容卻是非同步的 JS 程式碼),然後在編譯的時候它會把這個編到產出的 JS 檔案之中。這一段 JS 程式碼所在的 scope 是在 Emscripten 產出的模組內部,所以裡面可以取用一些模組內部宣告的東西,例如 Module 即為模組物件本身。換句話說,這個函數會去呼叫非同步的 Module.get() 方法(等一下會看到)並把結果傳回,讓 C++ 取用。於是,以後就直接在 C++ 裡面呼叫 emscripten_utils_get_double 函數就可以「同步」地從 JS 那邊拿到下一個數值了。

最後只剩一個重點,就是 Module.get() 在哪裡宣告?這個跟啟動 Emscripten 的部份有關,但簡單來說,剛才我們設置了 -sEXPORT_ES6=1 之後,Emscripten 編譯出來的 JS 檔案會是一個 ES 模組,於是我們可以在別的 JS 檔案中匯入:

import moduleFactory from "...(產出的 JS 路徑)...";

// 執行這個 factory 就會啟動 Emscripten 程式,而參數傳入的物件就是 Module 的原型
moduleFactory({
    // 常常我們會自己覆寫這兩個方法,以決定 stdout 跟 stderr 的輸出具體要怎麼處理
    // (看是要在瀏覽器 console 中印出、或是去 parse 其內容等等)
    print(text) { ... },
    printErr(err) { ... },

    // 我們可以在這邊宣告 get 方法並且自己看要怎麼實作都行
    async get() { ... },
});

以 ReferenceFinder 為例,我的 get() 方法是這樣實作的:

// 如果當前的階段沒有任何佇列中的值,那就用這個 callback 來通知 C++ 有新的數值進來了
let valueResolve: ((v: number) => void) | null = null;
// 否則就用這個佇列來存放新的數值
const queue: number[] = [];

// 呼叫這個同步函數即可把新的數值排程傳遞給 C++
export function put(data: number) {
    if(valueResolve) {
        valueResolve(data); // 執行 callback
        valueResolve = null;
    } else {
        queue.push(data);
    }
}

moduleFactory({
    async get() {
        if(queue.length > 0) { // 如果已經有佇列的值,就依序直接傳回
            return queue.shift();
        }
        // 否則,用一個 Promise 來等候其它 JS 呼叫上面的 put() 函數,
        // 直到有新的值進來為止
        return await new Promise(resolve => {
            return valueResolve = resolve;
        });
    },
});

虛擬檔案系統

Emscripten 在預設的編譯選項當會實作一個記憶體當中的虛擬檔案系統(MEMFS),以便如果 C/C++ 程式碼涉及到讀寫檔案的時候有東西可操作。這個檔案系統當然也能從 JS 端來存取,於是例如若 C++ 程式碼最後存檔了一張圖片,JS 端就可以把這張圖的二進位資料抓出來並顯示在畫面上。

不過既然是記憶體當中的檔案系統,意思就是下次開啟網頁的時候檔案系統就又是全空的,我們沒有辦法在裡面保存持久性資料。而 ReferenceFinder 就有這方面的需求:它產生出資料庫之後,會希望能把它持久存檔起來,以便下次能夠快速載入資料庫、而不必重新生成。這部份 Emscripten 有提供另外一個檔案系統的實作 IDBFS,顧名思義就是使用 IndexedDB 實作出來的檔案系統。要使用它的話,必須在編譯選項當中加入前述的 -lidbfs.js 旗標,然後我們可以在 C++ 裡面宣告這樣的函數來使用它:

EM_ASYNC_JS(const bool, emscripten_utils_mount_fs, (), {
    try {
        FS.mkdir("/data");
        FS.mount(IDBFS, {}, "/data");
        const err = await new Promise(resolve => FS.syncfs(true, resolve));
        return !err;
    } catch(e) {
        return false;
    }
});

EM_ASYNC_JS(void, emscripten_utils_sync_fs, (), {
    await new Promise(resolve => FS.syncfs(false, resolve));
});

其中 emscripten_utils_mount_fs 會初始化 IDBFS、並且把之前儲存的檔案同步到記憶體中,這個在程式啟動的時候呼叫一次就可以了;而 emscripten_utils_sync_fs 則是在我們用 C++ 做完存檔操作後、把記憶體的狀態同步回到 IndexedDB 當中。

VS Code 整合

為了有良好的開發體驗,當我們在使用 VS Code 的時候、當然會希望其中的 IntelliSense 認得 Emscripten 定義的標頭檔等等的東西。這部份可以透過在專案底下加入 .vscode/c_cpp_properties.json 設定檔來達成:

{
    // https://code.visualstudio.com/docs/cpp/c-cpp-properties-schema-reference
    "configurations": [
        {
            "name": "Emscripten",
            "includePath": [
                "${workspaceFolder}/src/...", // 專案的 .cpp 和 .h 檔案所在路徑
                "..." // 其它額外有用到的程式庫的路徑
            ],
            "compilerPath": "...", // emcc 的完整路徑
            "cStandard": "c17",
            "cppStandard": "c++17", // 或是你的專案實際使用的版本
            "intelliSenseMode": "linux-clang-x86"
        }
    ],
    "version": 4,
    "enableConfigurationSquiggles": true
}

其中 emcc 的路徑那邊,Windows 環境下要寫出 emcc.bat 所在的完整路徑(會在 emsdk 安裝路徑下的 upstream/emscripten 之中),而 Linux 系統則是去掉 .bat 即可。至於 intelliSenseMode 指定為 linux-clang-x86 這是我自己試出來的,這最符合 Emscripten 的實際應用。

Rsbuild 整合

當使用了 -sEXPORT_ES6=1 的時候,Emscripten 跟 Rsbuild 之間的整合是完全無腦的,因為 Rsbuild 有內建 WASM 的支援;只要有做好匯入,它就會直接正確地把 Emscripten 產生的 JS 與 WASM 檔案打包進來,什麼事情都不用做。


這篇就跟大家分享到這邊,下次有機會再分享我在其它也有用到 Emscripten 的專案當中獲得的心得。

更新:Emscripten 系列的下一篇請參見 BPS 開發分享之 23:Emscripten


  1. Windows 的使用者可以安裝 GNU make。 


分享此頁至:
最後修改日期: 2025/04/25

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。