WAT (WebAssembly Text Format) と Common Lisp で遊ぶ ~小ネタ: 文字列出力マクロ~

相変わらずCommon Lisp + WAT (WebAssembly Text Format)で遊んでいますが、文字列の出力もできないと遊ぶには不便な感じがしたので、公式で紹介されている方法を参照しつつ、前々回の記事で作ったマクロつきパーサも活用しつつ、前回の記事で作ったメモリアロケータも利用しつつ文字列出力を簡易に行っていく記事です。

前々回記事: eshamster.hatenablog.com

前回記事: eshamster.hatenablog.com


目次


公式で紹介されている方法

まず公式のドキュメントである Understanding WebAssembly text format -WebAssembly Memory-で紹介されている方法に触れます(この項のコードは同ページから引用しています)。

まず、WASM側にimportさせる consoleLogStringJavaScript側で定義します。これは、WASMとの共有メモリ(memory)上の offsetoffset+length-1 間をUTF8エンコードされたバイト列と解釈してデコードし、結果を console.log で出力するという代物です。

var memory = new WebAssembly.Memory({initial:1});

function consoleLogString(offset, length) {
  var bytes = new Uint8Array(memory.buffer, offset, length);
  var string = new TextDecoder('utf8').decode(bytes);
  console.log(string);
}

この memory, consoleLogString は次のようにしてWASM側に渡します。

var importObject = { console: { log: consoleLogString }, js: { mem: memory } };

WebAssembly.instantiateStreaming(fetch('logger2.wasm'), importObject)
  .then(obj => {
    obj.instance.exports.writeHi();
  });

WASM側は次のようになります。data セグメントを利用して、文字列 "Hi" をUTF8エンコーディングしたバイト列を memory 上に配置します。

(module
  ;; consoleLogString を $log として improt
  (import "console" "log" (func $log (param i32 i32)))
  (import "js" "mem" (memory 1))
  ;; "Hi" をUTF8エンコーディングして memory 上に配置する
  (data (i32.const 0) "Hi")
  (func (export "writeHi")
    i32.const 0  ;; pass offset 0 to log
    i32.const 2  ;; pass length 2 to log
    call $log))

ということで、consoleLoeStringmemory 上のバイト列を0~1(バイト単位)まで読み取ってUTF8デコードし、"Hi" を出力することになります。

func 中で同じことをしたい

C言語などでは固定文字列には固定のアドレスが割り付けられるようなので、ちゃんとしたコンパイラをつくる場合は前記の data セグメントをうまく使ってそうしたやり方を模倣するのが良いでしょう。が、もう少しカジュアルに遊びたいので、関数定義(func)中で固定文字列を出力する方法について(無駄に)考えてみます。

JavaScript側は前項から特に変更はありませんが、WAT側は data セグメントが func 中では利用できないので、別の方法を考える必要があります。

別の方法といっても、地道に自分でUTF8エンコードした文字列を memory 中に埋めていくしか(たぶん)ありません。そのため、"Hi"を出力するには次のようになります(関数呼び出し等はS式形式で書いています)。

(func test-log-string
  (i32.store8 (i32.const 0) (i32.const 72))  ;  72 = "H"
  (i32.store8 (i32.const 0) (i32.const 105)) ; 105 = "i"
  (call $log (i32.const 0) (i32.const 2)))

Asciiコードの範囲内ならまだマシで、「斑鳩」などと出力しようとすると次のようになります。

(func test-log-string
  ;; "斑" = (230 150 145)
  (i32.store8 (i32.const 0) (i32.const 230))
  (i32.store8 (i32.const 1) (i32.const 150))
  (i32.store8 (i32.const 2) (i32.const 145))
  ;; "鳩" = (233 179 169)
  (i32.store8 (i32.const 3) (i32.const 233))
  (i32.store8 (i32.const 4) (i32.const 179))
  (i32.store8 (i32.const 5) (i32.const 169))
  (call $log (i32.const 0) (i32.const 6)))

全然カジュアルじゃないですね。

マクロで簡単に書けるようにする

前々回の記事ではCommon LispにWATを書かせることができるようになりました。ここで一番やりたかったのがマクロの導入ですが、それを利用してもっと簡単に固定文字列の出力を書けるようにしてみます。なお、このWAT生成部分は watson というライブラリに切り出してみました。

github.com

先に、前項の test-log-string (と合わせてimport, export部分も)をこのwatsonを使って書く場合は次のようになります。

(defimport.wat log console.log (func ((i32) (i32))))
(defimport.wat mem js.mem (memory 1))

(defun.wat test-log-string () ()
  ;; "斑" = (230 150 145)
  (i32.store8 (i32.const 0) (i32.const 230))
  (i32.store8 (i32.const 1) (i32.const 150))
  (i32.store8 (i32.const 2) (i32.const 145))
  ;; "鳩" = (233 179 169)
  (i32.store8 (i32.const 3) (i32.const 233))
  (i32.store8 (i32.const 4) (i32.const 179))
  (i32.store8 (i32.const 5) (i32.const 169))
  (log (i32.const 0) (i32.const 6)))
  
(defexport.wat test-log-string (func test-log-string))

さて、これを次のように書けるように log-string マクロを作成します。なお、前回の記事でメモリアロケータ(malloc, free)を作ったので、それを使ってきちんとメモリを確保・解放する仕込みとして変数 ptr を用意しています*1

(defun.wat test-log-string () ()
  (let ((ptr i32))
    (log-string "斑鳩" ptr)))

この log-string マクロの定義は次のようになります。

(defmacro.wat log-string (text var-ptr)
  (unless (stringp text)
    (error "input should be string. got: ~A" text))
  (let* ((octets (flexi-streams:string-to-octets text :external-format :utf-8))
         ;; octets は8バイト単位、malloc は32バイト単位であることに注意
         (alloc-size (ceiling (/ (length octets) 4))))
    `(progn (set-local ,var-ptr (malloc (i32.const ,alloc-size)))
            ,@(loop :for i :from 0 :below (length octets)
                    :collect `(i32.store8 (i32.add (i32.mul ,var-ptr
                                                           (i32.const 4))
                                                  (i32.const ,i))
                                         (i32.const ,(aref octets i))))
            (log (i32.mul ,var-ptr (i32.const 4))
                 (i32.const ,(length octets)))
            (free ,var-ptr))))

リストを出力するまではCommon Lispの領域なので、当然Common Lispライブラリも利用できます。ここでは、flexi-streamsを利用してUTF8エンコードしたバイト列 octets を生成しています。これを元に malloc で確保するサイズ alloc-size を計算しておきます。これらの情報を利用して、(progn 以下で下記を展開します。

  • alloc-size 分のメモリを malloc する(32bit単位)
  • 確保したアドレスの先頭からバイト列の値を詰めていく(8bit単位)
  • log に格納したバイト列を指すようにoffset, lengthを渡す(8bit単位)
  • free で確保したメモリを解放する

ということで、次のように書けるようになります。先程は省略しましたが、自作 malloc, free を使う準備として init-memory も呼んでおきます。

(defun.wat test-log-string () ()
  (let ((ptr i32))
    (init-memory)
    (log-string "斑鳩" ptr)))

log-string マクロ部分を展開すると次のようになります。

(defun.wat test-log-string () ()
  (let ((ptr i32))
    (init-memory)
    (progn (set-local ptr (malloc (i32.const 2)))
           ;; "斑" = (230 150 145)
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 0)) (i32.const 230))
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 1)) (i32.const 150))
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 2)) (i32.const 145))
           ;; "鳩" = (233 179 169)
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 3)) (i32.const 233))
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 4)) (i32.const 179))
           (i32.store8 (i32.add (i32.mul ptr (i32.const 4)) (i32.const 5)) (i32.const 169))
           (log (i32.mul ptr (i32.const 4)) (i32.const 6))
           (free ptr))))

こんな風にマクロでわっと展開されると気分が良いですね。


*1:これが純粋にCommon Lispであればマクロ内で自分で変数を用意すれば良いのですが、WATでは関数冒頭でしか変数を用意できないのでそうもいかず...。ちょっとかっこ悪い。watsonパーサ側で中途に表れる変数宣言を関数冒頭に持ってくるなどすれば対処できそうですが、そこまではできておらず