元同僚の id:tanishiking24 くんがScalaMatsuri 2024でWASMの発表をしたのだが、それもあってWASMへの興味が湧いていた自分もちょっとWASMで遊んでみようと思った。
そこで、ゴリラ氏が書いたWASMの記事を読んでScalaでWASMバイナリをパースして簡単なWASIが動作するまでやってみようと思った。
技術スタック
- Scala 3 (3.4.1)
- scodec (バイナリパーサコンビネータ)
- scodec-core 2.2.2
- scodec-bits 1.1.38
- os-lib 0.9.3 (ファイルシステムまわり)
Scalaにはバイナリまわりを扱うためのデファクトライブラリとしてscodecというものがある。僕はscodec未経験だったが、すぐに慣れてWASMをパースできるようになった。とても使いやすくて良いライブラリだった。
加えてファイル操作まわりの雑務をやらせるライブラリとしてos-libを利用した。これがなくても実装できるが、なにかと便利なので利用している。
作った
できた。テストファイルの中でいろいろとWASMバイナリを作り、これをパースして実行するのを繰り返している。i32.add
などの基本的な命令を実装済みで、ゴリラ氏の教科書で出ている範囲の全て、番外編を除いて実装済みだ。Hello Worldも無事動作した。
it("can call fd_write via WASI") { val wasmBinary = wat2wasm(""" |(module | (import "wasi_snapshot_preview1" "fd_write" | (func $fd_write (param i32 i32 i32 i32) (result i32)) | ) | (memory 1) | (data (i32.const 0) "Hello, World via WASI!\n") | | (func $hello_world (result i32) | (local $iovs i32) | | (i32.store (i32.const 24) (i32.const 0)) | (i32.store (i32.const 28) (i32.const 23)) | | (local.set $iovs (i32.const 24)) | | (call $fd_write | (i32.const 1) | (local.get $iovs) | (i32.const 1) | (i32.const 32) | ) | ) | (export "_start" (func $hello_world)) |) |""".stripMargin) val wasm = WasmBinary.codec.decodeValue(BitVector(wasmBinary)).require val runtime = Runtime(wasm) val result = Runtime.call(runtime, "_start", Vector.empty) result shouldBe Some(Value.I32(0)) }
Scalaの特徴
Rustもそうだが、Scalaも関数型パラダイム重視の言語なので特定のコンビネータを変形して別のバイナリのパースに流用したり、ということができる。scodecはScalaの作法を重んじているので、ライブラリが提供している小さいコンビネータをレゴブロックのように組み立てて大きくしていく、という手法を取る。
// こんな感じでパーサを組立てていく object Function: val codec: Codec[Function] = { // Function ::= size, locals*, body* // Leb128.codecIntもscodecで実装したコンビネータ val size = Leb128.codecInt val locals = //繰り返し回数をLEB128で読み取り、LEB128の数値とValueTypeの組を読み取る vectorOfN(Leb128.codecInt, Leb128.codecInt :: ValueType.codec) // 残りはInstructionの繰り返し val body = vector(Instruction.codec) // sizeバイトぶんだけlocalsとbodyを読み取る variableSizeBytes(size, locals :: body).as[Function] }
どのようにパースしたいかをコンビネータに教えていけば、最終的にWASMのパーサができあがるという仕組みだ。宣言的で構造が分かりやすい感じに見える。
また、Rustほどメモリ管理に気をかける必要がなくGCに任せることができるため、わりと気楽に実装できた(かわりにGC負荷を得ることになる)。どちらかといえばRustよりもイミュータビリティを重視している言語なので(Rustはわりとよくmut
するがScalaだと基本的にval
を利用する)、メモリまわりの仕組みにだけミュータブルな構造を使い、あとはだいたいイミュータブルな構造で済ませたりした。メモリまわりでimmutableにすると大量のゴミができてしまうため。
今回はScalaのデフォルトのJVMで動作させたが、このままScala Nativeを利用してネイティブバイナリを作ることもできる。もっとランタイムが大きくなったら遊んでみてもいいかもしれない。
全体としての感想は、やれば普通にできるな、という感じ。ScalaはもともとJVMを念頭に開発された言語なので低レベルのサポートが不足しているように思われがちだけれど、パースまわりはscodecで全然問題なかった。
scodecについてはまた別の機会に解説記事を書いてみようかな。
困ったこと
めちゃくちゃ困ったわけではないが、ScalaにはRustのu8
のように具体的なメモリサイズを使って型を表現しないので、Int
が32ビットである前提でコードを書いたりする必要があった。このへんはRustに軍配が上がる。