undefined

bokuweb.me

Rustで書いたWebAssemblyインタプリタ上でGoで書いたゲームボーイエミュレータを動かした

概要

最近はWebAssemblyに興味があり、勉強していたんだけど仕様を読み始めても頭に入らないのでインタプリタを作ってみることにした。よくわからないものは作ってみるのが一番よい。

github.com

まだ残された課題は多いのだけれども、一つ目標にしていた「Goで書いたゲームボーイエミュレータを動かす」を達成できたのでここに書いておく。

こツイートに貼られているのは残念ながら、静止画ではなく、動画でありパフォーマンスが悲しいことになっていることを示している。あまりに遅くてプレイ画面まで到達できない。今後これは改善していきたい。

Goでゲームボーイエミュレータを書いた話は以下の記事を参照のこと。

blog.bokuweb.me

blog.bokuweb.me

インタプリタを作る

仕様書を読みながら実装していくのだけど、日本語でさくっと全体を知るのに以下のリポジトリがとても参考になった。

github.com

Hello wasm

開発は最小限のバイナリを実行できるようにし、そこに命令を足していく方針で進めた。これにはWebAssembly Studioがとても役立った

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で詳細も表示してくれる。

f:id:bokuweb:20200312025514p:plain

先頭の橙色はマジックナンバー。重要なのは(このサンプルでは)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はセクションのデータサイズを示す。

f:id:bokuweb:20200312025532p:plain

紫部分の先頭の0x01はExportするエントリ数。今回はhelloという関数のみをExportしているので0x01。次の0x05はExport名の長さを示す。

f:id:bokuweb:20200312025548p:plain

Export名の長さが0x05であることがわかったので、次の5Byte0x68 0x65 0x6c 0x6c 0x6fを見るとこれがhelloであることがわかる。

f:id:bokuweb:20200312025600p:plain

末尾の0x00 0x00だが、前者はExport種別を表す。今回は関数をExportしているので0x00だが、MemoryやTableなどもExportできる。

最後尾はインデックスで今回の場合はモジュールが持っている関数一つの中から先頭の関数をExportしているので0x00になる。

f:id:bokuweb:20200312025615p:plain

詳細は省いているが、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するエントリのインデックスの型はなにかと言うとu8u32ではなく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のときと同様セクション番号とサイズだ。

f:id:bokuweb:20200312025720p:plain

0x01 0x04 0x000x01はエントリ数、0x04はエントリのBodyサイズ、0x00はローカル変数の数を表す。

f:id:bokuweb:20200312025733p:plain

0x41 0x2A 0x0Bはいよいよバイトコード部、すなわちi32.const 42に該当する部分になる。0x41i32.const0x2A42を表しており、0x0Bendを表している。

f:id:bokuweb:20200312025800p:plain

ちょっと複雑だけれども、やっていることは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とかの命令に対応していくことになると思う

github.com

実行

ここまでで外部から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が用意されているのでそれを使用するのがよい。

github.com

ここでは以下のようなテストが定義されており、これを実行していくことでテストができる。

(assert_return (invoke "add" (i32.const 1) (i32.const 1)) (i32.const 2))

assert_returnなどはテスト用のテキスト表現でwasm自体のこの、命令が備わっているわけではない。のでテストを実行するのはこのテキストフォーマットもparseして実行してやる必要がある。

そのあたりを自分で実装するのはさすがに嫌だったので今回はwabtのRustバインディングを使った。これを使うと以下のようにテストが書ける。

github.com

    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を見るとだいたい分かる。

github.com

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.jsReflectを多用しており、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![])
      }

全体が気になる方はこちら

github.com

他のランタイムはどうしているのかと見てみたら、wasmerは専用のGoライブラリを用意しているようだった。正しい。。。。

github.com

パフォーマンスはまだひどいけど、ひとまず目標の一つであるゲームボーイエミュレータの起動は達成できた。

これから

まずはWASIに対応したい。WASIに対応すればpythonが動かせるはずなのでこれはこれで面白い。

合わせてパフォーマンスの改善。インタプリタではかなり速いと言われるwasm3の実装を見ていいところを取り込みたい。

github.com

また、[no_std]に対応してベアメタル、強いて言うならARM7で動かしたい。JITAOTの機構の検討もしたいが、ベアメタルでの動作を考慮にいれるとこのリポジトリでは採用することはなさそう。

今回実装した機能はMVPで、あとにはGCThreadなども控えているので勉強の題材としてはよさそう。以上。