はじめに
結論は至って単純です。「WASIで定義された標準出力ができる関数(API)をWebAssembly内でimportして使っている」ということです。
もちろんドキュメントを見れば分かることですが……折角なのでコードを辿っていきませんか。
※WebAssemblyの命令や構文の説明をした後、最終的にはChatGPTの力を借りて締めています。
※素人学生の備忘録です。温かい目でご覧ください。
WebAssembly
WebAssemblyを手打ちしたことのある皆さんならご存知だと思います。
WebAssembly単体でできること少なすぎではと。標準出力もできないので、WebAssembly単体でHello Worldすらできません。
WebAssemblyのDesign Goasには
- Fast, safe, and portable semantics
- Efficient and portable representation
と書かれており、さらにSecurity Considerationsの節には
WebAssembly provides no ambient access to the computing environment in which code is executed
と書かれています。
- I/O
- リソースへのアクセス
- OSのシステムコール
などはembedder(埋め込まれたホスト環境)によって提供されて、Wasmモジュールにインポートされた関数を呼び出すことによってのみ実行できると書かれています。
ブラウザ向けのWebAssemblyを作る時は、WebAssemblyファイル(wasm)と同時にJSの関数群なども用意する必要があります。Rustでwasm-bindgenなどからWebAssemblyを生成しているとJSの関数なども一緒に作ってくれるため意識することも少ないですが、手動でWebAssembly+対応するJSを作るのはびっくりするくらい面倒です(逆にそれが他OS、他アーキテクチャに依存しない仕様になっているとも言えますが……)
ZigでHello WorldするWASIなWasmを作る!
WebAssemblyを作るにあたって言語はZigでなくても構いません。天下のLLVMがWasmをサポートしたこともあって、大体の言語からWasmが作れます。
折角なのでナウな言語のZigで動かしてみます。
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, {s}!\n", .{"World"});
}
一旦普通に動作確認
❯ zig run src/main.zig
Hello, World!
ヨシ!
ではWASIなWebAssemblyにしてみます。
zig build-exe -O ReleaseSmall -target wasm32-wasi src/main.zig
main.wasmとmain.wasm.oが生成されました。
WASIに対応したランタイムはいくつかありますが、今回は手元にあったWasmerで実行して見ます。
❯ wasmer run main.wasm
Hello, World!
zigと同じ結果が出力されました!……なぜ??
……………この謎を解き明かすべく私はウェブアセンブリーの奥地へ向かった。
Wasmのアセンブリ表現Watに逆アセンブル
WebAssemblyにはbinary format(.wasm)とtext format(.wat)の2種類が用意されています。バイナリ形式がテキスト形式のバイナリ版というほどシンプルではないのですが、.wasmと.watは相互に変換できます。
先程生成したmain.wasmはバイナリ形式なので、これを人間でも読みやすいテキスト形式(WebAssembly text format)に変換していきます。
wat↔wasmの相互変換は公式に提供されています。
これ以外にもWebAssemblyの最適化などその他いろいろなWebAssembly関連ツールを押し込んだwabtの一部に含まれています。
今回はwasm→watにしたいので、wasm2watを使用します。
wasm2wat main.wasm > main.wat
このコマンドでwatファイルができました。
(Firefoxなどは開発者ツール内にwasmをwatに変換して読める機能が付属しています)
ひとまず、これで人間にも読めます。
(後ほど部分ごとに読み解いていくので、一旦読み飛ばしてください……)
(module
(type (;0;) (func (param i32)))
(type (;1;) (func (param i32 i32 i32 i32) (result i32)))
(type (;2;) (func))
(type (;3;) (func (param i32 i32 i32) (result i32)))
(import "wasi_snapshot_preview1" "proc_exit" (func (;0;) (type 0)))
(import "wasi_snapshot_preview1" "fd_write" (func (;1;) (type 1)))
(func (;2;) (type 2)
call 3
i32.const 0
call 0
unreachable)
(func (;3;) (type 2)
(local i32)
global.get 0
i32.const 32
i32.sub
local.tee 0
global.set 0
block ;; label = @1
i32.const 0
i32.load8_u offset=1048595
br_if 0 (;@1;)
i32.const 0
i32.const 1
i32.store8 offset=1048595
end
local.get 0
i32.const 2
i32.store offset=8
block ;; label = @1
local.get 0
i32.const 8
i32.add
i32.const 1048582
i32.const 7
call 4
i32.const 65535
i32.and
br_if 0 (;@1;)
local.get 0
i32.const 2
i32.store offset=24
local.get 0
i32.const 24
i32.add
i32.const 1048576
i32.const 5
call 4
i32.const 65535
i32.and
br_if 0 (;@1;)
local.get 0
i32.const 2
i32.store offset=16
local.get 0
i32.const 16
i32.add
i32.const 1048592
i32.const 2
call 4
drop
end
i32.const 0
i32.const 0
i32.store8 offset=1048595
local.get 0
i32.const 32
i32.add
global.set 0)
(func (;4;) (type 3) (param i32 i32 i32) (result i32)
(local i32 i32 i32 i32)
global.get 0
i32.const 16
i32.sub
local.tee 3
global.set 0
i32.const 0
local.set 4
i32.const 0
local.set 5
block ;; label = @1
loop ;; label = @2
local.get 5
local.get 2
i32.eq
br_if 1 (;@1;)
local.get 0
i32.load
local.set 6
local.get 3
local.get 2
local.get 5
i32.sub
i32.store offset=4
local.get 3
local.get 1
local.get 5
i32.add
i32.store
block ;; label = @3
local.get 6
local.get 3
i32.const 1
local.get 3
i32.const 12
i32.add
call 1
i32.const 65535
i32.and
local.tee 6
i32.eqz
br_if 0 (;@3;)
block ;; label = @4
block ;; label = @5
block ;; label = @6
block ;; label = @7
block ;; label = @8
local.get 6
i32.const -19
i32.add
br_table 3 (;@5;) 1 (;@7;) 1 (;@7;) 4 (;@4;) 0 (;@8;)
end
block ;; label = @8
block ;; label = @9
block ;; label = @10
local.get 6
i32.const -63
i32.add
br_table 2 (;@8;) 1 (;@9;) 0 (;@10;)
end
local.get 6
i32.const 8
i32.eq
br_if 3 (;@6;)
local.get 6
i32.const 76
i32.eq
br_if 1 (;@8;)
block ;; label = @10
local.get 6
i32.const 51
i32.eq
br_if 0 (;@10;)
local.get 6
i32.const 29
i32.ne
br_if 3 (;@7;)
i32.const 3
local.set 4
br 9 (;@1;)
end
i32.const 4
local.set 4
br 8 (;@1;)
end
i32.const 8
local.set 4
br 7 (;@1;)
end
i32.const 7
local.set 4
br 6 (;@1;)
end
i32.const 15
local.set 4
br 5 (;@1;)
end
i32.const 11
local.set 4
br 4 (;@1;)
end
i32.const 1
local.set 4
br 3 (;@1;)
end
i32.const 2
local.set 4
br 2 (;@1;)
end
local.get 3
i32.load offset=12
local.get 5
i32.add
local.set 5
br 0 (;@2;)
end
end
local.get 3
i32.const 16
i32.add
global.set 0
local.get 4)
(memory (;0;) 17)
(global (;0;) (mut i32) (i32.const 1048576))
(export "memory" (memory 0))
(export "_start" (func 2))
(data (;0;) (i32.const 1048576) "World\00Hello, {s}!\0a\00"))
Hello Worldしただけなのに……なが!!!
重要なこと
ソースコード説明のために事前情報をいくつか示しておきます。
Wat(WebAssembly text format)について
先述の通りWatはWebAssemblyのテキスト表現です。アセンブリライクな形ですので高級言語ほどの読みやすさはありませんが、一般的なアセンブリ言語にあるgotoがなく、逆にifやloopがあるなど、LLVM IRに似ている印象です。
一方でLLVM IRとの大きな違いはWebAssemblyがスタックマシンであることです。レジスタに値を出し入れするのではなく、スタックに積んだり取ったりして操作を行います。
他にも色々特徴はありますが、挙げるとキリがないので、今回読む上で重要なことのみを挙げると……
- 外部の関数をインポート、内部の関数をエクスポートできる
- 型の種類は少なく、文字や文字列型、配列も無い
- 線形メモリを定義することができ、そこに自由に手動で格納できる
-
;;
でインラインコメント、(;;)
でブロックコメントになる - WebAssemblyでは関数名どころか引数や変数名も含めて省略できる(名前を省略した関数や変数には暗黙的に番号が振られるので、その番号で指定して使う。wasm2watはご丁寧に無名の関数や変数などに
(;0;)
のように番号コメントを付けてくれています。)
WASI
改めて軽くWASIを説明すると、WASIはWebAssembly System Interfaceの略で、簡単に言えばブラウザで動くWebAssemblyをブラウザの外でも動かしてやろう!ってやつです。
WASIに対応したWebAssemblyを作るには、対応している言語から対応しているコンパイラでターゲットを変えるだけで作れて、しかもwasmファイルのみで動かせてしまいます。
WebAssemblyのコードを読み解く
型指定
(type (;0;) (func (param i32)))
(type (;1;) (func (param i32 i32 i32 i32) (result i32)))
(type (;2;) (func))
(type (;3;) (func (param i32 i32 i32) (result i32)))
main.watでは最初に関数の引数と返り値を宣言しています。
先述の通り(;0;)
はコメントで暗黙的な番号に相当します。ここで宣言した型を使うにはtype 1
のように番号で指定します。
この名前の省略は今後もずっと使われます。湯婆婆もびっくりの省略具合です。リーダブルコードでは名前の付け方について取り上げられていましたが、名前を付けないというワイルドな解決策もあるんです。
(type (;0;) (func (param i32)))
上記では32bit整数を引数にとって返り値無しであることを定義しています。
(type (;1;) (func (param i32 i32 i32 i32) (result i32)))
上記では32bit整数の引数を4つ取って返り値は32bit整数であることを定義しています。
こういった具合です。ウェブアセンブリなんて厳かな名前ですが、比較的読みやすいのではないでしょうか。
import
(import "wasi_snapshot_preview1" "proc_exit" (func (;0;) (type 0)))
(import "wasi_snapshot_preview1" "fd_write" (func (;1;) (type 1)))
次にmain.watでは必要な関数を外部からインポートしています。
ここでWasm単体では許されない標準出力をする力を外から注入する形です。
ここでも関数名は省略され、呼び出すときはcall 0
のように番号で指定します。関数の型については先程typeで宣言したものをtype 0
のように番号で呼び出しています(WebAssemblyでは型のインライン記述もできます)。
それではimportしている関数(API)のドキュメントを見てみます。
2023/9現在WASIはpreview1です(preview2は開発中)。
(Wasmerだけ独自に拡張が進んでいますが、今回は一旦触れません……)
preview1のドキュメントを見てみると
- proc_exitはプロセスを正常終了させるための関数
- fd_writeは書き込みさせるための関数
のようです。
今回外部から取り込んだ関数はこの2つだけです。
💁♂️「あー、はいはい、fd_writeでHello Worldを標準出力して、proc_exitで正常終了してるだ」
💁♂️「なんかあれでしょ、print("Hello World")みたいに、fd_writeに文字列渡してるんでしょ??」
fd_writeの引数を見てみましょう。
fd_write(fd: fd, iovs: ciovec_array) -> Result
fd,iovsはWASI独自定義のものです。
ご存知の通りWebAssemblyにこんな型はありません。そもそも配列も文字型もありません。
fdは
A file descriptor handle.
iovsは
ciovec_array List of scatter/gather vectors from which to retrieve data.
……よく分からなくなってきたので、実際にfd_writeを呼び出す流れを見てみましょう。
そのためには、まず末尾の記述から見る必要があります。
線形メモリの確保
(memory (;0;) 17)
先述の通りWebAssemblyでは文字や文字列型がないので、線形メモリ(linear memory)を確保して、そこに値を入れていく形になります。
線形メモリはmemory命令を用いて確保でき、引数で1ページ64KiBのページ単位で何ページ確保するかを指定します。
今回はmemory 17
なので17ページ確保してます。……Hello Worldの文字を入れるだけにしては多くないか??
線形メモリは配列や型もないただの空間なので、番地を指定して値を出し入れする形です。番地の管理を自分でする必要があり、番地を間違えるとデータは普通に上書きされます。
文字もバイナリがそのまま入るので、取り出した後は文字列にデコードして解釈する必要があります(C言語のようにnull終端文字形式にするか、文字列の長さを最初に入れておくかなども含めて実装する必要があります……とても大変……)
グローバル変数
(global (;0;) (mut i32) (i32.const 1048576))
WebAssemblyにはグローバル変数とローカル変数があって、それぞれglobal.get
、local.get
のようにしてスタックに詰めます。
mut i32
はミュータブルな32bit整数ということです。mut
の指定がなかればイミュータブルになります。(さすがナウな仮想ISAです……)
そしてここで定義している数値1048576
は2^20で、仮にバイト数だとすれば1MiBに相当します。
これは1ページ64Kibの線形メモリ上では15ページ分なので、書き込みなどで使用するために16ページの先頭アドレスをグローバル変数に用意しているのではないでしょうか。(watには変数名などの文脈が残らないので実際何なのかは分かりません。あくまで推測です。)
1048576
はちょくちょくコード上に出てきます。
data
(data (;0;) (i32.const 1048576) "World\00Hello, {s}!\0a\00"))
さっそくでてきました、1048576
!
線形メモリの1048576
の位置に、"World\00Hello, {s}!\0a\00"
の文字列を入れている処理です。
先述の通り線形メモリは配列やスタックのように追加できるわけでなく、位置を指定して入れます。(頭の中で番地管理しないといけない……)
なお、線形メモリはmoduleに対して1つまでしか定義できないため、どの線形メモリに入れるかの指定は不要です。
exportとエントリーポイント
(export "memory" (memory 0))
(export "_start" (func 2))
exportで外部に線形メモリや関数をエクスポートしています。
_startがエントリーポイントとなる関数のようなので、この関数から辿っていきましょう。
外部向けには_start
という贅沢な名前がついていますが、内部では名無しのfunc 2
で、この関数は8行目にあります。
そこから見ていきましょう。
関数2
(func (;2;) (type 2)
call 3
i32.const 0
call 0
unreachable)
call
に着目します。call命令では第1引数で指定の関数を呼び出せます。
呼んでいるのは3と0です。
関数0
は先述のWASIが用意しているproc_exit
関数のことで、最後に正常終了するために呼び出していると言えそうです。
関数3
はこのあと内部で定義される関数です、これについて追ってみましょう。
関数3と基本のWebAssembly命令
(func (;3;) (type 2)
関数3はtype 2
の型(引数なし返り値なし)を指定しています。
具体的な処理を辿る前に、よく使われるWebAssemblyを命令を見ておきます。
スタックマシンな世界なのでレジスタを意識する必要はないですが、頭の中でスタックにプッシュしたりポップしたりしながらコードを読む必要があります。
- local.* ローカル変数に対しての操作
- global.* グローバル変数に対しての操作
で下記のような操作があります。
- get : 値をスタックにプッシュ
- set : スタックから値をポップして変数に格納
- tee : スタックから値をポップせず変数に格納
また型ごとに命令があります。
- i32.*
- f32.*
- i64.*
- f64.*
などの型があり、i32.const
やi32.add
のように書くことで、定数のプッシュや加減乗除、メモリへのロードやストアなどができます。
スタックマシンなので加算命令のaddを使う場合、スタックから2つ値をポップして、それを加算して結果をスタックにプッシュする形です。
例えばローカル変数0の値と32bit整数10を足した結果をローカル変数1に書き込む場合、
local.get 0
i32.const 10
i32.add
local.set 1
と書けます。加算などの算術命令は引数で指定するわけではなく、スタックに積まれた値が使用されて、返り値もスタックに積まれることがポイントです。
なので、関数3の最初は
(func (;3;) (type 2)
(local i32)
global.get 0
i32.const 32
i32.sub
local.tee 0
global.set 0
-
(local i32)
でローカル変数を宣言 -
global.get 0
でグローバル変数0の値(先述の1048576)をスタックに積む -
i32.const 32
で32bit整数の32をスタックに積む -
i32.sub
でスタックに積んだ2つの値を取り出して引き、結果(1048576-32=1048544)をスタックに積む -
local.tee 0
でスタックに積まれた計算結果をローカル変数0に格納(ここでは取り出さない) -
global.set 0
でスタックに積まれた計算結果を取り出してグローバル変数0に格納(mut宣言でミュータブルにしたのでグローバル変数の値は上書きできます)
といった処理です。意外と読めますね。尤も何しているかは読み取れませんが……
条件分岐の登場
次に関数3を見て行くとblockとbr_ifが出てきます。
block ;; label = @1
i32.const 0
i32.load8_u offset=1048595
br_if 0 (;@1;)
i32.const 0
i32.const 1
i32.store8 offset=1048595
end
この部分と同じ処理を高級言語で書くとするなら
if(hogeArray[1048595] === 0){
hogeArray[1048595] = 1
}
みたいに書ける処理です(3項演算子使うと、もはや1行レベル)。
ifで比較するときも値をスタックに事前に積むことで比較ができます。
block
で囲っているのはWebAssemblyがgoto無しでジャンプするための仕組みで、br_if
が条件を満たしたらそのまま次の命令に進み、満たさなかったらblockを抜ける(end
の次に行く)ような構文です。
ちなみに逆アセンブルしているのでこの形ですが、WebAssemblyを手打ちするときはもうちょっとモダンでキレイに、if elseみたいに書くこともできます。
あらかたWebAssemblyの命令や構文の説明を終えたので、ここからは重要な場所に絞っていきます。
関数4の呼び出し
block ;; label = @1
local.get 0
i32.const 8
i32.add
i32.const 1048582
i32.const 7
call 4
i32.const 65535
i32.and
br_if 0 (;@1;)
local.get 0
i32.const 2
i32.store offset=24
local.get 0
i32.const 24
i32.add
i32.const 1048576
i32.const 5
call 4
i32.const 65535
i32.and
br_if 0 (;@1;)
local.get 0
i32.const 2
i32.store offset=16
local.get 0
i32.const 16
i32.add
i32.const 1048592
i32.const 2
call 4
drop
end
これが次の関数4を呼び出す条件分岐で、関数4がついにfd_writeを呼び出す関数です。
関数4をチラ見すると引数を3つも持っています。
(func (;4;) (type 3) (param i32 i32 i32) (result i32)
(local i32 i32 i32 i32)
関数の引数はcall命令の引数で指定するのではなく、他の命令と同様にスタックに積まれた値がcallで呼ぶ関数の引数となります。
最初の関数4呼び出しに着目すると
local.get 0
i32.const 8
i32.add
i32.const 1048582
i32.const 7
call 4
loca.get 0
にはfunc3の0つめのローカル変数で、先に説明した処理からも分かるとおり1048544
が入っています。
次のi32.const 8
で8を定義してその次で加算命令を行っていることから、1048544+8=1048552
がスタックに積まれることになります。その後の2つの命令はconstで、32bit整数を2つスタックに積んでいます。
結果としてfunc4の3つの引数には1048542
,1048582
,7
が入ります。
関数4
改めて、関数4の冒頭を見てみます。
(func (;4;) (type 3) (param i32 i32 i32) (result i32)
(local i32 i32 i32 i32)
関数4では引数3つとローカル変数4つが宣言されていて、これらは引数とローカル変数含めて連番になります。
例えば関数4の第2引数の2つめの値をスタックに積むならlocal.get 1
だし、2行目のローカル変数宣言の1つめの円数にスタックからポップして代入するならlocal.set 3
になります。
新しく定義した4つのローカル変数のうち最初の3つは次のコードで値が入っていきます。(4つ目は繰り返し処理中で入るので一旦除外)
global.get 0 ;;スタックに1048544をプッシュ
i32.const 16 ;;スタックに16をプッシュ
i32.sub ;;スタックから2つの値を取って引き算1048544-16=1048528し、結果をスタックにプッシュ
local.tee 3 ;;スタックの一番上の値1048528をローカル変数3に代入し、スタックはそのまま
global.set 0 ;;スタックからポップしてグローバル変数0に1048528を代入
i32.const 0 ;;スタックに0をプッシュ
local.set 4 ;;スタックからポップした0をローカル変数4に代入
i32.const 0 ;;スタックに0をプッシュ
local.set 5 ;;スタックからポップした0をローカル変数5に代入
これらの結果をまとめると、関数4のローカル変数には初期の段階で下記のように値が入っていると考えられます。
ローカル変数番号 | 値 |
---|---|
0 | 1048542 |
1 | 1048582 |
2 | 7 |
3 | 1048544 |
4 | 0 |
5 | 0 |
6 | -- |
繰り返し処理
次に繰り返し処理が入ります。
block ;; label = @1
loop ;; label = @2
local.get 5
local.get 2
i32.eq
br_if 1 (;@1;)
WebAssemblyではloop
とbr
,br_if
,br_table
の組み合わせで繰り返し処理を実現します。(分岐とgotoで移動してループを作るタイプのアセンブリ言語よりは読みやすいかも……)
loopの中でbr
を実行するとloopの先頭に戻り、br_if
やbr_table
でtrueになるとloopを抜ける形です。ちなみにbrを実行せずblockの最後まで行くとブロックの外に出てしまうので、brはSwitch文で言うところのcontinue的な意味があります。
ということで繰り返し処理の解説に行きたいのですが、さすがに複雑になってきました。
そもそもバイナリwasmファイルにコンパイルされた時点でコンテキストが失われており、処理の意図を完全に理解することはできません。
さすがに無理があるので、ChatGPTに読ませてみます。
ChatGPTに頼る
🤠「ChatGPT(GTP4 Ver2023.8.3
)、func4内で行っている繰り返し処理についてfd_writeの挙動とともに説明してください」
ChatGPT「 https://chat.openai.com/share/aa793647-9335-4538-98a2-220a69ce0ab5 」
だそうです。
もちろんChatGPTの結果を鵜呑みにして良い訳ではないのですが、概ね正しいのではないでしょうか。
というか、ChatGPTの説明あればこの記事いらなくないか……????
このまま読み解いてもアセンブリだけで文脈の完全理解には(少なくとも私の力では)無理があるので、ChatGPTの説明にて、締めさせていただきたいと思います。
おわり
今回のテーマについて簡単に要約すれば
- WebAssemblyは制限が厳しく、システムコールを直接叩いたりはできない
- WASIではWebAssemblyで様々なことができるように関数(API)が用意されており、これをインポートして使うことで、WebAssemblyの記述のみでHello Worldを始め様々なことが実現できる
- WebAssemblyでは型や返り値など制限があるので、WASIで用意されている関数(API)は高級言語のノリでは使えず、線形メモリを活用して関数に値を渡している
といったところでしょうか。
このことからも分かる通り、WebAssemblyを手書きすることはおすすめしません。
高級言語からコンパイルして使うのが一番です。
ですが、意外と読めなくもないのがWebAssemblyだと感じました。
今回のソースコード(といってもHello Worldしてるzigとwatくらいですが)、下記にあります。
謝辞
- ChatGPT
- 入門WebAssembly(The Art of WebAssembly)←WebAssemblyの基礎知識はこの書籍で身につけました