続・易しいアセンブリ
WebAssemblyアドベントカレンダー1日目,兼モニクル Advent Calendar 20251日目の記事になります.
はじめに
Wasm 3.0
さて,今年の9月にWasm 3.0が発表されました.これは Wasm 2.0以降新たに追加,実装されたWasmの仕様をまとめ,新たにWasm 3.0としてバージョニングしたものです.
ですので,Wasm 3.0が発表されたからといって何かが変わるということではなく,ここまでの実装をWasm 3.0の仕様とすると解釈すると良いでしょう.
Wasm 3.0として新たに追加された仕様については,技術書典19にて配布している合同誌WebAssembly Cookbook vol.4に寄稿していますのでよければこちらを読んでいただけると幸いです.
合同誌に寄稿するにあたり,Wasm 3.0のリリースノートを読んでいたときに,WAT[1]が数年前と比べて圧倒的に書きやすくなっていると感じました.
注目されていたWasm GCの他にも,try-catch命令による例外処理のハンドリングや,return命令による関数からの脱出命令など,便利な命令が追加されました.
e.g. return命令
Wasmはスタックマシンなので,ある関数を呼び出したとき,その関数のスタックに残った値がそのまま戻り値になります.
(func $one (result i32)
i32.const 1
)
この例では,スタックに1という値が残っているため,これが戻り値になります.
ですが,このスタック上の値の数と型として定義されている値の数が一致しない場合,Wasmは例外を発火します.
そのため,関数から抜けるときはスタック上の値の数を調整する必要がありました.
次の例では1つの値を返すのに対し,スタック上に3つの値が積まれているため,2回すタック上の値を取り出すことで,戻り値を調整しています.
(func $one (result i32)
i32.const 1 ;; 1が返る
i32.const 2
i32.const 3
drop
drop
)
なんだか面倒そうですね.
ここで,return命令を使ってみると,スタック上の値の破棄をランタイム側の責務にできるため,実装者はスタック上の値の数を気にせず次のように書くことができます.
(func $one (result i32)
i32.const 1 ;; return命令によって破棄
i32.const 2 ;; return命令によって破棄
i32.const 3 ;; 3が返る
return
)
dropの例と比較すると,return命令はスタックの上の値を戻り値として扱うという点に注意が必要ですが,実際にWATを書くときはS式の構文を使えるため,上記の実装は次のように書くことができます.
(func $one (result i32)
i32.const 1 ;; return命令によって破棄
i32.const 2 ;; return命令によって破棄
(return (i32.const 3)) ;; 3が返る
)
なんだか普通のプログラミング言語のように見えてきます.
もう一つ例を出してみましょう.retrun命令は関数脱出ができるため,次のような実装も可能になりました.
(func $not (param $bool i32) (result i32)
- (if (result i32)
- (local.get $bool)
- (then (i32.const 1))
- (else (i32.const 0))
- )
+ (if (local.get $bool)
+ (then (return (i32.const 1)))
+ )
+ (i32.const 0)
)
同じ内容のTSのコードと比較してみましょう.
function not(n: boolean): boolean {
if (n) {
return true;
}
return false;
}
これはもう普通のプログラミング言語ですね.
WATでCOMET2エミュレータを実装する
過去に易しいアセンブリやアセンブリでAI彼女を実装するという記事で,IPAの基本情報技術者試験の問題として出題されるアセンブリ言語であるCASL2[2]のコンパイラと,その実行環境であるCOMET2のエミュレータを自作したものを紹介しました.
今回は,このCOMET2エミュレータをWATで実装します.
題材として選んだ理由としては,CASL2のターゲットであるCOMET2の仕様がシンプルであるのに加えてWasmのアーキテクチャ設計と相性が良いなと感じたからです.
COMET2の特性
COMET2は16ビットコンピュータなのでプログラムは2の16乗のアドレスにアクセスできるわけですが,これらの領域をゲストプログラム(=アプリケーション)とホストプログラム(=OS)でどう分け合うのかというところまで仕様が定義されていません.
にも関わらず,CALS2の命令セットにはスーパーバイザーコール(SVC)が定義されています.
COMET2におけるスーパーバイザーは誰なのか
もしこの16ビットのアドレス空間にホストプログラムとゲストプログラムが載っていると考えた時に,メモリ空間をどう分け与えるのかというのが設問ごとに定義されるとした場合,SVCが呼ばれた時にメモリ上のどの命令が呼ばれるのかなども設問に委ねられてしまいます.
そのような仕様として捉えてしまうと,SVCの実装がエミュレータによって異なってしまうため,教材として扱うにはあまりにも不適切な設計と感じるのではないでしょうか(SVCの定義が異なる問題が出題されるということはほぼ無いとは思いますが).
であるならば,16ビットのメモリ空間はホスト環境であるOSもしくはハードウェアからホスト環境であるアプリケーションに与えられた空間であると考え,SVCはゲスト環境を管理しているホスト環境を呼び出す命令と捉えるのが自然と言えるでしょう.
この構成はJSランタイムにおけるWasmランタイムと似たような構成となるため,Wasmで実装してみるとなんだかしっくりきそうです.
既存実装
自作のCASL2エミュレータ[3]はDartで実装さているのですが,これはCASL2のコンパイラとCOMET2のエミュレータという2つの構成となっています.
ですので,今回はCASL2のコンパイラターゲットであるCOMET2のエミュレータの部分のみをWATによって実装します.
レジスタの定義
COMET2は,8つの汎用レジスタ,プログラムレジスタ,スタックポインタ,フラグレジスタ,メモリから構成されています.
それぞれのリソースをWATで定義すると,次のようになります.
(memory $mem i32 2) ;; 1ワード(16ビット) x 16ビットアドレス空間
(global $GR0 (mut i32) (i32.const 0))
(global $GR1 (mut i32) (i32.const 0))
(global $GR2 (mut i32) (i32.const 0))
(global $GR3 (mut i32) (i32.const 0))
(global $GR4 (mut i32) (i32.const 0))
(global $GR5 (mut i32) (i32.const 0))
(global $GR6 (mut i32) (i32.const 0))
(global $GR7 (mut i32) (i32.const 0))
(global $PR (mut i32) (i32.const 0))
(global $SP (mut i32) (i32.const 0))
(global $FR (mut i32) (i32.const 0))
step関数(ALU)の実装
次に命令を実行するstep関数を実装します.
;; |F C|B 8|7 4|3 0|
;; |-------------------|
;; | opecode | operand |
;; | - |r/r1|x/r2|
;; |-------------------|
;; | address |
;; |-------------------|
(func (export "step")
;; let op;
(local $op i32)
;; op = load(PR) & 0xff00;
(local.set $op
(i32.and (call $load_u (global.get $PR)) (i32.const 0xff00))
)
;; if (op == 0x1000) return LD();
(if (i32.eq (local.get $op) (i32.const 0x1000))
(then (return_call $LD))
)
;; if (op == 0x1100) return ST();
(if (i32.eq (local.get $op) (i32.const 0x1100))
(then (return_call $ST))
)
(; ... ;)
;; NOP()
call $NOP
)
それぞれの命令ごとの関数を用意してあり,オペコードのバイナリ列を確認して各命令の関数を呼び出しています.
オペコードが一致した場合return_call命令で関数を呼び出すのと同時にstep関数から脱出しています.どのオペコードにもマッチしない場合は,NOP命令を実行します.
この関数はホスト環境(e.g. JS)から次のようにして呼び出す予定の関数です.
do {
comet2.step();
} while (comet2.SP.value);
個別の命令の実装
次にstep関数から呼ばれるすべての命令を実装するだけですが,数が多いのでここではLDを例に説明します.
COMET2は1ワード,もしくは2ワードの命令を扱うコンピュータです.
例えばLD GR0,100という命令は「メモリアドレス=100の内容をGR0にロードする」という意味となり,次のような2ワードの命令となります.
;; |F C|B 8|7 4|3 0|
;; |---------------|
;; | LD |GR0| - |
;; |---------------|
;; | 100 |
;; |---------------|
そのため,処理の流れは1ワード目のオペランドを読み込んでプログラムレジスタのカウントを進め,次のアドレスを読み込んでプログラムレジスタのカウントを進めます.
オペランドとアドレスを読み込んでプログラムカウンタを進める関数は,次のような実装になります.
(func $load_op_incr (result i32)
;; let val;
(local $val i32)
;; val = mem[PR * 2);
(local.set $val
(i32.load16_u $mem (i32.mul (global.get $PR) (i32.const 2)))
)
;; PR = PR + 1;
(global.set $PR
(i32.add (global.get $PR) (i32.const 1))
)
;; return val;
(return (local.get $val);
)
(func $load_adr_incr (param $op i32) (result i32)
;; let adr;
(local $adr i32)
;; let val;
(local $val i32)
;; adr = mem[PR * 2];
(local.set $adr
(i32.load16_u $mem (i32.mul (global.get $PR) (i32.const 2)))
)
;; PR = PR + 1;
(global.set $PR
(i32.add (global.get $PR) (i32.const 1))
)
;; val = mem[adr * 2];
(local.set $val
(i32.load16_u $mem (i32.mul (local.get $adr) (i32.const 2)))
)
;; return val;
(return (local.get $val);
)
一部実装を簡略化していますが,普通に読めるのではないでしょうか?
これらの関数を利用して,LD命令を実装すると,次のようになります.
(func $LD
;; let op;
(local $op i32)
;; let adr;
(local $adr i32)
;; let val;
(local $val i32)
;; op = load_op_incr();
(local.set $op (call $load_op_incr))
;; adr = load_adr_incr(op);
(local.set $adr (call $load_adr_incr (local.get $op)))
;; set_r1_u(op, val = load_u(adr));
(call $set_r1_u (local.get $op)
(local.tee $val (call $load_u (local.get $adr)))
)
;; フラグレジスタの設定
(call $set_of_z)
(call $set_sf (local.get $val))
(call $set_zf (local.get $val))
)
スーパーバイザーコール
スーパーバイザーコールはプログラム上で定義されるものではなく,ホスト環境で定義されるものです.
そのため,次のように関数テーブルと,supervisor_call関数のインポートを定義します.
(import "env" "call_supervisor" (func $call_su (param (ref null extern))))
(table $su i32 16 (ref null extern))
(export "supervisor" (table $su))
スーパーバイザーコールはアドレスに対応する関数テーブルを呼び出すための設計です.
COMET2の仕様では,どのアドレスがどのスーパーバイザーに対応するかという厳密な定義はありませんが,今回使用するコンパイラではIN/OUTマクロがそれぞれSVC 1とSVC 2を呼び出す設計[4]になっています.
(func $SVC
;; let op;
(local $op i32)
;; let adr;
(local $adr i32)
;; op = load_op_incr();
(local.set $op (call $load_op_incr))
;; adr = load_adr_incr(op);
(call $load_adr_incr (local.get $op))
;; call_supervisor(table[adr]);
(call $call_su (table.get $su (local.get $adr)))
)
supervisor_callは次のようにホスト環境に実装します.
今回はTSで実装してきます.
export function call_supervisor(fn: (() => void) | null): void {
assert(fn);
fn();
}
そして,関数テーブルはプログラム実行前に次のように初期化します.
const memory = new Int16Array(comet2.exports.memory.buffer);
comet2.exports.supervisor.set(1, () => {
const input = prompt(">");
if (input === null) {
memory.set([-1], gr1.value);
return;
}
const bytes = new TextEncoder().encode(input).slice(0, gr2.value);
memory.set(bytes, gr1.value);
memory.set([-1], gr1.value + bytes.length);
});
comet2.exports.supervisor.set(2, () => {
const output = memory.slice(gr1.value, gr1.value + gr2.value);
console.log( String.fromCharCode(...output.slice(0, output.findIndex((v) => v === -1))));
});
ひとまず仮置きで関数テーブルを16個定義しており,現時点で利用しているのが1と2のみなので,それ以外の関数テーブルは用途に合わせて自由に変更することができます.
設問次第で乱数を取得できる関数を関数テーブルに追加しても良いのではないでしょうか.
その他の構文
switch構文
ここまでWasmにはifブロック命令があるのをみてきましたが,switch構文を利用したい場面があるかもしれません.
残念ながらWasmにswitchはないのですが,ブロック命令を利用した擬似的なswitchは存在します.
たとえば変数iの内容に応じて主臆するGR0〜GR7の値を取得するような処理の場合,次のように書くことができます.
(func $set_r1 (param $i i32) (param $val i32)
(block $GR7
(; ... ;)
(block $GR0
(block $trap
(local.get $i)
;; switch (i) {
;; case 0:
;; break $GR0;
;; ...
;; default:
;; break trap;
;; }
(br_table $GR0 $GR1 $GR2 $GR3 $GR4 $GR5 $GR6 $GR7 $trap)
)
;; throw;
unreachable
)
;; GR0 = val;
;; return ;
(return (global.set $GR0 (local.get $val)))
(; ... ;)
)
;; GR7 = val;
(global.set $GR7 (local.get $val))
)
「なんだこれ」と思うかもしれませんが,これは変数iの内容(0 ~ n)に応じてブロックをブレイクするbr_table命令を利用した実装です.
iが0〜7の値になる場合,$GR0〜$GR7のラベルのブロックをブレイクします.例えばiが0の場合,$GR0のラベルのブロックをブレイクし,(return (global.set $GR0 (local.get $val)))を実行します.
return命令を利用しているため,処理はここで終了します.
一方でiが8〜nの場合は,常に一番最後の$trapラベルのブロックをブレイクし,例外(trap)を発生させるunreachable命令を実行します.
関数参照
ref.func命令で,関数参照を作成し,引数に渡すことができます.
ただし,関数参照として渡す関数はテーブルとして型情報が必要なため,次のように実装します.
(func $ADDA
(call $binomial_adrx_s (ref.func $add))
)
(func $binomial_adrx_s (param $calc (ref $binomial_t))
nop ;; 実装省略
)
(func $add (param $a i32) (param $b i32) (result i32)
(i32.add (local.get $a) (local.get $b))
)
;; 関数型の定義
(type $binomial_t (func (param i32) (param i32) (result i32)))
;; 関数参照として利用できる関数情報の定義
(elem declare func $add)
このように実装することで,関数参照を引数に渡すことができるようになります.
完成したもの
ここまでで紹介した内容を一通り実装し,完成した実装がコードがこちらになります.
このWasmの実行するためのTSの実装がこちらになります.
実行
deno run --allow-read main.ts
> foo
input:foo
> exit
input:exit
goodbye!
ね,簡単でしょ?
閑話
12/20に新宿御苑内のコワーキングスペースを借りてLTイベント新宿御苑.devを開催します.
Discussion