はじめに
Node.jsで小さなプログラミング言語を作ってみるシリーズを、「ミニコンパイラー」「ミニインタープリター」とやってきました。そして三部作(?)の最後として、 ミニNode.jsからWASMを生成する小さなコンパイラーに取り組んでいます。
今回の目的
前回で目標としていたNode.js-WASMコンパイラーの最低限の実装が終わりました。今回は生成したWASMをいろいろな環境で動かすべく WASI(WebAssembly System Interface)に対応させたいと思います。
WASI とは
WASIはWebAssemblyをウェブ以外の場所(ブラウザやNode.js以外の環境)で動かせる様にする取り組みです。
- WebAssembly/WASI ... https://github.com/WebAssembly/WASI
- WASI の標準化: WebAssembly をウェブの外で使うためのシステムインターフェース (翻訳)
WASMのコードを、いろいろなプラアットフォーム上で動かせる様にシステムコールに相当するAPIを標準化する試みです。様々なランタイムが実装されていて、CDNのエッジサーバーや組み込みデバイスで動かす試みもあります。
- wasmtime ... Rustで作られた、リファレンス的なランタイム環境
- lucet ... Fastlyが取り組んでいる、CDNエッジ上でWASMを実行することを目指したランタイム
- WebAssembly Micro Runtime ... 組み込みでも使えることを目指した、軽量ランタイム(JITコンパイラーなし、インタープリターのみ)
WASIを使えば、将来的にCDN上や組み込みデバイス上でWASMを実行できるはずです。ワクワクしますね。
WASIで使える関数
WASIではOSを抽象化して、ファイルやネットワークなどの入出力にアクセスできるようになります。実際にサポートされるAPIはこちらにまとめられています。
これを見ると、「System Interface」と言うだけあってC言語のprintf()やputs()などは存在せず、よりプリミティブな関数がサポートされています。今回のミニWASMコンパイラーの組み込み関数putn()/puts()を実現するために、次の関数を利用することにします。
Hello, WASI
WASI の実行環境
今回はWASIの実行にwasmtimeを使います。ビルドにはRustとcargoが必要です。
$ git clone --recurse-submodules https://github.com/bytecodealliance/wasmtime.git
$ cd wasmtime
$ cargo build --release
$ ./target/release/wasmtime --version
0.7.0
文字列の出力
さっそくWASIを使った文字列出力にチャンレンジしてみます。次のWATファイルを用意しました。
(module
;; -- WASIの fd_write()をインポイートするため宣言 --
;; fd_write(File Descriptor, *iovs, iovs_len, nwritten)
;; -> Returns number of bytes written
(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory 1)
(export "memory" (memory 0))
(data (i32.const 16) "Hello WASI\n") ;; 'Hello WASI\n' をメモリ上に確保 (offset 16 bytes, length 11 bytes)
;; -- メイン関数は _start() としてエクスポート --
(func $main (export "_start")
;; iov (バッファーのアドレスと、長さのセット)をメモリ上に用意
(i32.store (i32.const 0) (i32.const 16)) ;; バアッファーの先頭アドレス(=offset)
(i32.store (i32.const 4) (i32.const 11)) ;; バッファーの長さ
(call $fd_write
(i32.const 1) ;; ファイルでスクリプタ - 1:stdout
(i32.const 0) ;; iovのセットへのアドレス
(i32.const 1) ;; iovのセットの長さ - [buffer, length]のセットの数
(i32.const 8) ;; *nwritten - 出力されたバイト数を受け取るポインター
)
drop ;; 戻り値として出力されたバイト数が帰ってきているので、それを破棄
)
)
- fd_write()関数をインポート
- エントリーポイントとなるメイン関数を _start() という名前でエクスポート
- 出力する文字列をメモリ上に確保
- 「文字列バッファーの先頭アドレスと、その長さ」のセットをメモリ上に確保 ... iov
- 先のiovのアドレスと、そのセット数を指定して、fd_write()を呼び出す
実行結果はこちら
$ wasmtime hello_wasi.wat
Hello WASI
無事出力されました。
WASI対応コンパイラー
これまで作った Node.js-WASMコンパイラー mininode_wasm_08.jsを、WASI向けに改造します。
WASI関数のインポート
いままでは呼び出し側(Node.js)でputn(), puts()の実体を用意したものをWASM内部でインポートしていました。その代わりにWASIのランタイムからfd_write()をインポートします。
// ---- compile simplified tree into WAT ---
function compile(tree, gctx, lctx) {
// ... 省略 ...
let block = '(module' + LF();
// -- builtin func (imports) --
block = block + TAB() + ';; ---- builtin func imports ---' + LF();
// --- normal WASM ---
//block = block + TAB() + '(func $putn (import "imports" "imported_putn") (param i32))' + LF();
//block = block + TAB() + '(func $puts (import "imports" "imported_puts") (param i32))' + LF();
// --- WASI ---
block = block + TAB() + '(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))' + LF();
// ... 省略 ...
}
メイン関数の宣言
メイン関数のエクスポート宣言部分も_start()に変更します。
// ---- compile simplified tree into WAT ---
function compile(tree, gctx, lctx) {
// ... 省略 ...
block = block + TAB() + ';; ---- export main function ---' + LF();
// --- normal WASM ---
//block = block + TAB() + '(export "exported_main" (func $main))' + LF();
// --- WASI ---
block = block + TAB() + '(export "_start" (func $main))' + LF();
// ... 省略 ...
}
整数の出力 putn()
インポートしたfd_write()を内部で呼び出して、符号付32ビット整数を表示するputn()関数を作ります。あらかじめWATで記述した別ファイルを用意した関数を用意しておき、コンパイラでWATを生成する際に連結する方式ににします。
putn() は内部で次の処理を行います。
- (1) 整数値を文字列で表現した時の桁数を算出する
- この時、マイナス値の場合はマイナス記号分もカウントする
- (2) 整数値がマイナスの場合は絶対値をとる
- (3) 一桁ずつ取り出し、1文字のASCIIキャラクターに変換、メモリー上に格納する
- 例) 1 --> 49 (0x31)
- (4) 最後に改行文字(`\n')を入れる、マイナス値だったら先頭にマイナス記号を格納する
- (5) fd_write()を呼び出すためのパラメーターをメモリー領域に準備する
- パラメーターは、あらかじめメモリー上の決まった位置にダミーの値で確保
- 毎回値を書き換えて使う
- (6) fd_write()を呼びだす
これを全てWATで手書きするのはなかなか骨が折れます。そこで、一部をJSで記述して、前回までのコンパイラーを使ってWATを生成することにしました。直接メモリーをいじったり関数を呼び出すところはコンパイラーでサポートしていないので、手書きすることになります。
整数を文字列に変換する部分を _convI32ToString() として抜き出してJSで実装します。担当する処理は上記(1)~(3)の範囲です。
function _convI32ToString(n) {
let restValue = n;
let isMinus = 0;
let dummy;
if (_isMinus(n)) {
restValue = -n;
isMinus = 1;
dummy = _storeChar(0, 45); // minus mark '-'
}
let len = _calcLength(restValue);
let idx = len - 1;
let digitChar = 0;
while (idx >= 0) {
digitChar = _getOneDigit(restValue);
_storeChar(idx + isMinus, digitChar);
restValue = _div10(restValue);
idx = idx - 1;
}
return len + isMinus;
}
実際にはさらに次の内部関数を呼び処理を行っています。
- _calcLength() ... 整数が文字列にした場合に何桁になるかを算出
- _getOneDigit() ... 整数の一の位をASCIIコードに変換
- _div10() ... 整数を1/10にする
- _isMinus() ... 整数がマイナス値かどうかを判定
- _storeChar() ... 1文字分をメモリーに格納するダミー
- 実際にはWASMのメモリーに値を格納する処理に後で置き換える
// calc char length of int32
// NOT support minus
function _calcLength(n) {
let restValue = n;
let len = 1;
while (restValue >= 10) {
restValue = restValue / 10;
len = len + 1;
}
return len;
}
// get 1 digit char code
function _getOneDigit(n) {
const r = n % 10;
const c = 48 + r; // '0' + r
return c;
}
// div 10
function _div10(n) {
const d = n / 10; // calc as int
return d;
}
// --- for node direct ---
//let _strBuf = '....................';
function _storeChar(idx, charCode) {
puts(' _storeChar() called. idx, charCode bellow');
putn(idx);
putn(charCode);
/* --- for Node.js direct --- */
//let ch = String.fromCharCode(charCode);
//_strBuf = _strBuf.slice(0, idx) + ch + _strBuf.slice(idx + 1);
return 0;
}
function _isMinus(n) {
if (n < 0) {
return 1;
}
return 0;
}
前回のWASMコンパイラー mininode_wasm_08.jsでコンパイルした結果の抜粋はこちらです。実際にはこれを手で修正して利用しています。
(func $_calcLength (param $n i32) (result i32)
(local $restValue i32)
(local $len i32)
get_local $n
set_local $restValue
i32.const 1
set_local $len
loop ;; --begin of while loop--
get_local $restValue
i32.const 10
i32.ge_s
if
get_local $restValue
i32.const 10
i32.div_s
set_local $restValue
get_local $len
i32.const 1
i32.add
set_local $len
br 1 ;; --jump to head of while loop--
end ;; end of if-then
end ;; --end of while loop--
get_local $len
return
i32.const 88
return
)
(func $_div10 (param $n i32) (result i32)
(local $d i32)
get_local $n
i32.const 10
i32.div_s
set_local $d
get_local $d
return
i32.const 88
return
)
(func $_convI32ToString (param $n i32) (result i32)
(local $restValue i32)
(local $isMinus i32)
(local $dummy i32)
(local $len i32)
(local $idx i32)
(local $digitChar i32)
get_local $n
set_local $restValue
i32.const 0
set_local $isMinus
get_local $n
call $_isMinus
if
i32.const 0
get_local $n
i32.sub
set_local $restValue
i32.const 1
set_local $isMinus
i32.const 0
i32.const 45
call $_storeChar
set_local $dummy
end
get_local $restValue
call $_calcLength
set_local $len
get_local $len
i32.const 1
i32.sub
set_local $idx
i32.const 0
set_local $digitChar
loop ;; --begin of while loop--
get_local $idx
i32.const 0
i32.ge_s
if
get_local $restValue
call $_getOneDigit
set_local $digitChar
get_local $idx
get_local $isMinus
i32.add
get_local $digitChar
call $_storeChar
get_local $restValue
call $_div10
set_local $restValue
get_local $idx
i32.const 1
i32.sub
set_local $idx
br 1 ;; --jump to head of while loop--
end ;; end of if-then
end ;; --end of while loop--
get_local $len
get_local $isMinus
i32.add
return
)
この生成した関数を使って、putn()を実現します。
(func $putn(param $n i32)
(local $strLen i32)
get_local $n
call $_convI32ToString ;; ret=Lenght
set_local $strLen
;; write tail LF
i32.const 12 ;; head of string buffer
get_local $strLen
i32.add
i32.const 10 ;; LF
i32.store8
;; +1 length for tail LF
get_local $strLen
i32.const 1
i32.add
set_local $strLen
;; iov.iov_base
i32.const 4
i32.const 12
i32.store
;; iov.iov_len
i32.const 8
get_local $strLen
i32.store
;; $fd_write
i32.const 1 ;; file_descriptor - 1 for stdout
i32.const 4 ;; *iovs - The pointer to the iov array, which is stored at memory location 0
i32.const 1 ;; iovs_len - We're printing 1 string stored in an iov - so one.
i32.const 0 ;; nwritten - A place in memory to store the number of bytes writen
call $fd_write
drop ;; Discard the number of bytes written from the top the stack
)
文字列の出力 puts()
同様に、固定文字列を表示するputs()関数も作ります。puts() は内部で次の処理を行います。
- (1) 出力する文字列のアドレスを受け取る
- (2) 別のメモリー領域に文字列をコピーする
- (3) 最後に改行文字(`\n')を入れる
- (4) fd_write()を呼び出すためのパラメーターをメモリー領域に準備する
- パラメーターは、あらかじめメモリー上の決まった位置にダミーの値で確保
- 毎回値を書き換えて使う
- (5) fd_write()を呼びだす
これを全てWATで手書きするのはなかなか骨が折れます。そこで、一部をJSで記述して、前回までのコンパイラーを使ってWATを生成することにしました。直接メモリーをいじったり関数を呼び出すところはコンパイラーでサポートしていないので、手書きすることになります。
今回のputs()の例では「(2)別のメモリー領域に文字列をコピーする」部分をJSファイルで書いてからコンパイラーで生成したものを参考にし、残りは手書きで作りました。
(func $puts (param $n i32)
(local $srcIdx i32)
(local $destIdx i32)
(local $len i32)
(local $c i32)
get_local $n
set_local $srcIdx
i32.const 0
set_local $destIdx
i32.const 0
set_local $len
get_local $srcIdx
call $_loadChar
set_local $c
loop ;; --begin of while loop--
get_local $c
if
get_local $destIdx
get_local $c
call $_storeChar
get_local $len
i32.const 1
i32.add
set_local $len
get_local $srcIdx
i32.const 1
i32.add
set_local $srcIdx
get_local $destIdx
i32.const 1
i32.add
set_local $destIdx
get_local $srcIdx
call $_loadChar
set_local $c
;; check lenght 255
get_local $destIdx
i32.const 255
i32.lt_s
br_if 1
;; br 1 ;; --jump to head of while loop--
end ;; end of if-then
end ;; --end of while loop--
;;get_local $len
;;call $putn
;; tail LF
get_local $destIdx
i32.const 10 ;; LF
call $_storeChar
get_local $len
i32.const 1
i32.add
set_local $len
;; iov.iov_base
i32.const 4
i32.const 12
i32.store
;; iov.iov_len
i32.const 8
get_local $len
i32.store
;; $fd_write
i32.const 1 ;; file_descriptor - 1 for stdout
i32.const 4 ;; *iovs - The pointer to the iov array, which is stored at memory location 0
i32.const 1 ;; iovs_len - We're printing 1 string stored in an iov - so one.
i32.const 0 ;; nwritten - A place in memory to store the number of bytes writen
call $fd_write
drop ;; Discard the number of bytes written from the top the stack
)
WASI対応コンパイラーの拡張
テンプレートの用意
用意したビルトイン関数putn(), puts()はこちらの別ファイルに保存しておき、コンパイラーで読み込んで使います。
テンプレート読み込みモジュール
今回のミニNode.js-WASMコンパイラーでは、最初に作っていた「ミニインタープリター」で動かす、という縛りを設けています。ミニインタープリターではファイルの読み書きを直接はサポートしておらず、外部モジュールとして準備しています。なので今回のテンプレートファイルも外部モジュールを用意してそちらで読み込みます。
// -------------------------
// module_wasibuiltin.js - WASM builtin for WASI
// - puts()
// - putn()
// -------------------------
'use strict'
const fs = require('fs');
const println = require('./module_println.js');
const abort = require('./module_abort.js');
const printWarn = require('./module_printwarn.js');
const builtinTamplateFile = 'wasi_builtin_template.watx';
// === exports ===
// --- parser ----
module.exports = wasiBuiltin;
function wasiBuiltin() {
const builtinFuncs = fs.readFileSync(builtinTamplateFile, 'utf-8');
//println(builtinFuncs);
return builtinFuncs;
}
fd_write()呼び出し用のパラメータ領域
fd_write()の呼び出しで使うパラメータをメモリ上に確保しておきます。
- オフセット位置 0バイト目から、4バイト分 ... 実際に出力したバイト数を受け取るための領域
- オフセット位置 4バイト目から、4バイト分 ... 出力するバイト列の組の最初のアドレスを格納する領域
- オフセット位置 8バイト目から、4バイト分 ... 出力するバイト列の組の数
- オフセット位置 12バイト目から、255バイト分 ... 出力するバイト列を格納する領域
function generateMemoryBlock() {
let block = '';
block = block + TAB() + '(memory 1)' + LF();
block = block + TAB() + '(export "memory" (memory 0))' + LF();
block = block + TAB() + '(data (i32.const 0) "\\00\\00\\00\\00") ;; placeholder for nwritten - A place in memory to store the number of bytes written' + LF();
block = block + TAB() + '(data (i32.const 4) "\\00\\00\\00\\00") ;; placeholder for iov.iov_base (pointer to start of string)' + LF();
block = block + TAB() + '(data (i32.const 8) "\\00\\00\\00\\00") ;; placeholder for iovs_len (length of string)' + LF();
block = block + TAB() + '(data (i32.const 12) "hello world\\n") ;; 4--> iov.iov_base = 12, 4--> iov_len = 8, 12-->"hello ...":len=13' + LF();
return block;
}
この領域をputn(), puts()で利用しています。
テンプレートの連結
コンパイラーでWATファイルを生成する際に、ユーザ定義関数に引き続きテンプレートとして用意しておいたputn(), puts()のWATコードを連結して出力します。
function compile(tree, gctx, lctx) {
// ... 省略 ...
// ---- global user_defined functions ---
block = block + generateGlobalFunctions(gctx);
// ---- builtin function for wasi ---
block = block + wasiBuiltin();
// --- close all ---
block = block + ')';
return block;
}
WASI向けのコンパイル&実行
今回作ったコンパイラーはこちらです。
これを使って、これまでのサンプルをコンパイル、wasmtimeを使って実行してみましょう。(wasmtimeはテキスト形式の.wat、バイナリ形式の.wasmの両方を実行することができます)
FizzBuffの例
$ node mininode_wasm_wasi.js sample/fizzbuzz_func.js
$ wasmtime generated.wat
1
2
Fizz
4
Buzz
Fizz
7
... 省略 ...
94
Buzz
Fizz
97
98
Fizz
Buzz
$
WASIランタイム上で、無事FizzBuzzを実行できました!
ここまでのソース
GitHubにソースを上げておきます。
- GitHubのレポジトリ ... https://github.com/mganeko/mini_node_wasm
- WASI向けコンパイラー ... mininode_wasm_wasi.js
- WASI向けビルトイン関数を生成するモジュール ... module_wasibuiltin.js
- ビルトイン関数のためのWATテンプレート ... wasi_builtin_template.watx
- WASI対応コンパイラーのテストスクリプト(1ファイル) ... test/test_wasi_stdout.sh
- WASI対応コンパイラーのテストスクリプト(まとめて) ... test_wasi.sh