C言語へのFFIを含むRustコードをWASMにする(CMakeを添えて)
動機
PlantUMLをwasm化するためにGraphvizへの依存をどうしたものか考えていました。すべてRustで書き直せればそれがいちばん手堅いのですが、Graphvizのソースコードは中々に大きく、それをRustで書き直すのは現実的ではありません。そこで考えたのが、RustからFFIでGrapvizのC++コードを呼ぶようにして、それをwasm化すればいいじゃないかというアイデアです。
探してみると以下のnpmパッケージでGraphvizをEmscriptenでwasm化していました。
これを応用すればやりたいことができるのではないかと考え、まずは最小限のCMakeを使ったFFIをwasm化することを試みることにしました。
結論
最終的には以下にまとめてあります。
.
├── Cargo.lock
├── Cargo.toml
├── ffi-example ...Cのコードを使ったwasmライブラリ
│ ├── Cargo.toml
│ ├── index.html
│ └── src
│ ├── lib.rs
│ ├── main.rs
│ └── sequence.rs
├── ffi-example-sys ...Cのコードとそれに対するバインディング
│ ├── build.rs
│ ├── Cargo.toml
│ └── src
│ ├── c ...CのコードとそれをビルドするためのCMakeLists.txt
│ │ ├── CMakeLists.txt
│ │ ├── all.h
│ │ ├── array
│ │ │ ├── array.c
│ │ │ ├── array.h
│ │ │ └── CMakeLists.txt
│ │ └── string
│ │ ├── CMakeLists.txt
│ │ ├── string.c
│ │ └── string.h
│ └── lib.rs ...Cの関数へのバインディング
├── LICENSE
├── README.md
VSCodeとRemote Container拡張を使うと簡単に再現できると思います。
ポイントは以下です。
- CMakeクレートを使ってbuild.rsを記述する
- Bindgenクレートによるバインドコードの自動生成は諦めた
- extern "C" する関数名と、wasm-bindgenでexportする関数名の重複を避ける
- Cのコードがstdioなどに依存しているとEmscriptenがenvをwasmにimportする前提になるので避ける
なおこの検証コードの元ネタは @eduidl さんのコードをもとにしています。
解説
各段階でとにかくハマり散らしたので色々残しておきます。
CMakeクレート
RustからFFI for Cをする記事はいくつか見つかったのですがいずれもccクレートによるものでした。
CMakeクレートについてはあまりサンプルが無く公式のドキュメントを参考にしながら進めました。
CMakeにCMAKE_TOOLCHAIN_FILEを指定する
これはおそらく必須ではありません。現行のClang/LLVMはEmscriptenに頼らずともwasmへのコンパイルが可能です。本稿では一旦hpcc-js-wasmのビルド手順を模してCのコードはEmscriptenでビルドする考えにしてあるので、defineを使ってCMAKE_TOOLCHAIN_FILEを指定しています。
※環境変数EMSDKはRemote Container環境のDockerfileで指定しています。CMakeで生成されたStatic Library(*.a)は明示的に指定する
CMakeによって作られたStatic Libraryは以下の場所に作られます。
target/wasm32-unknown-unknown/release/build/ffi-example-sys-xxxxx/
├── out
│ ├── build
│ ├── libarray.a <--
│ └── libstring.a <--
├── output
├── root-output
└── stderr
libarray.a, libstring.aの2つのライブラリとリンクが必要ですが、これら一つ一つを明示しなければなりません。
println!で標準出力すると言うのはなんとも不思議ですがbuild.rsの標準出力をcargo(かrustc)が拾っているのでしょうと理解しておきます。bindgenによるCコードとのバインドが生成できない
これは原因がわかりませんでした。
もともと下記のようにbindgenクレートでCのヘッダを読んでバインディングコードを生成する事になっていました。
これがネイティブビルドだと生成されたtarget/release/build/ffi-example-sys-xxxx/out/build/bindings.rs
に下記のコードが含まれていましたが、
extern "C" {
pub fn sum(arr: *const ::std::os::raw::c_int, size: usize) -> ::std::os::raw::c_int;
}
extern "C" {
pub fn get_sequential_array(size: usize) -> *mut ::std::os::raw::c_int;
}
extern "C" {
pub fn free_array(arr: *mut ::std::os::raw::c_int);
}
extern "C" {
pub fn print_str(str_: *const ::std::os::raw::c_char);
}
extern "C" {
pub fn hello() -> *const ::std::os::raw::c_char;
}
--target wasm32-unknown-unknown
でビルドすると何故か含まれないのです。
この件についてはbindgenで生成するのは諦めて、上記のコードを手書きしました。
target_archがwasm32のときだけの条件付きにする
本来的にはwasm-pack build
でビルド(要するに --target=wasm32-unknown-unknown
としてビルド)することが目的なので気にしなくてもいいのですが、ToolChainとしてEmscriptenを指定したままネイティブビルド(--target=x86_64-*** )するとリンカーのエラーが出ます。
= note: /usr/bin/ld: /workspaces/rust-ffi-with-cmake-example/target/debug/deps/libffi_example_sys-7403dda2c2055465.rlib: error adding symbols: file format not recognized
collect2: error: ld returned 1 exit status
これ回避するためにはターゲットがwasm32のときだけという条件をつけます。
以下読まなくても良いのですが、これが起きる原因が自分的には面白かったので記載しておきます。
rlibファイルはARで固められたアーカイブ
ffi_example ---> ffi_example_sys という依存関係なので、依存として`libffi_example_sys-xxxx.rlibが先に作られます。rlibはRustのstaticリンクライブラリで、位置付けはC言語における*.aと同じなのですが、フォーマットとしても*.aと全く同じでした。
$ ar t /workspaces/rust-ffi-with-cmake-example/target/debug/deps/libffi_example_sys-7403dda2c2055465.rlib
ffi_example_sys-7403dda2c2055465.3nw68xamg571f9f4.rcgu.o
string.c.o
array.c.o
lib.rmeta
Emscriptenがビルドする*.oはすでにwasm形式
上記のldがfile format not recognized
を吐く原因は端的に言って「ELF形式とWASM形式が混ざっているのでリンクできないッピ」ということでした。
$ ar x libffi_example_sys-7403dda2c2055465.rlib
$ file string.o
string.c.o: WebAssembly (wasm) binary module version 0x1 (MVP)
$ file /workspaces/rust-ffi-with-cmake-example/target/release/deps/ffi_example.5dzumq9wbk47eeqp.rcgu.o
/workspaces/rust-ffi-with-cmake-example/target/release/deps/ffi_example.5dzumq9wbk47eeqp.rcgu.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
wasmでエクスポートする関数の名前に気をつける
幾多の苦難を乗り越えてようやくwasm-pack build
を使って--target wasm32-unknown-unknown
なビルドが通るようになりました…が、今度はwasm-bindgenがwasmのパースに失敗するというエラーが発生しました。
$ wasm-pack build --target web ./ffi-example
[INFO]: Checking for the Wasm target...
:
[INFO]: Installing wasm-bindgen...
error: failed getting Wasm module for '/workspaces/rust-ffi-with-cmake-example/target/wasm32-unknown-unknown/release/ffi_example.wasm'
Caused by:
0: failed to parse input as wasm
1: failed to parse code section
2: type mismatch: expected i32 but nothing on stack (at offset 1212)
Error: Running the wasm-bindgen CLI
Caused by: failed to execute `wasm-bindgen`: exited with exit status: 1
full command: "/home/vscode/.cache/.wasm-pack/wasm-bindgen-e0094da74c1e9817/wasm-bindgen" "/workspaces/rust-ffi-with-cmake-example/target/wasm32-unknown-unknown/release/ffi_example.wasm" "--out-dir" "./ffi-example/pkg" "--typescript" "--target" "web"
これは正直「詰んだ…」と思いましたがwasm-bindgenのissueで投稿したところ「Cでエクスポートされている関数名とWASMでエクスポートされている関数名が同じなが原因だ」と教えてもらえました。
マジ感謝です。関数名を変更してこの問題をクリアしました。
とりあえずenvはなかったことにする
ついにwasm-pack build
でwasmとつなぎのJSを生成することに成功しました。
ffi-example/pkg
├── ffi_example_bg.wasm <--
├── ffi_example_bg.wasm.d.ts
├── ffi_example.d.ts
├── ffi_example.js <--
└── package.json
しかしながらこれをhtmlから読み込んで実行しようとすると...
Failed to resolve module specifier "env". Relative references must start with either "/", "./", or "../".
これはwasm-packで生成されたJavaScriptを見るとわかりますが、1行目のenvがどこにも無いためです。
import * as __wbg_star0 from 'env';
let wasm;
let WASM_VECTOR_LEN = 0;
:
wasmのwatに逆アセンブルして確認してみましょう。
(module
(type $t0 (func (param i32 i32) (result i32)))
:
(type $t13 (func))
(import "env" "printf" (func $env.printf (type $t0)))
(func $f1 (type $t3) (param $p0 i32) (result i32)
printf関数を外部から注入するためのもののようです。
これに関してはピンと来ました。
現状のWebAssemblyに(主にセキュリティ上の理由で敢えて定義していないものも含めて)機能が不足していて、本当にあらゆるCやC++のコードをコンパイルするには至っていないという背景もあります。EmscriptenはそれらをJavaScriptで実装して、ビルドしたWebAssemblyのモジュールにimportさせることでなんとか多くのCやC++のコードを動かせているのです。具体的には、例えば次の機能がWebAssemblyだけでは定義されていません:
ファイルやソケットへの入出力処理
マルチスレッド機構
C++の例外ハンドラ
この記事で述べられているようにEmscliptenが標準出力をどうにかするための機構で、printfを使ったCのソースをToolChainとしてEmscriptenを使用しているのでこうなっていると考えられます。
私の本来の目的はGraphvizをWASM化することで、標準入出力をどうにかするのはスコープに入っていないのであっさりとprintfのことは忘れることにしました。つまりprint_str関数の中身を何もしないことにしました。
これにて無事、envをインポートする箇所はなくなりました。
結び
実に仕事の合間の内職で2週間近くハマり続けた問題がようやく解決したので忘れないうちに記事にしました。
Rustは言語仕様もビルドシステムも洗練されたものですが、如何せん新参なのでまだC/C++の資産の力を借りねばならないシーンが多いと思います。Rustで完結しないケースではCMakeやリンカーなどを含めて知る必要がありこの辺をおざなりにしてきた自分にとってはなかなかハードでしたがいい勉強になりました。
Discussion