ごく単純なwasm(WAT)を書きたいときに見るやつ
先日こんな記事を出したので、wasm(WAT)の書き方の備忘録も書いておこうと思います。
完全な説明ではないことは承知の上で、最低限この理解をしていれば書けるだろう、という方針で書いていきます。
事前説明: wasmとは?
そもそもwasmとは何?
正式名称はWebAssembly。長いのでwasmと略されがち。
コンパクトな低レイヤーの言語で、実行が速いらしいです。
別にJSを置き換えるためのものではなくて、速度を求める低レイヤー部分だけwasmを使うという共存が想定されています。
WebAssembly は現代のウェブブラウザーで実行できるコードの一種です。
ネイティブに近いパフォーマンスで動作する、コンパクトなバイナリー形式の低レベルなアセンブリー風言語です。
WebAssembly は、 JavaScript を補完し、並べて実行するように設計されています。
WebAssembly は、ウェブプラットフォームに大きな影響を与えます。
複数の言語で記述されたコードをネイティブに近い速度でウェブ上で実行できるようになります。
テキスト形式表現(WAT)
wasmはバイナリー形式なので、そのままだと(ほとんどの人には)読めません。
そこで、wasmを人間が読んだり編集したりできるようにするためのテキスト表現(WAT)が存在します。
直接wasmを書くことになるならまあWATを書くことになるでしょう。
別にwasmにコンパイルできる言語が書ける人はそれを使えば良いでしょう。
wasmへのコンパイルが可能な言語には、C, C++, Rust, Go, Java, Kotlinなどがあるらしいです。一回あなたのメイン言語がwasmに変換可能か調べておくとよさそうです。
前提知識いろいろ
コメントアウト
ソースコードの各行について、;;以降はコメントアウトとみなされます。
スタックマシン
wasmの実行環境はスタックマシンとして定義されます。
つまり、実行開始時点では空のスタックがあり、各命令はスタックから値を読んだりスタックに値を積んだりします。
スタックは関数ごとに分離されていると考えてよいです。
もうちょっと厳密な話
厳密には、スタック自体はスレッド全体で共通です。
ただし、関数呼び出しごとに境界線が引かれ、その境界線を越えるコードはエラーとなります。
境界線自体は「スタックの何番目の後ろに境界線があるか」を別のスタックで持っておくような実装になってるってGeminiが言ってました、本当かは知りませんが賢い実装だな〜って思ったのでなんか本当な気がします
wasmが持つ型
wasmには数値型が4種類あります。文字列型とか真偽値型とかはないです。
整数型の符号の有無は型単位では決まっていなくて、命令によってどう扱うかが異なります。
-
i32: 32bit整数 -
i64: 64bit整数 -
f32: (符号あり)32bit浮動小数点数 (IEEE 754) -
f64: (符号あり)64bit浮動小数点数 (IEEE 754)
booleanはi32の0(false)と1(true)で代用するのが一般的らしいです。
また、ベクトル型というのもあります。
これに関しては符号どころかどう扱うかも決まっていなくて、命令によって「32bit整数が4つ」とか「16bit整数が8つ」とかいろんな読み方をします。
-
v128: 128bitの整数の組とか浮動小数点数の組とか、128bit整数とか
他にexternrefとかfuncrefとかがありますが、今回の用途で使うことはなさそうなので無視します。
文法いろいろ
モジュールエクスポート
JS/TSで使うためには関数としてまとめたうえでエクスポートしないといけません。
ほぼ定型文なので記述例を提示しておきます。以下は有効なコードの例です。
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a ;; [] -> [$a]
local.get $b ;; [$a] -> [$a, $b]
i32.add ;; [$a, $b] -> [] -> [$a+$b]
)
(export "add" (func $add))
)
-
$addと"add"は適宜書き換えることができます。- JS/TS側で読み込むときの名前は
"add"のほうが参照されます。
- JS/TS側で読み込むときの名前は
関数の定義
概ね下記の形になります。
(func $関数名 (param $引数名 型) (local $変数名 型) (result 型)
;; 実際の処理
)
-
(param $引数名 型)はその関数の引数です。- 0個以上をスペース区切りで書きます。
-
(local $変数名 型)はその関数内で使うローカル変数です。- 0個以上をスペース区切りで書きます。
- JSだと
varが巻き上げで先に定義されますが、それを地でやれって言われている気がしますね
-
(result 型)はその関数の戻り値です。- 0個以上1個以下を書きます。
- 複数値を返したい場合、
(result 型 型)のように書きます。
- 引数が複数ある場合、先にpopした値が後ろの引数に入ります。
- 返り値が複数値になる場合、関数内スタックの頂上側が戻り値の頂上側として積まれます。
- 境界線を抜き取るだけ、とも言えそう。
制御フロー
ブロックとかループとか条件分岐とかです。
;; ブロック
(block $foo
;; do something
)
;; ループ
(loop $bar
;; do something
)
;; if-else
(if
(then
;; do something
)
(else
;; do something else
)
)
-
ifの条件分岐は、if突入時点でスタックから値を1つpopすることで行います。- そのときpopされる値は
i32である必要があります。 - popされた値が
0ならelse節を実行します。 - さもなければ
then節を実行します。
- そのときpopされる値は
-
br $labelというものがあり、$labelがついた区間を飛ばします。- 関数について
brすると、その関数を途中で抜けます。 -
blockについてbrすると、そのブロックを途中で抜けます。 -
loopについてbrすると、そのブロックの最初に戻ります。- JS/TSでいうcontinueですね
-
ifでも使えるってMDN Web Docsには書いてるんですけど詳細不明 有識者求む
- 関数について
-
br_if $labelというのもあります。- スタックから値を1つpopして、その値が
i32かつ0でないときのみbrします。
- スタックから値を1つpopして、その値が
命令いろいろ
たぶんここまで知っておけば単純なプログラムは書けるので、使いそうな命令に一通り目を通しておくことにしましょうか。
完全な表ではなくて、v128周りの命令を筆頭に色々端折っています。
定数・破棄・制御
| 命令 | 即値 | pop | push | 説明 |
|---|---|---|---|---|
NUM.const <i0> |
i0: NUM |
[] |
[r0: NUM] |
i0をpush |
drop |
[a0] |
a0を破棄 |
||
nop |
何もしない | |||
unreachable |
正常ならここには来ないのでエラー発火 | |||
call <i0> |
i0: $label |
関数i0を呼び出して実行 |
変数
-
localでは引数もローカル変数も同一の連番で呼ばれることに注意。 - 他に
tableとかelemとかmemoryとかあるらしいけど一旦見なかったことにします。
| 命令 | 即値 | pop | push | 説明 |
|---|---|---|---|---|
global.get <i0> |
i0: $label |
[] |
[r0] |
グローバル変数i0の値をpush |
global.set <i0> |
i0: $label |
[a0] |
a0をグローバル変数i0の値として代入 |
|
local.get <i0> |
i0: $label |
[] |
[r0] |
ローカル変数i0の値をpush |
local.set <i0> |
i0: $label |
[a0] |
a0をローカル変数i0の値として代入 |
型変換
| 命令 | 即値 | pop | push | 説明 |
|---|---|---|---|---|
i64.extend_i32_s |
[a0: i32] |
[r0: i64] |
a0を符号ありとみなしてi64に変換しpush |
|
i64.extend_i32_u |
[a0: i32] |
[r0: i64] |
a0を符号なしとみなしてi64に変換しpush |
|
i32.wrap_i64 |
[a0: i64] |
[r0: i32] |
a0の下位32bitをi32としてpush |
数値比較
- このカテゴリーの各命令は即値・pop・push・説明が共通しています。
- 即値: なし
- pop:
[a0: (命令名に対応する型), a1: (命令名に対応する型)] - push:
[r0: i32] - 説明: 「条件」を満たすならば
1を、そうでなければ0をpush
-
iXXを扱う命令で被演算子の符号をあり/なしどちらとみなすかは、以下の法則に従います。- 命令名末尾が
_s: 符号ありとみなす - 命令名末尾が
_u: 符号なしとみなす - どちらでもない: 符号の有無にかかわらず同じロジックで対応できるのでそもそも区別しない
- 命令名末尾が
| 命令 | 条件 |
|---|---|
iXX.eqz |
a0 === 0 |
NUM.eq |
a0 === a1 |
NUM.ne |
a0 !== a1 |
fXX.lt |
a0 < a1 |
iXX.lt_u |
a0 < a1 |
iXX.lt_s |
a0 < a1 |
fXX.gt |
a0 > a1 |
iXX.gt_u |
a0 > a1 |
iXX.gt_s |
a0 > a1 |
fXX.le |
a0 <= a1 |
iXX.le_u |
a0 <= a1 |
iXX.le_s |
a0 <= a1 |
fXX.ge |
a0 >= a1 |
iXX.ge_u |
a0 >= a1 |
iXX.ge_s |
a0 >= a1 |
数値演算
- このカテゴリーの各命令は即値・push・説明が共通しています。
- 即値: なし
- push:
[r0: (命令名に対応する型)] - 説明: 「計算式」の計算結果をpush
- "計算式"列では、
a0を 、a_0 a1を のように表記します。a_1
- "計算式"列では、
-
iXXを扱う命令で被演算子の符号をあり/なしどちらとみなすかは、以下の法則に従います。- 命令名末尾が
_s: 符号ありとみなす - 命令名末尾が
_u: 符号なしとみなす - どちらでもない: 符号の有無にかかわらず同じロジックで対応できるのでそもそも区別しない
- 命令名末尾が
| 命令 | pop | 計算式 |
|---|---|---|
fXX.floor |
[a0: fXX] |
|
fXX.ceil |
[a0: fXX] |
|
fXX.trunc |
[a0: fXX] |
|
fXX.nearest |
[a0: fXX] |
|
fXX.abs |
[a0: fXX] |
|
fXX.neg |
[a0: fXX] |
|
fXX.sqrt |
[a0: fXX] |
|
NUM.add |
[a0: NUM, a1: NUM] |
|
NUM.sub |
[a0: NUM, a1: NUM] |
|
NUM.mul |
[a0: NUM, a1: NUM] |
|
fXX.div |
[a0: fXX, a1: fXX] |
|
iXX.div_s |
[a0: iXX, a1: iXX] |
|
iXX.div_u |
[a0: iXX, a1: iXX] |
|
iXX.rem_s |
[a0: iXX, a1: iXX] |
|
iXX.rem_u |
[a0: iXX, a1: iXX] |
|
fXX.min |
[a0: fXX, a1: fXX] |
|
fXX.max |
[a0: fXX, a1: fXX] |
ビット演算
- このカテゴリーの各命令は即値・pushが共通しています。
- 即値: なし
- push:
[r0: (命令名に対応する型)]
| 命令 | pop | 説明 |
|---|---|---|
v128.not |
[a0: v128] |
a0をbit反転したものをpush |
iXX.and |
[a0: iXX, a1: iXX] |
a0とa1の論理積をpush v128にもある |
iXX.or |
[a0: iXX, a1: iXX] |
a0とa1の論理和をpush v128にもある |
iXX.xor |
[a0: iXX, a1: iXX] |
a0とa1の排他的論理和をpush v128にもある |
iXX.clz |
[a0: iXX] |
a0の最上位に0が何bit連続するかを返す |
iXX.ctz |
[a0: iXX] |
a0の最下位に0が何bit連続するかを返す |
JS/TS側での読み込み
外部ファイル(.wasm)を読み込む
MDN Web Docsに記事があります。
簡潔に述べると以下の通り。
- WAT形式で書いたコードを
.watで保存します。 -
wabtに
wat2wasmというコマンドラインツールがあり、これで.watを.wasmに変換できます。 -
WebAssembly.instantiateStreaming(source: Response | Promise<Response>)で読み込めます。
例えば、wasm側で"add"でexportしている関数は、以下のようなJS/TSコードで読み込めます。
const { add } = (await WebAssembly.instantiateStreaming(fetch("add.wasm"))).instance.exports;
JavaScript内に直接埋め込む
先述の記事で触れたやつですが再掲します。
何らかの都合でJSファイルに埋め込みたい場合、wasmバイナリをBase64変換することで埋め込むことができます。
WATのBase64変換はwabtのOnline Demoでできます。
- https://webassembly.github.io/wabt/demo/wat2wasm/ を開きます
- ページ左側にWATをコピペします
- その右の"BUILD LOG"って書いてあるところの隣に"BASE64"があるのでそれを押します
- BASE64文字列が表示されるのでそれをコピーします。
コピーした文字列を使って、以下のようなコードを書きます。
const wasmBase64 = "(base64 string)";
const binary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
const module = new WebAssembly.Module(binary);
const instance = new WebAssembly.Instance(module);
const { add } = instance.exports;
短くしたい場合
const wasmBase64 = "(base64 string)";
const { add } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0)))).exports;
Discussion