はじめに
Node.jsで小さなプログラミング言語を作ってみるシリーズを、「ミニインタープリター」「ミニコンパイラー」とやってきました。そして三部作(?)の最後として、 WebAssembly(WASM)の基本を理解するために、ミニNode.jsからWASMを生成する小さなコンパイラーに取り組みます。
※以前の記事「Node.js でつくる WASM トランスパイラー - 01:初めてのWASMで定数戻り値を返す」のアップデート版になります
###これまでの取り組み
- Node.jsでつくるNode.js(ミニインタープリター) - もくじ
- Node.jsでつくるNode.jsミニコンパイラ - もくじ
- Node.jsでつくるNode.js-WASMコンパイラ - もくじ
ミニコンパイラー編のおまけでLLVM IRからWASMに変換して見ましたが、ツールに任せていて中身は理解できていませんでした。
また、余計なコードが大量に出力されています。今回は、必要最低限のWASMコードを生成することを目指します。
目指すもの
今回もプログラミング言語というのにはおこがましいぐらい、小さな仕様を目指します。
- 扱えるのは32ビット符号付整数のみ
- 四則演算、余りの計算
- ローカル変数
- 配列やハッシュ(連想配列)は使えない
- if - else
- ループは while のみ
- 組込関数として、整数の出力と、固定文字列の出力ができる
- ユーザ定義関数が使える
- FizzBuzzと、再帰によるフィボナッチ数列の計算がゴール
ミニコンパイラーと同様に、コンパイラーはミニNode.jsインタープリターで実行できる縛りを設けました。そのためモダンな書き方になっていないのはお見逃しください。
WASMの実行はNode.jsで.wasmファイルを読みんで呼び出す形にします。
前提環境
今回の記事は、macOS Mojave 10.14 を前提にしてます。Linux系OSでも同様に利用できると思います。Windowsの場合の環境構築方法については調査していません。あしからず。
また node v10.x で動作確認しています。
初めてのWASM:「1」だけを返すWAT
WATファイル
WASM(WebAssembly)のテキスト表記は、WAT(またはWAST)ファイルと呼ばれます。このシリーズでは、次にように区別して呼び分けます。
- WAT ... WebAssemblyのテキスト表記。プリミティブな表現でスタックに値を積んで操作する
- WAST ... S式を使ったWebAssembyのテキスト表記。カッコで括ることでスタックを意識させない
それでは、最初の一歩として「1」を返す関数を作ってみます。
(module
;; ---- export main function ---
(export "exported_main" (func $main)) ;; 関数$mainを、exported_main という名前で公開
(func $main (result i32) ;; i32(符号あり32ビット整数)を返す
i32.const 1 ;; スタックに「定数1」を積む
return ;; スタックの値を返す
)
)
WASM内の $main() 関数を、exported_main()という名前で外部に公開しています。$main() 関数は、i32(符号あり32ビット整数)を返します。
WASMはスタック型の処理系なので、「定数1」をスタックに積んでから、関数の戻り値として返しています。
WASMファイルへの変換
これを wat2wasm を使ってバイナリ表記のWASMファイルに変換します。先ほどのファイルを one.wat として用意して次のように変換すると、one.wasm が生成されます。
$ wat2wasm one.wat
補足: wat2wasm のセットアップ
wat2wasm は WebAssembly/wabt に含まれるツールです。
WebAssembly/wabt のインストール
GitHubからソースを取得し、ビルドします。ビルドには cmake が必要です。
- WebAssembly/wabt の GitHubのページ ... https://github.com/WebAssembly/wabt
$ git clone --recursive https://github.com/WebAssembly/wabt
$ cd wabt
$ mkdir build
$ cd build
$ cmake ..
$ cmake --build .
ビルド後、必要に応じてパスを通しておきます。
WASM の実行
Node.js v10では、WASMの実行がサポートされています。次のコードを使って、wasmファイルを読み込み実行します。
'use strict'
const fs = require('fs');
const filename = process.argv[2]; // 対象とするwasmファイル名
console.warn('Loading wasm file: ' + filename);
let source = fs.readFileSync(filename);
let typedArray = new Uint8Array(source);
let ret = null;
WebAssembly.instantiate(typedArray,
{} // 実行時の環境
).then(result => {
ret = result.instance.exports.exported_main();
console.warn('ret code=' + ret);
process.exit(ret);
}).catch(e => {
console.log(e);
});
$ node run_wasm_simple.js
Loading wasm file: one.wasm
ret code=1
exported_main() の戻り値として、1 が返ってきていることが確認できました。one.wat を編集して、再度 wat2wasm で wasm に変換して実行すると、修正した値が返ってきます。
(module
;; ---- export main function ---
(export "exported_main" (func $main))
(func $main (result i32)
i32.const 111
return
)
)
$ wat2wasm one.wat
$ node run_wasm_simple.js one.wasm
Loading wasm file: one.wasm
ret code=111
最初のコンパイラーの実装
それでは、ミニNode.jsのソースコード → WATファイル のコンパイラーを実装して行きます。最初は、定数を返すだけの処理から始めることにします。
基本の構造
基本的な構造は、ミニコンパイラーと同じにしました。(参考:Node.js でつくる Node.js ミニコンパイラ - 03 : 足し算(+演算子)を実現する)
- esprimaでjsのソースを読み込み、パース
- simplify()で単純化
- compile()から再帰的にgenerate()を呼び出して、WATのテキストを生成、ファイルに保存
対象とするソースコード
次のソースコードを対象にします。
8;
これを次のように変換するのがゴールです。
(module
(export "exported_main" (func $main))
(func $main (result i32)
i32.const 8
return
)
)
ソースのパース
ミニインタープリター、ミニコンパイラーで使ったモジュール(module_parser_13.js)をベースに拡張したモジュール(module_parser_15.js)を使って、ソースコードをパースし単純化します。このモジュールは、内部で esprima を使ってJavaScriptのソースコードをパースしています。
- 参考:Node.jsでつくるNode.js - Step 1:ソースのパース
- 参考;Node.jsでつくるNode.js - Step2:単純化
- 参考:Node.jsでつくるNode.js - Step 10の パースを行う loadAndParseSrc()
esmprima は npm等でインストールしておいてください。
$ npm install esprima
const tree = loadAndParseSrc();
今回は、次のような単純化したAST(抽象構文木)が取得できます。
[ 'lit', 8 ]
WATの生成
定数を解釈して、WATの該当部分を生成するコードは次のようにしました。
function generate(tree) {
if (tree === null) {
return '';
}
if (tree[0] === 'lit') {
const v = tree[1];
const block = 'i32.const ' + v;
return block;
}
println('-- ERROR: unknown node in generate() ---');
printObj(tree);
abort();
}
さらにこ前後の箇所を付け加えて全体を生成します。
// ---- compile simplified tree into WAT ---
function compile(tree) {
const mainBlock = generate(tree);
let block = '(module' + LF();
block = block + TAB() + '(export "exported_main" (func $main))' + LF();
block = block + TAB() + '(func $main (result i32)' + LF();
block = block + TAB() + TAB() + mainBlock + LF();
block = block + TAB() + TAB() + 'return' + LF();
block = block + TAB() + ')' + LF();
block = block + ')';
return block;
}
TAB()やLF()は、ミニインタープリターで実行できるようにグルーバル定数の代わりに関数で固定の文字列を返すために用意しています。本筋にはあまり関係ありません。
function LF() {
return '\n';
}
function TAB() {
return ' ';
}
以上の処理をつないで、生成されたWAT文字列をファイルに出力すれば、WATファイルが完成です。(今回は固定のファイル名 generated.wat にしています)
// --- load and parse source ---
const tree = loadAndParseSrc();
// --- compile to WAT --
const wat = compile(tree);
writeFile('generated.wat', wat);
コンパイラーの実行
コンパイラーの全体のソースを mininode_wasm_01.js としました。
- GitHub mininode_wasm_01.js
これを使って対象となる eight.js を変換、実行します。
- mininode_wasm_01.js で、eight.js → generated.wat に変換
- wat2wasm で、 generated.wat → generated.wasm に変換
- run_wasm_simple.js で、generated.wasm を実行
$ node mininode_wasm_01.js eight.js
$ more generated.wat
(module
(export "exported_main" (func $main))
(func $main (result i32)
i32.const 8
return
)
)
$
$ wat2wasm generated.wat
$ node run_wasm_simple.js generated.wasm
Loading wasm file: generated.wasm
ret code=8
戻り値として「8」がちゃんと返ってきました。
テストの整備
ミニインタープリター編ではテストなし、ミニコンパイラー編では最後になってからテストを用意しましたが、今回は最初から準備しておきます。今回もEnd-to-Endのテストとして、次の3つを比較することにしました。
- Node.jsで直接実行したときの終了コード
- ミニNode.jsコンパイラーで生成したWASMを、Node.jsで実行したときの戻り値
- ミニNode.jsインタープリターからコンパイラーを実行して生成されたWASMを、Node.jsで実行したときの戻り値
Node.jsで直接実行
前処理
実行対象のソースコードeight.jsは、次のような中身です。
8;
これをNode.jsで実行することはできますが、何も起こらず終了コードも「0」(ゼロ)になります。そこでソースコードを次のように変換してから実行してやることで、終了コードとして「8」を取得できるようにします。
process.exit(8);
前処理はシェルスクリプトで行っています。
PreprocessForDirect() {
echo "-- preprocess for exit code: src=$jsfile tmp=$direct_file --"
echo "process.exit(" > $direct_file
cat $jsfile | sed -e "s/;\$//" >> $direct_file # remove ';' at line end
echo ");" >> $direct_file
}
ここで、変数は次を保持しています。
- $jsfile ... テストに使うjsのファイル名
- $direct_file ... Node.jsでの直接実行用に一時的に変換したファイル名
直接実行
前処理が終わったら、Node.jsで直接実行して終了コードを覚えておきます。こちらもシェルスクリプト内で実行します。
NodeDirect() {
echo "-- node $src --"
node $direct_file
direct_exit=$?
echo "direct exit code=$direct_exit"
}
- $direct_exit ... Node.jsでの直接実行の終了コードを覚えておく変数
WASMの生成と実行
js → WATヘの変換
まず、今回作成しているコンパイラーで、対象とするjsをWATファイルに変換します。シェルスクリプトから実行しています。
TranslateToWat() {
echo "--- translate src=$jsfile wat=$wat_file translater=$translater ---"
node $translater $jsfile
if [ "$?" -eq "0" ]
then
echo "translate SUCCERSS"
mv generated.wat $wat_file
else
echo "ERROR! ... translate FAILED !"
exit 1
fi
}
ここで、変数の内容は次の通りです。
- $translater ... テスト対象になっている、(ミニNode.jsで書かれた)コンパイラーのファイル名
- $jsfile ... テストに使うjsのファイル名
- $wat_file ... 生成するWATファイル名
このシェルスクリプトを実行すると、例えば次のような処理が行われます。
$ node mininode_wasm_01.js sample/eight.js
$ mv generated.wat test/tmp/eight.js.wat
WAT → WASM への変換
WatToWasm() {
echo "--- wat $wat_file to wasm $wasm_file --"
$wat_to_wasm $wat_file -o $wasm_file
if [ "$?" -eq "0" ]
then
echo "wat2wasm SUCCERSS"
else
echo "ERROR! ... wat2wasm FAILED !"
exit 1
fi
}
変数の内容は次の通りです。
- $wat_to_wasm ... wat2wasm コマンド。パスを通しておくか、事前に環境変数 WAT2WASM_FOR_TEST にフルパスを設定しておく
- $wat_file ... 変換するWATファイル名
- $wasm_file ... 変換後のWASMファイル名
WASMの実行結果
生成したWASMを実行するには、この記事の「 WASM の実行」で準備したrun_wasm_simple.js を利用します。
ExecWasm() {
echo "--- exec $wasm_file from node"
node $wasm_exec $wasm_file
wasm_exit=$?
echo "wasm exit code=$wasm_exit"
}
変数の内容は次の通りです。
- $wasm_exec ... wasmの実行に使うNode.jsのコード。今回はrun_wasm_simple.jsを利用
- $wasm_file ... 実行するWASMファイル名
- $wasm_exit ... wasmの戻り値を保持する
このシェルスクリプトを実行すると、例えば次のような処理が行われます。
$ node run_wasm_simple.js test/tmp/eight.js.wasm
ミニインタープリターを使ったWASM生成
今回のコンパイラーは、以前つくったミニNode.jsインタープリターで動くことを縛りにしています。(なのでモダンな書き方はしていません、という言い訳)
詳細は省略しますが、例えば次のような処理を行って、もう一つWASMファイルを生成、実行しています。
$ node mininode_15.js mininode_wasm_01.js sample/eight.js
$ mv generated.wat test/tmp/inetep_eight.js.wat
$ watwasm test/tmp/inetep_eight.js.wat
$ node run_wasm_simple.js test/tmp/inetep_eight.js.wasm
- mininode_15.js ... ミニNode.jsインタープリター
- mininode_wasm_01.js ... テスト対象のコンパイラー
終了コードの比較
シェルスクリプトで、終了コードを比較します。
CompareExitCode() {
if [ "$direct_exit" -eq "$wasm_exit" ]
then
echo "OK ... node <-> wasm exit code match: $direct_exit == $wasm_exit"
else
echo "ERROR! ... node <-> wasm exit code NOT MATCH : $direct_exit != $wasm_exit"
echo "ERROR! ... node <-> wasm exit code NOT MATCH : $direct_exit != $wasm_exit" > $exitcode_file
exit 1
fi
if [ "$direct_exit" -eq "$interp_wasm_exit" ]
then
echo "OK ... node <-> interp-wasm exit code match: $direct_exit == $interp_wasm_exit"
else
echo "ERROR! ... node <-> interp-wasm exit code NOT MATCH : $direct_exit != $interp_wasm_exit"
echo "ERROR! ... node <-> interp-wasm exit code NOT MATCH : $direct_exit != $interp_wasm_exit" > $exitcode_file
exit 1
fi
}
変数の内容は次の通りです。
- $direct_exit ... Node.jsでの直接実行の終了コード
- $wasm_exit ... wasmの戻り値
- $interp_wasm_exit ... ミニインタープリターからコンパイラーを実行して生成したwasmの戻り値
- $exitcode_file ... 終了コードに違いがあったことを記録しておくテキストファイル
テストコードはこちらにあります。
- GitHub test/test_exitcode.sh
次のように実行します。(引数は、コンパイラー, ミニインタープリター, 対象ソース)
$ cd test
$ sh test_exitcode.sh mininode_wasm_01.js mininode_15.js eight.js
--- translate src=../sample/eight.js wat=tmp/eight.js.wat translater=../mininode_wasm_01.js---
-- start WASM translator --
Loading src file:../sample/eight.js _argIndex=2
... 省略 ...
direct exit code=8
OK ... node <-> wasm exit code match: 8 == 8
OK ... node <-> interp-wasm exit code match: 8 == 8
次回は
次は四則演算と、余りを計算できるようにする予定です。
これまでのソース
GitHubにソースを上げておきます。
- GitHubのレポジトリ ... https://github.com/mganeko/mini_node_wasm
- mininode_wasm_01.js ... 今回のWASMコンパイラー
- mininode_15.js ... Node.jsミニインタープリター
- module_parser_15.js ... ミニインタープリター、ミニコンパイラー、WASMコンパイラーで共通に使うパーサー
- module_xxxx ... ミニインタープリターやWASMコンパイラーで使うモジュール類
- run_wasm_simple.js ... WASMの実行に使うソース。終了コードを取得
- sample/one.js ... 変換対象のソース
- sample/eight.js ... 変換対象のソース
- test/test_exitcode.sh ... テストに使うシェルスクリプト