7
5

More than 1 year has passed since last update.

WebAssemblyでyesコマンドを作ってみる

Last updated at Posted at 2022-04-06

TL; DR

wasmer yes.wasm
y
y
y
...

はじめに

「入門WebAssembly」では、watファイル (WebAssembly Text) 手書きでプログラミングをする方法が紹介されています。

読んだら自分でも何か作りたくなったので、そこそこ簡単そうな yes コマンドに挑戦してみました。

なぜ yesコマンド?

  • helloworldの次くらいに簡単そう (やってみたら意外と難しかった)
  • ブラウザ以外でもwasmを使ってみたい (WASI)
  • CLIとして WAPM に公開したい

WAPMへの公開の仕方については後日別の記事にする予定です。

(4/7更新) 記事にしました。

作ったもの

使い方はGNUの yes コマンドと同じです。

# 無限にyを出力
$ wasmer yes.wasm
y
y
y
...

# 引数で自分の好きな言葉を指定可能
$ wasmer yes.wasm no!
no!
no!
no!
no!
no!

watファイルを手書きしたことで、ファイルサイズもかなり小さくなりました!

wasmファイル サイズ
今回作ったもの 394B
(参考)TinyGoをコンパイル 464kB

ツールの準備

wasm生成: wat2wasm

CIではインストールが楽な node製のものを使用しています。

$ npm install wat2wasm
$ npx wat2wasm yes.wat

wasm実行: wasmer

ブラウザ以外でwasmを実行できるようにランタイムを用意します。今回はWasmerを使用しました 2

実装

yを無限に出力する

まずは yes のメイン機能、 y の無限出力を実装します。

WASI

「文字の出力」はWebAssemblyでは簡単ではありません...
OSのシステムコールを呼ぶためにWASIが必要です。

使用する関数をimport

標準出力には fd_write 関数を使用します。

仕様書では引数/戻り値の型がRustのような形式で書かれているので、WebAssemblyの形式に読み替える必要があります。
まだ規則を完全に把握できていませんが、

  • 構造体は線形メモリ上のポインタ (i32) で表現
  • 配列は線形メモリ上のポインタと要素数を2つの引数 (i32, i32) で表現
  • Resultはwasm上表現不能なので、最後以外の型パラメータは引数で表現

という変換がなされているようです。
(間違いがあればコメントいただけるとありがたいです :pray:

型定義
fd_write(fd: fd, iovs: ciovec_array) -> Result<size, errno>

fd: ポインタをi32で表現
ciovec_array: ポインタをi32, 長さをi32で表現
size: 戻り値で返せないので引数に含める。ポインタをi32で表現

wasm上の関数型
;;              (*fd, *iovs, iovs_len, *size) (errno)
(func $fd_write (param i32 i32 i32 i32) (result i32))

参考

上記の型で fd_write をimportします。

yes.wat
(module
  (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
  ;; ...
)

線形メモリを確保

次に、fd_array の引数 fd, iovs, size の実体をメモリ上に確保できるようにします。

仕様書の Size を見ると線形メモリの必要領域がわかるので、被らないように割り当てます。offsetごとの用途についても Record members に書かれているので、指定された内容を保存します。

fd: https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-fd-handle
ciovec: https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-ciovec-record

以下の記事も参考にさせていただき、見よう見まねで作ってみました。

yes.wat
(module
  (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

  ;; メモリの初期化、エクスポート
  (memory 1)
  (export "memory" (memory 0))

  ;; 出力したい文字列を メモリ[16:18] に保存
  (data (i32.const 16) "y\n")

  ;; CLIツールとしてwasmを実行する場合、 `_start` がメイン関数として起動
  (func $main (export "_start")
    (local $errno i32)

    ;; io vector を メモリ[0:8] に保存 (ciovec 8byte * 1要素)
    (i32.store (i32.const 0) (i32.const 16)) ;; offset 0: 出力文字列("y\n")のアドレス
    (i32.store (i32.const 4) (i32.const 2)) ;; offset 4: 出力文字列("y\n")の長さ

    ;; 無限ループでstdoutに出力
    (loop $next
      ;; "y\n" を出力 (戻り値のエラーはdropで握りつぶす)
      (drop (call $fd_write
        (i32.const 1) ;; stdout
        (i32.const 0) ;; io vectorのアドレス
        (i32.const 1) ;; io vectorの長さ
        (i32.const 8) ;; 書き込まれたバイト数(u32)を格納するためのアドレス (4byteなので メモリ[8:12] に保存)
      ))
    )
  )
)

パイプラインでの絞り込みができるようにする

上記コードで y を無限出力できるようになりましたが、head で絞り込もうとするとパイプラインが固まってしまいます。

本物のyesの場合
$ yes | head -n 3
y
y
y
$ 
上記コードの場合
$ wasmer yes.wasm | head -n 3
y
y
y # ここで固まる

head コマンドは指定した行数を表示したらプロセスを終わらせるために SIGPIPE を投げますが、自作yesがこれを握りつぶしているのが原因でした。

参考:「「シェル芸」に効く! AWK処方箋

fd_write の戻り値が 0 (正常終了)以外の場合はループを抜けるように修正します。

yes.wat
    (loop $next
      (local.set $errno (call $fd_write ;; 戻り値のエラーをdropせずに格納
        (i32.const 1)
        (i32.const 0)
        (i32.const 1)
        (i32.const 8)
      ))

      ;; 0でなければエラーが発生しているのでループを抜ける
      (br_if $next (i32.eqz (local.get $errno)))
    )

これで head が終了するようになりました。

$ wasmer yes.wasm | head -n 3
y
y
y
$ 

コマンドライン引数を読み込む

続いて、yesのもう一つの機能、「引数が指定された場合 y の代わりにその文字列を表示する」を実装します。かなり複雑になったので、まずは完成したmain関数で概要を紹介します。

yes.wat
(func $main (export "_start")
  (local $errno i32)
  (local $output_ptr i32) ;; 出力文字列のアドレス ("y\n" 、コマンドライン引数どちらも参照できるよう変数化)
  (local $output_len i32) ;; 出力文字列の長さ

  ;; デフォルトでは "y\n" を参照
  (local.set $output_ptr (i32.const 16))
  (local.set $output_len (i32.const 2))

  ;; コマンドライン引数が指定された場合、参照先をこちらへ変える
  (if (call $has_command_line_args)
    (then
      ;; コマンドライン引数情報をメモリに格納し、先頭引数文字列のアドレスを取得
      (local.set $output_ptr
        (call $get_first_command_arg
          (i32.load (i32.const 20)) ;; 引数の個数 ($has_command_line_args が保存したもの)
          (i32.const 28) ;; 引数情報を格納するアドレス
        )
      )
      ;; 改行するために、引数文字列に "\n" を追加
      (call $append_breakline (local.get $output_ptr))
      ;; 引数文字列の長さを取得
      (local.set $output_len (call $len_str (local.get $output_ptr)))
    )
    (else nop) ;; 指定されない場合は何もしない (else省略不可のため nop指定)
  )

  ;; io vector初期化
  (call $initialize_iov (local.get $output_ptr) (local.get $output_len))

  ;; 無限ループで文字列出力
  (loop $next
    (local.set $errno
      (call $fd_write
        (i32.const 1) ;; stdout
        (i32.const 0) ;; io vectorのアドレス
        (i32.const 1) ;; io vectorの要素数
        (i32.const 8) ;; 出力文字列バイト数を格納するアドレス
      )
    )

    ;; エラーが起きたらループを抜ける
    (br_if $next (i32.eqz (local.get $errno)))
  )
)

コマンドライン引数の個数を取得: args_sizes_get

まずは引数が指定されたかどうかを調べる関数を作ります。
args_sizes_get() -> Result<(size, size), errno> はコマンドライン引数の個数と文字列データの長さを返します。

前述の規則に従うと、引数は sizeのポインタ2つ、戻り値はerrnoで (func $args_sizes_get (param i32 i32) (result i32)) のシグネチャになります。

yes.wat
(func $has_command_line_args
  (result i32)
  (drop (call $args_sizes_get
    (i32.const 20) ;; メモリ[20:24] にコマンドライン引数の個数を保存
    (i32.const 24) ;; メモリ[24:28] に引数文字列データ長を保存
  ))
  ;; 引数の個数が1より大きければ、引数が指定されている
  ;; NOTE: 0番目引数はコマンド名自身( `yes.wasm` )なので、2つ以上指定される必要がある
  (i32.gt_u (i32.load (i32.const 20)) (i32.const 1)) ;; return len(args) > 1
)

コマンドライン引数の中身を取得: args_get

続いて、1番目引数が指定された場合はその中身を取得します。
args_get(argv: Pointer<Pointer<u8>>, argv_buf: Pointer<u8>) -> Result<(), errno> はコマンドライン引数のアドレスの配列(argv)と引数データ(argv_buf)を格納します。

argv_buf は全コマンドライン引数を \0 で結合した一続きのデータで、argv の各要素は argv_buf 中の該当引数開始場所のアドレスを持っています。

イメージ
コマンドライン引数: "a bc def"

0         4         8        12          
| argv[0] | argv[1] | argv[2] |   argv_buf   |
|    12   |   14    |   16    | "a\0bc\0def" |

argv は配列のため (4 * 引数の個数)バイトの可変長の領域を使用します。そこで、先ほど取得した引数の個数を使用し argvargv_buf の領域が被らないようにしています。

yes.wat
;; 1番目コマンドライン引数文字列のアドレスを取得
(func $get_first_command_arg
  ;;         引数の個数             引数データ格納開始アドレス
  (param $n_args i32) (param $args_data_ptr i32) (result i32)

  ;; コマンドライン引数のデータを読み込み保存
  (drop (call $args_get
    ;; argv: コマンドライン引数のアドレスの配列
    ;; argv[n] は memory[$args_data_ptr+4*n:$args_data_ptr+4*(n+1)] へ保存される
    (local.get $args_data_ptr)
    ;; argv_buf: コマンドライン引数のデータを格納するアドレス
    ;; argvと衝突しないよう memory[$args_data_ptr+4*$n_args] から開始
    (i32.add (local.get $args_data_ptr) (i32.mul (local.get $n_args) (i32.const 4)))
  ))

  ;; argv[1] の中身が1番目引数のアドレス
  ;; NOTE: argv[0] はコマンド名 "yes.wasm" なので無視
  (i32.load (i32.add (local.get $args_data_ptr) (i32.const 4)))
)

引数に "\n" を追加

出力のたびに改行する必要があるので、格納した引数文字列に \n を追加します。
やっていることはヌル文字 \0 を見つけたら \n に置き換えているだけです 3

yes.wat
(func $append_breakline
  (param $str_ptr i32) ;; 文字列のアドレス

  (local $i i32)
  (local.set $i (local.get $str_ptr))
  (loop $next
    (if (i32.eqz (i32.load8_u (local.get $i))) ;; if memory[i] == '\0'
      (then
        ;; memory[i] を '\n' に置き換え 
        (i32.store8 (local.get $i) (i32.const 10))
        ;; memory[i+1] を '\0' に置き換え
        (i32.store8 (i32.add (local.get $i) (i32.const 1)) (i32.const 0))
        (return)
      )
      (else nop)
    )
    (local.set $i (i32.add (local.get $i) (i32.const 1))) ;; i++
    (br $next)
  )
)

文字列の長さを取得する

最後に、io vector 生成のために引数文字列の長さを数えます。こちらも単に \0 が見つかるまでインクリメントしているだけです。

yes.wat
(func $len_str
  (param $str_ptr i32) (result i32)
  (local $n i32)
  (local.set $n (i32.const 0))

  (loop $next
    (if (i32.eqz (i32.load8_u (i32.add (local.get $str_ptr) (local.get $n)))) ;; if memory[str_ptr+n] == '\0'
      (then
        (return (local.get $n))
      )
      (else nop)
    )
    (local.set $n (i32.add (local.get $n) (i32.const 1))) ;; i++
    (br $next)
  )
  (unreachable) ;; 戻り値未指定でコンパイルエラーにならないよう、この行に到達しえないことを明記
)

これでようやく、引数が出力できるようになりました。

$ wasmer yes.wasm foo | head -n 3
foo
foo
foo

テスト作成

ここまでwatファイルで実装してきましたが、「素人が手書きしたwasmなんか怖くて動かせるか!」とお思いの方もいらっしゃるでしょう。ごもっとも

そこで、今回はBatsを使ってCLIのテストを作成しました。

BatsはBashの拡張で、コマンドの出力結果が想定通りかテストできるフレームワークです。

まずは yesy の文字を出していることをテストします。

test/yes.bats
@test "yes without argument writes 'y'" {
  result="$(wasmer yes.wasm | head -n 1)" # 無限ループするとテストが終わらないので先頭行のみ取得
  [ "$result" = "y" ]
}

テストが通りました。

$ npx bats -x test/yes.bats
 ✓ yes without argument writes 'y'

1 tests, 0 failures

結果が等しくない場合は、以下のようなエラーが出ます。エラー箇所がわかりやすいように -x コマンドはいつもつけておくことをおススメします。

$ npx bats -x test/yes.bats
 ✗ yes without argument writes 'y'
   (in test file test/yes.bats, line 5)
     `[ "$result" = "n" ]' failed
   $ [yes.bats:4]
   $ result="$(wasmer yes.wasm | head -n 1)"
   $ wasmer yes.wasm
   $ head -n 1
   $ [ "$result" = "n" ]

1 test, 1 failure

yを出し続けることも、以下のようにテスト可能です。

test/yes.bats
@test "yes always writes 'y'" {
  lineno=0
  for result in $(wasmer yes.wasm | head -n 10); do
    echo "line $((lineno++)):"
    [ "$result" = "y" ] # n行目の出力が "y"
  done

  [ "$lineno" -eq 10 ] # 10行出力された
}

思いつく限りテストケースを書いたので、最低限動作していると信じたいです...

おわりに

以上、watファイル手書きで yes コマンドを実装した紹介でした。思った以上に記事が長くなってしまいました :innocent:

今までwasmの中身はブラックボックスと思っていましたが、実際に書いてみるとかなり取っつきやすいと感じました。作ったものはWAPMで公開しているので、この方法についても後日記事にしたいと思います。

ここまでお付き合いいただきありがとうございました!

  1. なぜかモジュールのページに直接飛ぶとInternal Server Errorが出るので検索ページのURLを貼っています。

  2. ちなみにWAPMをインストールするとWasmerも自動インストールされます。

  3. 文字列を他の用途に使わないので破壊的変更を加えていますが、ライブラリ化するならどこかへ新規文字列をコピーしたほうがお行儀がいいですね...

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5