概要
最近はWebAssembly
に興味があり、勉強していたんだけど仕様を読み始めても頭に入らないのでインタプリタを作ってみることにした。よくわからないものは作ってみるのが一番よい。
まだ残された課題は多いのだけれども、一つ目標にしていた「Goで書いたゲームボーイエミュレータを動かす」を達成できたのでここに書いておく。
うおーついにRustで書いた自作wasm interpreter上でgoで書いた自作gameboy emulatorが動いたー!けど1FPSくらいしかでない... pic.twitter.com/I5B2XL5E0W
— bokuweb (@bokuweb17) 2020年2月24日
こツイートに貼られているのは残念ながら、静止画ではなく、動画でありパフォーマンスが悲しいことになっていることを示している。あまりに遅くてプレイ画面まで到達できない。今後これは改善していきたい。
Goでゲームボーイエミュレータを書いた話は以下の記事を参照のこと。
インタプリタを作る
仕様書を読みながら実装していくのだけど、日本語でさくっと全体を知るのに以下のリポジトリがとても参考になった。
Hello wasm
開発は最小限のバイナリを実行できるようにし、そこに命令を足していく方針で進めた。これにはWebAssembly Studio
がとても役立った
これでEmpty Wat Project
を開くとプロジェクトが作成されいくつかのファイルが生成される。この中のmain.wat
を書き換えて実行したり、バイナリを覗いたりすることができる。*.wat
というのはWebAssembly
のテキストフォーマットでWebAssembly/wabt
で*.wasm
に変換したり、ランタイムの一つであるwasmtime
で実行できたりする。
例えば一番シンプルな42
を返すだけの関数を定義してみるとこうなる。
- main.wat
(module (func $hello (result i32) i32.const 42) (export "hello" (func $hello)) )
呼び出し側のJSは以下のようになる
fetch('../out/main.wasm').then(response => response.arrayBuffer() ).then(bytes => WebAssembly.instantiate(bytes)).then(results => { instance = results.instance; document.getElementById("container").textContent = instance.exports.hello(); }).catch(console.error);
これを実行すると42
が返ってくることがわかる。
インタプリタを作る上で便利なのはBinary Explorer
という機能でプロジェクトの成果物である*.wasm
を解析し表示してくれる。例えば今回のサンプルであれば以下のようなものが表示される。mousehover
で詳細も表示してくれる。
先頭の橙色はマジックナンバー。重要なのは(このサンプルでは)2Byteの赤色の箇所、具体的には01 05
のような箇所だ。これはセクション番号とそのサイズを表している。
セクションの番号と概要は以下のようなになっている
番号 | セクション名 | 概要 |
---|---|---|
0x1 | Type | 関数シグネチャー宣言 |
0x2 | Import | インポート宣言 |
0x3 | Function | 関数宣言 |
0x4 | Table | テーブル宣言 |
0x5 | Memory | メモリー属性 |
0x6 | Global | グローバル宣言 |
0x7 | Export | エクスポート |
0x8 | Start | 開始関数宣言 |
0x9 | Element | 要素セクション |
0xA | Code | 関数実体 |
0xB | Data | データセグメント |
0x00
はカスタムセクションだが今回は不要なため無視するとして、0x01 Typeセクション
、0x03 Functionセクション
、0x07 Exportセクション
、0x0A Codeセクション
が含まれていることがわかる。
ここでは端折って0x07 Exportセクション
、0x0A Codeセクション
について見ていく。
0x07 Exportセクション
Exportは以下のようなデータで表現されており、先頭の0x07
はセクション番号、次の0x09
はセクションのデータサイズを示す。
紫部分の先頭の0x01はExportするエントリ数。今回はhello
という関数のみをExportしているので0x01
。次の0x05
はExport名の長さを示す。
Export名の長さが0x05
であることがわかったので、次の5Byte0x68 0x65 0x6c 0x6c 0x6f
を見るとこれがhello
であることがわかる。
末尾の0x00 0x00
だが、前者はExport種別を表す。今回は関数をExportしているので0x00
だが、MemoryやTableなどもExportできる。
最後尾はインデックスで今回の場合はモジュールが持っている関数一つの中から先頭の関数をExportしているので0x00
になる。
詳細は省いているが、Rust
で書くと以下のようになった。
impl Decoder for ExportSection { type Error = DecodeError; fn decode<R: Read>(reader: &mut R) -> Result<Self, Self::Error> { let count: u32 = VarUint32::decode(reader)?.into(); let mut entries: Vec<ExportEntry> = vec![]; for _ in 0..count { let field_len = VarUint32::decode(reader)?.into(); let name = read_bytes(reader, field_len)?; let name = String::from_utf8(name)?; let kind = read_next(reader)?; let kind = ExternalKind::from_u8(kind)?; let index: u32 = VarUint32::decode(reader)?.into(); entries.push(ExportEntry { name, kind, index }); } Ok(ExportSection { count, entries }) } }
LEB128
順番が前後するが、例えばExport数を表すcount
や最後尾のExportするエントリのインデックスの型はなにかと言うとu8
やu32
ではなくvaruint32
になっている。これはLEB128
という可変長の整数エンコード形式で表現された型で、値の大きさと表現にバイト数が比例する。
具体的にはMSB
がデータの継続を表すフラグとして使用されており、実データ部は8bitのうち7bitになる。つまり100
であれば1byteで表現できるし、255
であれば表現に2byte必要になる。u32
において4byteで表現されていたデータを表現するのに5byte必要になることもあるが、多くの場合で固定で4byte確保するよりバイナリを小さくできる。のだと思う。
もともとはデバッグ用ファイルフォーマットのDWARF
のために設計されたものらしい。知らなかった。
もし、wasm
ファイルをごにょごにょするものを作るなら早い段階でLEB128
を読めるようにする必要がある。
実装は愚直にMSB
をチェックしながら結合していく実装になっている。より効率的な読み方があったら教えて下さい。。。
impl Decoder for VarUint32 { type Error = DecodeError; fn decode<R: io::Read>(reader: &mut R) -> Result<Self, Self::Error> { let mut value = 0; let mut i = 0; loop { let b = u32::from(read_next(reader)?); value |= (b & 0x7f) .checked_shl(i * 7) .ok_or(DecodeError::InvalidVarUint32Error)?; i += 1; if i > 5 { return Err(DecodeError::InvalidVarUint32Error); } if b & 0x80 == 0 { if i == 5 && b >= 0x10 { return Err(DecodeError::InvalidVarUint32Error); } break; } } Ok(VarUint32(value)) } }
Exportはこれで完了で、つまり外部からhello
が呼ばれたら関数インデックス0の関数を実行すればいいということがわかる。
0x0A Codeセクション
じゃあ関数がどのようになっているかとCodeセクション
を見れば良い。
先頭の0x0A 0x06
はExportのときと同様セクション番号とサイズだ。
0x01 0x04 0x00
の0x01
はエントリ数、0x04
はエントリのBodyサイズ、0x00
はローカル変数の数を表す。
0x41 0x2A 0x0B
はいよいよバイトコード部、すなわちi32.const 42
に該当する部分になる。0x41
はi32.const
、0x2A
は42
を表しており、0x0B
はend
を表している。
ちょっと複雑だけれども、やっていることはExportセクション
のデコードと変わらず愚直に読んでいくことになると思う。
impl Decoder for CodeSection { type Error = DecodeError; fn decode<R: Read>(reader: &mut R) -> Result<Self, Self::Error> { let count: u32 = VarUint32::decode(reader)?.into(); let mut bodies: Vec<FunctionBody> = vec![]; for _ in 0..count { let body_size: usize = VarUint32::decode(reader)?.into(); let mut body = Cursor::new(read_bytes(reader, body_size)?); let local_count: u32 = VarUint32::decode(&mut body)?.into(); let mut locals: Vec<LocalEntry> = vec![]; for _ in 0..local_count { let count = VarUint32::decode(&mut body)?.into(); let value_type = ValueType::from_u8(read_next(&mut body)?) .ok_or(DecodeError::InvalidValueTypeError)?; locals.push(LocalEntry { count, value_type }); } let mut code: Vec<u8> = vec![]; body.read_to_end(&mut code)?; code.pop(); bodies.push(FunctionBody { locals, decoded: decode_function_body(&code)?, }) } Ok(CodeSection { count, bodies }) } }
今回の例で取り扱う命令はi32.const
のみだが、他にどれくらいあるかと言うとMVPで以下くらい、これからBulkMemory
とかSIMD
とかの命令に対応していくことになると思う
実行
ここまでで外部からhello
が呼ばれたときに0x41 0x2A 0x0B
(i32.const 42
)を実行すれば、なんとかなりそうなのがわかった。
かなり簡素化して書くと以下のようなイメージ。
// (1) 名前を指定して関数を呼び出す pub fn invoke(&self, name: &str) -> Result<Vec<RuntimeValue>, YawError> { // (2) 名前から関数indexを解決する let index = self.exports.resolve(name)?; // (3) indexからバイトコードを引いてくる let func = self.functions.get_ref(index as usize)?; let mut stack = vec![]; // (4) バイトコードを読んで命令を判別 let opcode = get_op(&func.code); let arg = get_arg(&func.code); match opcode { // (5) 実行 Opcode::I32const => { stack.push(arg); } _ => { ... } } Ok(stack) }
wasm
の実行はスタックマシンとして定義されているので、値用のスタック(この例ではstack
という変数)を用意し、そこに値を出し入れするようにしている。i32.const
はもらった値をスタックに積むだけの命令なのでstack.push(arg)
して終了。stack
に残った値(この場合42
)が返り値となる。
かなり端折ったけど、ミニマムなものはだいたい、これくらいでできると思う。あとは命令やセクションを拡充しながらテストを通していくことになると思う。
テスト
wasm
にはtestsuiteが用意されているのでそれを使用するのがよい。
ここでは以下のようなテストが定義されており、これを実行していくことでテストができる。
(assert_return (invoke "add" (i32.const 1) (i32.const 1)) (i32.const 2))
assert_return
などはテスト用のテキスト表現でwasm
自体のこの、命令が備わっているわけではない。のでテストを実行するのはこのテキストフォーマットもparseして実行してやる必要がある。
そのあたりを自分で実装するのはさすがに嫌だったので今回はwabt
のRustバインディングを使った。これを使うと以下のようにテストが書ける。
let s = String::from_utf8(buf).unwrap(); let mut parser = ScriptParser::from_str(&s).unwrap(); while let Some(Command { kind, .. }) = parser.next().unwrap() { match kind { CommandKind::AssertReturn { action, expected } => { if let Action::Invoke { field, args, module, .. } = action { ... omitted ... let ret = ins.invoke(&field.to_string(), &args)?; match ret[ret.len() - 1] { RuntimeValue::I32(v) => assert_eq!(expected, vec![Value::I32(v)]), _ => { ... omitted ...} } } } _ => {} } }
テストが実行できるようになったらあとはテストが落ちたところを直していけばよい。ただ、このテストを走らすこと自体結構たいへんだったので、もしこの記事を見て試してみる人がいたら序盤からテストの実行について考慮することをおすすめしたい。
ゲームボーイエミュレータ
テストが通るようになり、Go
で書いたゲームボーイエミュレータを動かそうと試みた。方針としてはGo
側には手を加えずブラウザで動くwasm
ファイルをそのまま動かそう、という方針。
これは今からGo
側を変更するのが面倒だったし、手を加えたことで、問題が発生した場合の切り分けをしたくなかったからなんだけど、これはこれであまりいい方針ではなかったような気がしてる。。。
wasm
を吐く場合、たとえばGo
から以下のようにブラウザのwindow
に対して値をセットできる。
js.Global().Get("window").Set("hello", "world")
これを実行するとwindow
に値がセットされていることが確認できる。
console.log(window.hello) // world
wasm
-JS
間は数値でしかやり取りできないのにこのようなことができるのか調べる必要があった。これはグルーコードとして提供されているwasm_exec.js
を見るとだいたい分かる。
JS
側では以下のように値を保持しておき、配列のindexをリニアメモリ経由で指定することで値のやりとりをしているよう。
this._values = [ NaN, 0, null, true, false, global, this ];
js.Global().Get("window").Set("hello", "world")
を例にとると、まずsyscall/js.stringVal
を呼んで"world"
をthis._values
に登録する。
簡素化すると以下のようになっていて、"world"
をthis._values
に登録したあとthis._values
の最後尾のindexをメモリに書き戻しているためGo
側で"world"
がthis._values
格納されたindexを知ることができる。
go: { "syscall/js.stringVal": sp => { storeValue(sp + 24, loadString(sp + 8)); // "world" }, } const storeValue = (addr, v) => { this._values.push(v); // ... ommitted ... mem().setUint32(addr, this._values.length - 1, true); };
次に、リニアメモリに"hello"
を書き込みつつ、先に得られた"world"
格納先のindexとwindow
が格納されたindexをリニアメモリに書き込みyscall/js.valueSet
を呼び出す。
そうするとRefrect.set
によりwindow.hello
に"world"
が代入されるという仕組みだ。
"syscall/js.valueSet": sp => { Reflect.set( loadValue(sp + 8), // window loadString(sp + 16), // "hello" loadValue(sp + 32) // "world" ); }
これは一番簡単な例だが、関数呼び出しなどもこの応用でできている。
あとは、Rust
でなんとかwasm_exec.js
に擬態することにした。
うすうす分かってはいたがwasm_exec.js
はReflect
を多用しており、Rust
との相性はよくない。あまりいい方針ではなかった
と前述したのはこれが理由。
かなりアドホックでひどい作りな自覚があるのだけれども、なんとか擬態することはでき、syscall/js.copyBytesToJS
経由でVRAM
の内容を受け取りSDL2
で描画することに成功した。たとえば"syscall/js.valueSet"
は以下のようになった。(ひどい。)
"syscall/js.valueSet" => { let sp: u32 = args[0].into(); let _value = self.load_value(sp + 8, &self.values.borrow())?; let name: &str = &self.load_string(sp + 16).unwrap(); let value = &self.load_value(sp + 32, &self.values.borrow())?; match name { "GB" => { if let BridgeValue::WrappedFunc(f) = value.clone() { *self.gb.borrow_mut() = Some(f); } } "result" => {} "next" => { if let BridgeValue::WrappedFunc(f) = value.clone() { *self.next.borrow_mut() = Some(f); } } _ => {} } Ok(vec![]) }
全体が気になる方はこちら
他のランタイムはどうしているのかと見てみたら、wasmer
は専用のGo
ライブラリを用意しているようだった。正しい。。。。
パフォーマンスはまだひどいけど、ひとまず目標の一つであるゲームボーイエミュレータの起動は達成できた。
これから
まずはWASI
に対応したい。WASI
に対応すればpython
が動かせるはずなのでこれはこれで面白い。
合わせてパフォーマンスの改善。インタプリタではかなり速いと言われるwasm3
の実装を見ていいところを取り込みたい。
また、[no_std]
に対応してベアメタル、強いて言うならARM7
で動かしたい。JIT
やAOT
の機構の検討もしたいが、ベアメタルでの動作を考慮にいれるとこのリポジトリでは採用することはなさそう。
今回実装した機能はMVPで、あとにはGC
やThread
なども控えているので勉強の題材としてはよさそう。以上。