WASMのバイナリフォーマット

2020-11-22

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のリファクタ版

// wasmQuickRun.mjs
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) などと宣言する(括弧は省略できない)
  • loopblockifブロック となっていて、途中で抜けるには brbr_if を使用する
    • $cont などと名前をつけて、または直接数値で何段抜けるかを指定する
    • loop ブロックでも自動的にループされず、 br $cont と呼び出さないと抜けてしまう

文字列操作は結構面倒。 import する外部関数の $puts で文字列表示をさせるが、文字列やポインタといったものをWASMで扱うことができない。 線形メモリというものに対して、そのオフセットを受け渡すことでやりとりする。

起動側のJavScript(Node.js):

// run_fizzbuzz.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 に渡した importsconsole.puts がWASM側から参照できる
  • puts に線形メモリ中のオフセットを渡して、JavaScript側で mem(=instance.exports.memory.buffer) の内容を取り出す
  • 線形メモリのどこに何が置かれるかは自分で管理する
  • 数値を文字列化するには、線形メモリの適当な位置に文字列を構築する。 線形メモリへの書き込みは i32.store8 で1バイトずつ行う。
  • data 中のヌル文字のエスケープは \0 じゃなくて \00\に続けて16進数2桁)

配列

ローカル変数で配列型はどう扱うんだろうと思い、C言語で書いたエラトステネスの篩をemccしたものをwasm2watで逆アセンブルして見てみた。 要は、

  • ローカル変数で使用できるのはi32などの数値型だけで、配列は線形メモリ上に配置する。
  • グローバル変数で線形メモリ上のオフセットを(スタックポインタの用途として)保持して、関数内で使用する分を増減する。
  • 配列にはi32.storei32.loadでアクセスする。

構造体も同様。

雑感

  • 数値型しか扱えないからか、命令セットがすごくシンプル
  • ジャンプ先をアドレスじゃなくブロックから何段抜けるかで指定するのはバイトコード的にはリーズナブルかも
    • 好き勝手な場所に飛ばれないというのもいいかも
  • 定数はLEB128という可変長(最上位ビットで続くかどうかを示す)でエンコードする
  • if, loop, block などのブロックで結果の型をテキスト形式で指定するのはまだしも、バイナリ形式では必要なくね?
  • local.tee などという命令は入れないで、local.set-local.get をランタイムが勝手に最適化して欲しい…
  • malloc 相当の機能を提供するには、線形メモリを管理するランタイムを用意する必要がある?
  • バイナリフォーマットは結構シンプルなので、頑張ればターゲットコードとして出力できそう!?

参照