この記事では、C++のプロジェクト(RDKit)のTypstバインディングを作成するにあたって筆者がハマったポイントとその回避策を共有したいと思います。
TL;DR
ハマりポイント1: C++のグローバル/静的オブジェクトのコンストラクタが呼ばれないために、エラーとなる。
回避策: ラッパー関数の先頭で__wasm_call_ctorsを明示的に呼び出し、デバッグモードでビルドをする。
ハマりポイント2: Typstホストからの入力を受け取る際のデータ型の不一致、wasm_minimal_protocol_write_args_to_bufferに渡すポインタのアドレスの誤り
回避策: 文字列のnull終端、memcpyで指定するメモリアドレスが一つでもずれるとエラーになるため、気を付ける
TypstのWebAssemblyプラグインシステムの概要
TypstのWebAssemblyプラグインが独自のプロトコル(wasm-minimal-protocol) (https://github.com/typst-community/wasm-minimal-protocol) を要求します。
- プラグイン関数はi32引数(UTF-8でエンコードされた文字列の長さの値)のみを取り、i32を1つ返す
- 引数はホスト側が用意する
wasm_minimal_protocol_write_args_to_bufferでバッファに書き込まれる - 結果は
wasm_minimal_protocol_send_result_to_hostでホストに渡す - WASI由来のimport(ファイルI/O、標準出力など)は一切許可されない
Clangのwasm32-unknown-unknownターゲットを使えばWebAssemblyにコンパイルできますが、一般的なC++プロジェクトはそのままwasm32-unknown-unknown向けにビルドするのは難しいので、Emscriptenやclang (wasi-sdk)を用いてwasm32-wasip1にコンパイルしてから、wasi-stubを用いてwasm32-unknown-unknownに変換するという作業を行います。
ハマりポイント
Clangやemscriptenの--no-entryでビルドしたwasmはC++のネイティブの共有ライブラリ(.so/.dll/.dylib)とは異なり、関数を呼び出したときに
C++のグローバルコンストラクタ(型テーブルを初期化する処理)が呼ばれません。そのため、関数を直接呼び出すと、error: plugin panicked: wasm `unreachable` instruction executedとエラーを吐きます。
通常のWebAssembly実行環境(ブラウザのJSグルーコードなど)では、モジュールをインスタンス化した直後に__wasm_call_ctorsというエクスポート関数が自動的に(あるいはJS側の初期化コードから)呼ばれ、C++のグローバル/静的オブジェクトのコンストラクタ( _initialize function )が実行されます。しかしTypstのプラグインランタイムは、こちらが定義したプロトコル準拠の関数を直接呼び出すだけで、__wasm_call_ctorsを呼ぶ責務は負ってくれません。結果として、グローバルなクラスインスタンスやstd::stringの静的変数などを使っているC++コードは、初期化されないまま使われて未定義動作を起こします。
手順
1. C++コードをextern "C"のTypstへexportする関数で包む
まず、既存のC++ライブラリの入出力を、Typstのプロトコルが要求する「バイト列を受け取ってバイト列を返す」形に変換するラッパー層を用意します。C++の型システムをそのまま外に出さず、C ABIのフラットな関数として公開するのがポイントです。
#define PROTOCOL_FUNCTION __attribute__((import_module("typst_env"))) extern "C"
// Typst側から呼ばれるエントリポイント
EMSCRIPTEN_KEEPALIVE
PROTOCOL_FUNCTION int32_t process(int32_t arg_len);
2. Exportする関数内で__wasm_call_ctorsを明示的に呼ぶ
通常なら自動で呼ばれるはずの静的コンストラクタ初期化処理を、自前で呼び出す必要があります。__wasm_call_ctorsはリンカが生成するシンボルで、外部から直接呼べる形では宣言されていないため、unsafe(C++的にはextern宣言+関数ポインタ経由の呼び出し)として扱います。
#define PROTOCOL_FUNCTION __attribute__((import_module("typst_env"))) extern "C"
#define GLOBAL_CONSTRUCTOR __attribute__((import_module("typst_env"))) extern "C"
GLOBAL_CONSTRUCTOR void __wasm_call_ctors(void);
static bool ctors_called = false;
EMSCRIPTEN_KEEPALIVE
PROTOCOL_FUNCTION int32_t process(int32_t arg_len) {
if (!ctors_called) {
__wasm_call_ctors(); // グローバル/静的オブジェクトの初期化を保証する
ctors_called = true;
}
// ここから先で既存のC++コード(静的オブジェクトに依存する処理)を呼び出す
...
}
呼び出しは一度だけで十分です。Typstプラグイン関数は同じインスタンス内で複数回呼ばれる可能性があるため、ctors_calledのようなフラグで二重初期化を防ぎます。
3. wasm-minimal-protocolのimport関数を実装する
最後に、Typstランタイムが提供する2つのホスト関数をimportし、プロトコルに従って引数の受け取りと結果の送信を行います。
#define PROTOCOL_FUNCTION __attribute__((import_module("typst_env"))) extern "C"
#define GLOBAL_CONSTRUCTOR __attribute__((import_module("typst_env"))) extern "C"
PROTOCOL_FUNCTION void wasm_minimal_protocol_write_args_to_buffer(uint8_t *ptr);
PROTOCOL_FUNCTION void wasm_minimal_protocol_send_result_to_host(const uint8_t *ptr, size_t len);
GLOBAL_CONSTRUCTOR void __wasm_call_ctors(void);
EMSCRIPTEN_KEEPALIVE
PROTOCOL_FUNCTION int32_t process(int32_t arg_len) {
if (!ctors_called) {
__wasm_call_ctors();
ctors_called = true;
}
std::vector<uint8_t> buf(arg_len);
wasm_minimal_protocol_write_args_to_buffer(buf.data());
// 既存のC++ロジックを呼び出し、結果をoutに格納する
std::vector<uint8_t> out = existing_cpp_logic(buf);
wasm_minimal_protocol_send_result_to_host(out.data(), out.size());
return 0; // 成功時は0
}
4. デバッグシンボル付きでビルドする
__wasm_call_ctors関数はリリースビルドすると (func 3375)のように識別子になり、コードの側からunsafeで呼び出すことができなくなります。そのため、デバッグモードでビルドする必要があります。
Emscriptenでコンパイルする場合
emcc -g -O0 --no-entry \
-sFILESYSTEM=0 -sASSERTIONS=0 \
-sEXPORT_KEEPALIVE=1 -Wall \
-Wno-logical-op-parentheses \
-sWARN_ON_UNDEFINED_SYMBOLS=1 \
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sSTANDALONE_WASM -DCOMPILE_ANSI_ONLY \
wrapper.cpp -I"install/include" \
-L"install/lib" -lexisting_lib -o plugin.wasm
Clang (wasm32-wasi)でコンパイルする場合
clang++ \
-target wasm32-wasi \
--sysroot=<path/to/sysroot> -nodefaultlibs
-fno-exceptions \
-g \
-O0 \
-Wl,--no-entry \
-Wl,--allow-undefined \
-I"install/include" \
-L"install/lib"
-lexisting_lib
-o plugin.wasm \
wrapper.cpp
ポイントは次の3つです。
-
-gでデバッグシンボルを残す(最終的なリリース時はサイズ削減のためwasm-opt -O3 --strip-debugなどで除去する) - -fno-exceptionでビルドすることで、webassemblyに例外処理をさせる
- -sASSERTION=0でビルドする (でないとエラーを吐く)
5. wasi-stubを用いてスタブに置き換える
wasm32-wasip1でビルドしたwasmは、Typstのランタイムに存在しないWASI由来のimportがあります。それらのimportの関数を、wasi-stubを使用して関数を簡単な整数を返す関数へとスタブ化します。
wasi-stub -r 0 plugin.wasm
このときに、デフォルトのオプション (--return-value 76)よりも、(--return-value 0)とするのがおすすめです。
wasm2wat plugin.wasm | grep importで確認し、WASI準拠でないimport関数が残っていた場合( issue )、wasi-stub --stub-module envを追加で実行する必要があります。(通常はない)
別の方法: Clangのwasm32-unknown-unknownターゲットの場合
まだ試していませんが、ビルドスクリプトは以下になるだろうと思います。
clang++ \
-target wasm32-unknown-unknown
-nostdlib
-fno-exceptions \
-g \
-O0 \
-Wl,--no-entry \
-Wl,--allow-undefined \
-I"install/include" \
-L"install/lib"
-lexisting_lib
-o plugin.wasm \
wrapper.cpp
この場合、wasi-stubは必要ありません。
まとめ
- グローバル/静的オブジェクトの初期化処理(
__wasm_call_ctors)はTypstランタイムからは自動的に呼ばれない - ラッパー関数の先頭で
__wasm_call_ctorsをunsafeで呼び出し、デバッグモードでビルドすることで、この問題を回避できる
Clangは環境変数の設定などが複雑であるためビルドの設定が難しいです。
自動でビルドオプションが設定されるEmscriptenを推奨します。
参考文献:
「opencascadeをwasm32-unknown-unknownでビルドする技術【RustでCAD】」
https://qiita.com/lzpel/items/84004b96b3096f8cab25
wasm-minimal-protocol example
https://github.com/typst-community/wasm-minimal-protocol/tree/main/examples
Plugin - Typst Documentation
https://typst.app/docs/reference/foundations/plugin/