【WASM】オブジェクトフォーマットとその実装方法

2024-02-05

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

$ wasm-objdump -x hello.o

hello.o: file format wasm 0x1

Section Details:

Type[2]:
- type[0] (i32, i32) -> i32
- type[1] (i32) -> i32
Import[2]:
- memory[0] pages: initial=1 <- env.__linear_memory
- func[0] sig=1 <env.puts> <- env.puts
Function[1]:
- func[1] sig=0 <__main_argc_argv>
DataCount:
- data count: 1
Code[1]:
- func[1] size=17 <__main_argc_argv>
Data[1]:
- segment[0] <.rodata..Lstr> memory=0 size=14 - init i32=0
- 0000000: 4865 6c6c 6f2c 2077 6f72 6c64 2100 Hello, world!.
Custom:
- name: "linking"
- symbol table [count=3]
- 0: F <__main_argc_argv> func=1 [ binding=global vis=default ]
- 1: D <.Lstr> segment=0 offset=0 size=14 [ binding=local vis=default ]
- 2: F <env.puts> func=0 [ undefined binding=global vis=default ]
- segment info [count=1]
- 0: .rodata..Lstr p2align=0 [ STRINGS ]
Custom:
- name: "reloc.CODE"
- relocations for section: 4 (Code) [2]
- R_WASM_MEMORY_ADDR_SLEB offset=0x000004(file=0x00005c) symbol=1 <.Lstr>
- R_WASM_FUNCTION_INDEX_LEB offset=0x00000a(file=0x000062) symbol=2 <env.puts>
Custom:
- name: "producers"
Custom:
- name: "target_features"
- [+] mutable-globals
- [+] sign-ext

$ wasm-objdump -r -d hello.o

hello.o: file format wasm 0x1

Code Disassembly:

00005a func[1] <__main_argc_argv>:
00005b: 41 80 80 80 80 00 | i32.const 0
00005c: R_WASM_MEMORY_ADDR_SLEB 1 <.Lstr>
000061: 10 80 80 80 80 00 | call 0 <env.puts>
000062: R_WASM_FUNCTION_INDEX_LEB 2 <env.puts>
000067: 1a | drop
000068: 41 00 | i32.const 0
00006a: 0b | end
  • 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のバイナリフォーマットに無理やりリンクに必要な情報を埋め込んでしまうなんて、執念だね…。

参考・リンク

過去記事: