WASMを自前で生成してブラウザ上で動かせるようになったら面白いかと思って、まずはWASMのバイナリフォーマットを調べてみた。
使用するツール
WASMのテキスト形式からバイナリ形式へのコンパイルなどの低レベルなツール群の
wabt(The WebAssembly Binary Toolkit)
というものが便利。
Macの場合 brew install wabt
で簡単にインストールできる。
主に使うのは、
wat2wasm
: テキスト形式.watからバイナリ形式.wasmに変換
wasm-objdump
: wasmファイルの情報表示。 -d
で逆アセンブルとバイナリの対応が出力される。
コンパイル結果を見てみる
まずは簡単に、 WebAssembly テキストフォーマットを理解する - WebAssembly | MDN
の2引数を加算するだけの関数をコンパイルして内容を見てみると出力されるWASMファイルの全体像がつかみやすい:
;; add.wat (module (func $add (param $lhs i32) (param $rhs i32) (result i32) local.get $lhs local.get $rhs i32.add) (export "add" (func $add)) )
|
コンパイル結果:
$ wat2wasm add.wat --verbose 0000000: 0061 736d ; WASM_BINARY_MAGIC 0000004: 0100 0000 ; WASM_BINARY_VERSION ; section "Type" (1) 0000008: 01 ; section code 0000009: 00 ; section size (guess) 000000a: 01 ; num types ; type 0 000000b: 60 ; func 000000c: 02 ; num params 000000d: 7f ; i32 000000e: 7f ; i32 000000f: 01 ; num results 0000010: 7f ; i32 0000009: 07 ; FIXUP section size ; section "Function" (3) 0000011: 03 ; section code 0000012: 00 ; section size (guess) 0000013: 01 ; num functions 0000014: 00 ; function 0 signature index 0000012: 02 ; FIXUP section size ; section "Export" (7) 0000015: 07 ; section code 0000016: 00 ; section size (guess) 0000017: 01 ; num exports 0000018: 03 ; string length 0000019: 6164 64 add ; export name 000001c: 00 ; export kind 000001d: 00 ; export func index 0000016: 07 ; FIXUP section size ; section "Code" (10) 000001e: 0a ; section code 000001f: 00 ; section size (guess) 0000020: 01 ; num functions ; function body 0 0000021: 00 ; func body size (guess) 0000022: 00 ; local decl count 0000023: 20 ; local.get 0000024: 00 ; local index 0000025: 20 ; local.get 0000026: 01 ; local index 0000027: 6a ; i32.add 0000028: 0b ; end 0000021: 07 ; FIXUP func body size 000001f: 09 ; FIXUP section size
|
ヘッダや型、exportの宣言などがあって、0x21からが関数本体のコードとなっている。
フィボナッチ数計算のコード
フィボナッチ数の計算では
関数への引数(ローカル変数)参照、
加減算、
比較、
条件分岐、
関数呼び出し
を使用する。
出力されるバイナリコードと対比させてみた:
;; fib.wat (module (func $fib (export "fib") (param $n i32) (result i32) ;; 0000020: 1c | func body size ;; 0000021: 00 | local decl count local.get $n ;; 0000022: 20 00 | local.get 0 i32.const 2 ;; 0000024: 41 02 | i32.const 2 i32.lt_s ;; 0000026: 48 | i32.lt_s if (result i32) ;; 0000027: 04 7f | if i32 local.get $n ;; 0000029: 20 00 | local.get else ;; 000002b: 05 | else ;; (fib (- n 2) local.get $n ;; 000002c: 20 00 | local.get 0 i32.const 2 ;; 000002e: 41 02 | i32.const i32.sub ;; 0000030: 6b | i32.sub call $fib ;; 0000031: 10 00 | call 0
;; (fib (- n 1)) local.get $n ;; 0000033: 20 00 | local.get 0 i32.const 1 ;; 0000035: 41 01 | i32.const i32.sub ;; 0000037: 6b | i32.sub call $fib ;; 0000038: 10 00 | call 0
i32.add ;; 000003a: 6a | i32.add end ;; 000003b: 0b | end ) ;; 000003c: 0b | end )
|
- WASMのアーキテクチャはスタックマシン
- ローカル変数参照
local.get
で内容がスタックに積まれる
- 関数名やローカル変数や引数に
$fib
や $n
などと名前で参照できる(コンパイルするとインデクスに変換される)
- 条件分岐には
if
-else
-end
が使える
if
の終了時点でスタックになにが積まれるか (result i32)
を明示する必要がある(なにも積まない場合には省略できる)
- 加減算や比較演算は
i32.add
など、型に合った命令を使用する
- 関数呼び出しは引数を頭から順に積んで
call
実行
ブラウザでJavaScriptから読み込んで実行することもできるが、Node.js上でも実行できる。
runwasmのリファクタ版:
const [_, __, wasm_file, func_name, ...args] = process.argv;
import("fs/promises") .then(module => module.default.readFile(wasm_file)) .then(bytes => WebAssembly.compile(bytes)) .then( module => new WebAssembly.Instance(module, { env: { memory: new WebAssembly.Memory({ initial: 256 }),
table: new WebAssembly.Table({ initial: 0, element: "anyfunc", }), }, }) ) .then(instance => instance.exports[func_name](...args)) .then(console.log, console.error);
|
を使って
$ node --experimental-modules ./wasmQuickRun.mjs ./fib.wasm fib 10 55
|
FizzBuzz
FizzBuzzでは
ローカル変数、
ループと脱出、
文字列操作
を使用する:
(module (import "console" "puts" (func $puts (param i32)))
(func $puti32 (param $offset i32) (param $x i32) (result) ;; 000004e: 3e ; func body size ;; 000004f: 00 ; local decl count local.get $offset ;; 0000050: 20 00 ; local.get 0 i32.const 15 ;; 0000052: 41 0f ; i32.const 15 i32.add ;; 0000054: 6a ; i32.add local.set $offset ;; 0000055: 21 00 ; local.set 0
local.get $offset ;; 0000057: 20 00 ; local.get 0 i32.const 0 ;; 0000059: 41 00 ; i32.const 0 i32.store8 ;; 000005b: 3a 00 00 ; i32.store8 0 0
block $break ;; 000005e: 02 40 ; block void loop $cont ;; 0000060: 03 40 ; loop local.get $offset ;; 0000062: 20 00 ; local.get 0 i32.const 1 ;; 0000064: 41 01 ; i32.const 1 i32.sub ;; 0000066: 6b ; i32.sub local.set $offset ;; 0000067: 21 00 ; local.set
local.get $offset ;; 0000069: 20 00 ; local.get 0 local.get $x ;; 000006b: 20 01 ; local.get 1 i32.const 10 ;; 000006d: 41 0a ; i32.const 10 i32.rem_s ;; 000006f: 6f ; i32.rem_s i32.const 48 ;; 0000070: 41 30 ; i32.const '0' i32.add ;; 0000072: 6a ; i32.add i32.store8 ;; 0000073: 3a 00 00 ; i32.store8
local.get $x ;; 0000076: 20 01 ; local.get 1 i32.const 10 ;; 0000078: 41 0a ; i32.const 10 i32.div_s ;; 000007a: 6d ; i32.div_s local.set $x ;; 000007b: 21 01 ; local.set 1
local.get $x ;; 000007d: 20 01 ; local.get 1 i32.const 0 ;; 000007f: 41 00 ; i32.const i32.le_s ;; 0000081: 4c ; i32.le_s br_if $break ;; 0000082: 0d 01 ; br_if 1
br $cont ;; 0000084: 0c 00 ; br 0 end ;; 0000086: 0b ; end end ;; 0000087: 0b ; end
local.get $offset ;; 0000088: 20 00 ; local.get call $puts ;; 000008a: 10 00 ; call ) ;; 000008c: 0b ; end
(func $fizzbuzz (export "fizzbuzz") (result) ;; 000008d: 55 ; func body size ;; 000008e: 01 ; local decl count ;; 000008f: 01 ; local type count (local $i i32) ;; 0000090: 7f ; i32
i32.const 1 ;; 0000091: 41 01 ; i32.const 1 local.set $i ;; 0000093: 21 00 ; local.set 0
loop $cont ;; 0000095: 03 40 ; loop local.get $i ;; 0000097: 20 00 ; local.get 0 i32.const 15 ;; 0000099: 41 0f ; i32.const 15 i32.rem_s ;; 000009b: 6f ; i32.rem_s i32.const 0 ;; 000009c: 41 00 ; i32.const 0 i32.eq ;; 000009e: 46 ; i32.eq if ;; 000009f: 04 40 ; if void i32.const 10 ;; 00000a1: 41 0a ; i32.const 10 call $puts ;; 00000a3: 10 00 ; call 0 else ;; 00000a5: 05 ; else local.get $i ;; 00000a6: 20 00 ; local.get 0 i32.const 3 ;; 00000a8: 41 03 ; i32.const 3 i32.rem_s ;; 00000aa: 6f ; i32.rem_s i32.const 0 ;; 00000ab: 41 00 ; i32.const 0 i32.eq ;; 00000ad: 46 ; i32.eq if ;; 00000ae: 04 40 ; if void i32.const 0 ;; 00000b0: 41 00 ; i32.const 0 (offset "Fizz") call $puts ;; 00000b2: 10 00 ; call 0 else ;; 00000b4: 05 ; else local.get $i ;; 00000b5: 20 00 ; local.get 0 i32.const 5 ;; 00000b7: 41 05 ; i32.const 5 i32.rem_s ;; 00000b9: 6f ; i32.rem_s i32.const 0 ;; 00000ba: 41 00 ; i32.const 0 i32.eq ;; 00000bc: 46 ; i32.eq if ;; 00000bd: 04 40 ; if i32.const 5 ;; 00000bf: 41 05 ; i32.const 5 (offset "Buzz") call $puts ;; 00000c1: 10 00 ; call 0 else ;; 00000c3: 05 ; else i32.const 32 ;; 00000c4: 41 20 ; i32.const 32 (work) local.get $i ;; 00000c6: 20 00 ; local.get 0 call $puti32 ;; 00000c8: 10 01 ; call end ;; 00000ca: 0b ; end end ;; 00000cb: 0b ; end end ;; 00000cc: 0b ; end
local.get $i ;; 00000cd: 20 00 ; local.get 0 i32.const 1 ;; 00000cf: 41 01 ; i32.const 1 i32.add ;; 00000d1: 6a ; i32.add local.set $i ;; 00000d2: 21 00 ; local.set 0
local.get $i ;; 00000d4: 20 00 ; local.get 0 i32.const 100 ;; 00000d6: 41 e400 ; i32.const 100 i32.gt_s ;; 00000d9: 4a ; i32.gt_s if ;; 00000da: 04 40 ; if void br 2 ;; 00000dc: 0c 02 ; br 2 end ;; 00000de: 0b ; end br $cont ;; 00000df: 0c 00 ; br 0 end ;; 00000e1: 0b ; end ) ;; 00000e2: 0b ; end
(memory (export "memory") 1) (data (i32.const 0) ;; 00000e3: 0b ; section "Data" (11) ;; 00000e4: 19 ; section size ;; 00000e5: 01 ; num data segments ;; 00000e6: 00 ; segment flags ;; 00000e7: 41 00 ; i32.const 0 ;; 00000e9: 0b ; end ;; 00000ea: 13 ; data segment size "Fizz\00" ;; offset=0 "Buzz\00" ;; offset=5 "FizzBuzz\00" ;; offset=10 ) )
|
- ローカル変数は関数先頭で
(local $i i32)
などと宣言する(括弧は省略できない)
loop
や block
や if
は ブロック となっていて、途中で抜けるには br
や br_if
を使用する
$cont
などと名前をつけて、または直接数値で何段抜けるかを指定する
loop
ブロックでも自動的にループされず、 br $cont
と呼び出さないと抜けてしまう
文字列操作は結構面倒。
import
する外部関数の $puts
で文字列表示をさせるが、文字列やポインタといったものをWASMで扱うことができない。
線形メモリというものに対して、そのオフセットを受け渡すことでやりとりする。
起動側のJavScript(Node.js):
const w = require('./runwasm.js')
;(async () => { const imports = { console: { puts: (offset) => { const buffer = instance.exports.memory.buffer const mem = new Uint8Array(buffer) let end for (end = offset; mem[end] !== 0; ++end) ; const str = new Uint8Array(buffer, offset, end - offset) const text = new TextDecoder('utf-8').decode(str) console.log(text) }, }, }
const instance = await w.loadWebAssembly('fizzbuzz.wasm', imports) instance.exports.fizzbuzz() })()
|
- runwasmを使用
loadWebAssembly
に渡した imports
の console.puts
がWASM側から参照できる
puts
に線形メモリ中のオフセットを渡して、JavaScript側で mem
(=instance.exports.memory.buffer
) の内容を取り出す
- 線形メモリのどこに何が置かれるかは自分で管理する
- 数値を文字列化するには、線形メモリの適当な位置に文字列を構築する。
線形メモリへの書き込みは
i32.store8
で1バイトずつ行う。
data
中のヌル文字のエスケープは \0
じゃなくて \00
(\
に続けて16進数2桁)
配列
ローカル変数で配列型はどう扱うんだろうと思い、C言語で書いたエラトステネスの篩をemcc
したものをwasm2wat
で逆アセンブルして見てみた。
要は、
- ローカル変数で使用できるのは
i32
などの数値型だけで、配列は線形メモリ上に配置する。
- グローバル変数で線形メモリ上のオフセットを(スタックポインタの用途として)保持して、関数内で使用する分を増減する。
- 配列には
i32.store
やi32.load
でアクセスする。
構造体も同様。
雑感
- 数値型しか扱えないからか、命令セットがすごくシンプル
- ジャンプ先をアドレスじゃなくブロックから何段抜けるかで指定するのはバイトコード的にはリーズナブルかも
- 定数はLEB128という可変長(最上位ビットで続くかどうかを示す)でエンコードする
if
, loop
, block
などのブロックで結果の型をテキスト形式で指定するのはまだしも、バイナリ形式では必要なくね?
local.tee
などという命令は入れないで、local.set
-local.get
をランタイムが勝手に最適化して欲しい…
malloc
相当の機能を提供するには、線形メモリを管理するランタイムを用意する必要がある?
- バイナリフォーマットは結構シンプルなので、頑張ればターゲットコードとして出力できそう!?
参照