Node.js
WebAssembly

Node.js でつくる WASM トランススレーター - 01:初めてのWASMで定数戻り値を返す

はじめに

Node.jsで小さなプログラミング言語を作ってみるシリーズを、「ミニコンパイラー」「ミニインタープリター」とやってきました。そして三部作(?)の最後として、 WebAssembly(WASM)の基本を理解するために、ミニNode.jsからWASMを生成するトランスレーターに取り組みます。

これまでの取り組み

ミニコンパイラー編のおまけでLLVM IRからWASMに変換して見ましたが、ツールに任せていて中身は理解できていませんでした。

また、余計なコードが大量に出力されています。今回は、必要最低限のWASMコードを生成することを目指します。

目指すもの

今回もプログラミング言語というのにはおこがましいぐらい、小さな仕様を目指します。

  • 扱えるのは32ビット符号付整数のみ
  • 四則演算、余りの計算
  • ローカル変数
    • 配列やハッシュ(連想配列)は使えない
  • if - else
  • ループは while のみ
  • 組込関数として、整数の出力と、固定文字列の出力ができる
  • ユーザ定義関数が使える
  • FizzBuzzと、再帰によるフィボナッチ数列の計算がゴール

ミニコンパイラーと同様に、トランスレーターはミニNode.jsインタープリターで実行できる縛りを設けました。そのためモダンな書き方になっていないのはお見逃しください。

WASTの実行はNode.jsで.wasmファイルを読みんで呼び出す形にします。

初めてのWASM:「1」だけを返すWAST

WASTファイル

WASM(WebAssembly)のテキスト表記は、WASTファイルと呼ばれます。最初の一歩として「1」を返す関数を作ってみます。

one.wast
(module
  ;; ---- export main function  ---
  (export "exported_main" (func $main)) ;; 関数$mainを、exported_main という名前で公開
  (func $main (result i32) ;; i32(符号あり32ビット整数)を返す
    (i32.const 1) ;; スタックに「定数1」を積む
  )
)

WASM内の \$main() 関数を、exported_main()という名前で外部に公開しています。\$main() 関数は、i32(符号あり32ビット整数)を返します。
WASMはスタック型の処理系なので、「定数1」をスタックに積んでおくと関数の戻り値として返すことができます。

WASMファイルへの変換

これを、wasm-asを使ってバイナリ表記のWASMファイルに変換します。先ほどのファイルを one.wast として用意して次のように変換すると、one.wasm が生成されます。

$ wasm-as one.wast

補足: wasm-asのインストール

wasm-asはbinaryenに含まれるツールです。binaryen単独でセットアップするか、あるいはemscripten をセットアップすると、一緒に含まれます。

binaryen 単独のセットアップ

GitHubからソースを取得し、ビルドします。

$ git clone https://github.com/WebAssembly/binaryen.git
$ cd binaryen
$ cmake . && make
$ sudo make install

emscripten のセットアップ

emscriptenの公式サイトを参照して、インストールします。

$ git clone https://github.com/juj/emsdk.git
$ cd emsdk

$ git pull
$ ./emsdk install latest
$ ./emsdk activate latest

wasm-asは、次の場所に含まれます。

  • emsdkインストールディレクトリ/emsdk/clang/バージョン依存のディレクトリ/binaryen/bin/wasm-as

WASM の実行

Node.js v10では、WASMの実行がサポートされています。次のコードを使って、wasmファイルを読み込み、実行してみます。

run_wasm_simple.js
'use strict'

const fs = require('fs');
const filename = process.argv[2]; // 
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);
});
wasmの実行
$ node run_wasm_simple.js
Loading wasm file: one.wasm
ret code=1

exported_main() の戻り値として、1 が返ってきていることが確認できました。one.wast を編集して、再度 wasm-as で wasm に変換、実行すると、修正した値が返ってきます。

修正したone.wast
(module
  ;; ---- export main function  ---
  (export "exported_main" (func $main))
  (func $main (result i32)
    (i32.const 111)
  )
)
実行結果
$ wasm-as one.wast
$ node run_wasm_simple.js one.wasm
Loading wasm file: one.wasm
ret code=111

最初のトランスレーターの実装

それでは、ミニNode.jsのソースコード → WASTファイル のトランスレーターを実装して行きます。最初は、定数を返すだけの処理から始めることにします。

基本の構造

基本的な構造は、ミニコンパイラーと同じにしました。(参考:Node.js でつくる Node.js ミニコンパイラ - 03 : 足し算(+演算子)を実現する

  • esprimaでjsのソースを読み込み、パース
  • simplify()で単純化
  • compile()から再帰的にgenerate()を呼び出して、WASTのテキストを生成、ファイルに保存

対象とするソースコード

次のソースコードを対象にします。

eight.js
8;

これを次のように変換するのがゴールです。

(module
  (export "exported_main" (func $main))
  (func $main (result i32)
    (i32.const 8)
  )
)

ソースのパース

ミニインタープリター、ミニコンパイラーで使ったモジュール(module_parser_13.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 ]

WASTの生成

定数を解釈して、WASTの該当部分を生成するコードは次のようにしました。

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 WAST ---
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() + ')' + LF();
  block = block + ')';

  return block;
}

TAB()やLF()は、ミニインタープリターで実行できるようにグルーバル定数の代わりに関数で固定の文字列を返すために用意しています。本筋にはあまり関係ありません。

function LF() {
  return '\n';
}

function TAB() {
  return '  ';
}

以上の処理をつないで、生成されたWAST文字列をファイルに出力すれば、WASTファイルが完成です。(今回は固定のファイル名 generated.wast にしています)

// --- load and parse source ---
const tree = loadAndParseSrc();

// --- compile to WAST --
const wast = compile(tree);
writeFile('generated.wast', wast);

トランスレーターの実行

トランスレーターの全体のソースを mininode_wasm_01.js としました。

これを使って対象となる eight.js を変換、実行します。

  • mininode_wasm_01.js で、eight.js → generated.wast に変換
  • wasm-as で、 generated.wast → generated.wasm に変換
  • run_wasm_simple.js で、generated.wasm を実行
実行結果
$ node mininode_wasm_01.js eight.js
$ more generated.wast
(module
  (export "exported_main" (func $main))
  (func $main (result i32)
    (i32.const 8)
  )
)
$
$ wasm-as generated.wast
$ 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は、次のような中身です。

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 → WASTヘの変換

まず、今回作成しているトランスレーターで、対象とするjsをWASTファイルに変換します。シェルスクリプトから実行しています。

シェルスクリプト抜粋
TranslateToWast() {
  echo "--- translate src=$jsfile wast=$wast_file translater=$translater ---"
  node $translater $jsfile
  if [ "$?" -eq "0" ]
  then
    echo "translate SUCCERSS"
    mv generated.wast $wast_file
  else
    echo "ERROR! ... translate FAILED !"
    exit 1
  fi
}

ここで、変数の内容は次の通りです。

  • $translater ... テスト対象になっている、(ミニNode.jsで書かれた)トランスレーターのファイル名
  • $jsfile ... テストに使うjsのファイル名
  • $wast_file ... 生成するWASTファイル名

このシェルスクリプトを実行すると、例えば次のような処理が行われます。

$ node mininode_wasm_01.js sample/eight.js
$ mv generated.wast test/tmp/eight.js.wast

WAST → WASM への変換

シェルスクリプト抜粋
WastToWasm() {
  echo "--- wast $wast_file to wasm $wasm_file s--"
   $wast_file
  if [ "$?" -eq "0" ]
  then
    echo "wasm-as SUCCERSS"
  else
    echo "ERROR! ... wasm-as FAILED !"
    exit 1
  fi
}

変数の内容は次の通りです。

  • $wasmas ... wasm-as のパス。パスを通しておくか、事前に環境変数WASMAS_FOR_TESTにフルパスを設定しておく
  • $wast_file ... 変換するWASTファイル名
  • $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_14.js mininode_wasm_01.js sample/eight.js
$ mv generated.wast test/tmp/inetep_eight.js.wast
$ wasm-as test/tmp/inetep_eight.js.wast
$ node run_wasm_simple.js test/tmp/inetep_eight.js.wasm
  • mininode_14.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 ... 終了コードに違いがあったことを記録しておくテキストファイル

テストコードはこちらにあります。

次のように実行します。(引数は、トランスレーター, ミニインタープリター, 対象ソース)

$ cd test
$ sh test_exitcode.sh mininode_wasm_01.js mininode_14.js eight.js
--- translate src=../sample/eight.js wast=tmp/eight.js.wast 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にソースを上げておきます。