WASMのバイナリフォーマットは実行用の情報しかないのでコンパイルの中間ファイル・リンク用には別途独自のファイル形式が必要かと思ってたんだけど、どうやら追加の情報を加えることで対応してるらしい。 そこで使い方を探った。
前口上
自作のCコンパイラからWASMを出力する際には、与えられたソース以外にもライブラリもソースコードの状態で読み込んで全体をコンパイルして直接バイナリ出力していた。 これを分割コンパイルやリンクできるようにすることを考えたが、関数はインポートやエクスポートから繋げられるかもしれないが、グローバル変数を線形メモリ上のどこに配置してるかの情報は失われてしまっているので難しいと思っていた。
ただEmscriptenとかClangとかではできるので、重い腰を上げてググったらドキュメントにちゃんと書かれていた。
既存のコンパイラから生成する
オブジェクトファイルを作ってみる
仕様はドキュメントに書かれていても、実際に生成されるファイル自体を調べた方が理解が進むので使い方をメモ。 C言語やRustなど、LLVMを利用しているコンパイラではWASM形式出力もサポートされている:
- C:
clang --target=wasm32 -c foo.c
またはemcc -c foo.c
clang
はそのままでは標準ライブラリを使えない(--sysroot
を指定する必要がある)ので、その場合はemcc
を使う
- Rust:
rustc --target wasm32-wasi --emit=obj foo.rs
生成された内容はバイナリダンプしたり、wabtのobjdumpで確認できる:wasm-objdump -x foo.o
。
再配置情報を見るには -r -d
オプションで、逆アセンブルしたコードの間に挟まれて見ることができる:
$ emcc -c -O2 examples/hello.c |
linking
,reloc.CODE
,reloc.DATA
の3つのカスタムセクションが作られる:linking
: シンボルテーブルなどreloc.CODE
: コードセクションの再配置情報reloc.DATA
: データセクションの再配置情報
リンクしてみる
自分でオブジェクトファイルを生成した場合に内容が正しいかどうか試せるように、リンク方法も調べる:
emcc -o a.wasm hello.o
wasm-ld -o a.wasm hello.o
emccも内部ではwasm-ldを使用してるようで、Emscriptenが用意したライブラリなどを用いずに素の状態で確認したい場合にはwasm-ldを使うとよい。
自作コンパイラを対応させる
概要がわかったところで、自作のコンパイラから出力できるようにすることにした。 対応させるには結構変更が必要になる。 以下対処が必要な項目:
シンボルテーブルと再配置情報
シンボルテーブルの各シンボルは、種類、フラグが共通で、それに加えて種類によって追加情報が規定されている:
- 関数やグローバルの場合はインデックスに加えて、このコンパイルユニットで定義している場合(またはフラグの
WASM_SYM_EXPLICIT_NAME
が有効な場合)名前を出力する- 未定義の場合インポートセクションで名前を定義しているので、省略できる
- データの場合は名前に加えて、定義されている場合のみインデックス、オフセット、サイズを出力する
フラグは、このコンパイルユニットで定義されてない(UNDEFINED
)、外部に不可視か(VISIBILITY_HIDDEN
)、ローカルか(LOCAL
)など(不可視とローカルの違いがよくわからず…)。
「インデックス」というのは、関数セクションorグローバルセクションorデータセクションの何番目か、という順番を指す値になる。
再配置情報は「コードまたはデータ中の何バイト目からどのシンボルに対応する値に置き換える」という情報になる。 どのシンボルか、というのはシンボルテーブル中のインデックスで指定するので、関数もグローバルもデータも混ぜた通しの番号なので注意が必要。
線形メモリをインポート
実行ファイルの生成ではメモリセクションに定義したが、オブジェクト形式ではenv.__linear_memory
という名前でインポートしてやる。
再配置の値を埋め込める余地を作る
WASMではグローバル変数や関数呼出などどれもアドレスじゃなくて番号でアクセスするという命令体系で、 数値はLEB128という可変長でエンコードされている。 これが再配置によって番号がずれて拡げる必要が出たらどうするのか? アドレスや相対オフセットの命令はないからずらすことはできるかもしれないが面倒。
でどうするかと思ったら可変長でも最短のバイト列ではなくて上限を格納できる分で表現する。
例えば0
という値はLEB128で0x00
だけで表せるが、32ビット格納できる5バイト0x80, 0x80, 0x80, 0x80, 0x00
とも表せて、再配置用にはこの形で出力しておく。
バイトコードが可変長の意味とは…と思わないこともないが影響受けるのはパース時だけだろうし特に問題はなさげ (気になって夜も眠れないという人にはリンカーのオプションで切り詰めることもできるぽい)。
グローバル変数をデータセクションに配置する
WASMにはグローバルセクションでi32
などの数値型が扱えるのでそうしてたが、emcc
で確認すると数値型でもデータセクションに配置されるようだった。
なぜかと考えると、C言語でグローバル変数を別のソースからextern
して&
でアドレス参照された場合はポインタ経由でアクセスできるようにするためにグローバルセクションには配置できなくなるが、定義側のコンパイル時にはそれを知るすべはないのでデータセクションにしてるんだと思う。
- 静的変数でアドレス参照されてなければグローバルセクションに配置することもできるはずだけど、そうはしてない模様
- グローバルセクションには再配置情報が指定できないということもあるかも
「スタックポインタ」はグローバルセクションに
WASMではローカル変数の配列や構造体などは線形メモリ上に配置することになるが、命令セットとしては「スタックポインタ」というものがないので、自前で管理する必要がある。
emcc
でコンパイル結果を見るとインポートセクションでグローバルのenv.__stack_pointer
という名前でインポートして、
各関数の頭で必要なサイズ分マイナスしてその領域を利用し、関数を抜ける前に戻してやることで対応する。
- 内部的にはこのスタックポインタもC言語上のグローバル変数として扱っているため、 グローバル変数でもグローバルセクションかデータセクションの2通りがありうることになってしまった
初期化子がない変数も出力する
グローバルまたは静的変数で初期化子がない変数はデータのBSS領域に配置されるものとして、実行ファイル生成時にはファイルサイズを節約するため出力せずに済ませられた。 がオブジェクト形式では、データとして出力しないとシンボルテーブルだけでは(仮の)開始アドレスやサイズが指定できないので、出力する必要がある。
データにもサイズ自体の指定はなくて、実際のデータによって決定されるため、0埋めしたデータを出力する。 無駄ではあるがリンク時に判定されるのか、取り除かれる模様。
関数やグローバル変数の出力順に注意する
実行形式でもそうだけど、オブジェクト形式でも関数や変数の出力順に従って割り振られる番号で指定する。 なのでコード生成や再配置情報生成時とリンク情報の並び順が合うようにする必要がある:
- 関数はインポートしたもの→ファイル内で定義したもの、の順
- グローバル変数をすべてデータセクションに出力するのであればデータはインポートというものがないのでどういう順序にしてもいいのかもしれないが、 グローバルセクションに配置するよう切り替えるオプションを残しておきたかったのと、 初期化子がない変数は後方にまとめて配置したいこともあり、それらの条件によって並び順を決める必要がある
関数ポインタ
関数ポインタを使って間接呼び出しを使用する場合、WASMではテーブルを利用する。
オブジェクト形式の場合、テーブルセクションに定義する代わりにenv.__indirect_function_table
という名前のテーブルをインポートしてやる。
間接呼び出しで利用する関数はelems
セクションに出力して、その何番目かというのをWASMのCALL_INDIRECT
命令で指定するが、それを再配置情報にも追加する。
CALL_INDIRECT
は呼び出す関数が「どういう型か?」というのも指定する必要があるので、その型情報も再配置対象になる(R_WASM_TYPE_INDEX_LEB
)。
main関数のリネーム
いわゆる実行開始はmain
関数だけど、実際にはライブラリ内の_start
があれこれ初期化や後始末をしている。
そしてC言語上ではmain
はコマンドライン引数を受け取るには2引数だが、コマンドライン引数を処理しない場合には0引数ということも可能、という自由度がある。
WASMでも2引数だと想定してmain
関数を呼び出せば問題なく動くし、0引数だったとしても積んだ引数が関数呼出では取り除かれないが呼び出し元の関数終了時点で処理系が処理スタックに残る値はクリアするっぽいので動作としては問題ない。
が、LLVMでは念の為かわからないがリネームされる:
- 0個の場合:
__main_void
,__original_main
(どちらも元の関数を指す) - 2個の場合:
__main_argc_argv
wasi-libcの_start
関数から__main_void
が呼び出され、
その中でコマンドライン引数を取得して__main_argc_argv
に渡されるということで動作している
(ユーザが0引数のmain
を定義した場合はそれが__main_void
として呼び出される)。
なのでこれに合うようにリネームしてやる。
setjmp, longjmp
WASMで大域ジャンプを実現するには例外を使う。 例外はタグを定義して、その番号を指定する。 こいつも再配置対象なので考慮する。
- ドキュメントではタグじゃなくて「イベント」となっている
- emccでは
-fwasm-exceptions
オプションを渡すことでコンパイルできるが、実のところオプションを渡さなくてもできる、謎
結果
そんなこんなでなんとか自作のコンパイラからWASMオブジェクトファイルを出力して、emcc
でリンクして実行できるようになった。
対応にはかなり苦労した。
出力順の値とズレると動かないし、再配置のオフセット位置がおかしいともちろんダメだし。
進め方として行き当たりばったりというか、先に全体を計画してみたいなやり方が苦手なためなのか余計に時間がかかってしまった。
にしてもWASMのバイナリフォーマットに無理やりリンクに必要な情報を埋め込んでしまうなんて、執念だね…。
参考・リンク
- 該当のマージコミット
- WebAssembly/tool-conventions: WebAssembly Object File Linking
- User entrypoint
main
のこととかが書いてある
- User entrypoint
- WebAssembly lld port — lld 19.0.0git documentation これがwasm-ld?
- Adventures in WebAssembly object files and linking – Mike’s corner of the web 独自コンパイラからオブジェクトファイルを出力するようにした際の話
「グローバルセクションのリロケーションはできない」と書かれている、がそんなことはない?できない、コードとデータのみ
- WasmLinux: “普通の” Hello, Worldに対応する
main
関数をリネームする件 - WebAssemblyLowerEmscriptenEHSjLj.cpp File Reference
setjmp
,longjmp
に使われるタグ名は__c_longjmp
過去記事: