🔎

TinyGo の wasm ターゲットは文字列をどう扱うのかを調べた

2022/01/25に公開

WebAssembly の仕様には文字列型が存在しないが、TinyGo では文字列を扱う関数を wasm ターゲットでビルドできる。ビルドした WebAssembly モジュールの内部ではどういった扱いになっているのか気になったので調べてみた。

事前準備

macOS を使っているので、最初に Homebrew を使って TinyGoWABT をインストールした。

$ brew install tinygo-org/tools/tinygo wabt 

TinyGo のバージョンは 0.21.0 だった。

$ tinygo version
tinygo version 0.21.0 darwin/amd64 (using go version go1.17.3 and LLVM version 11.0.0)

WebAssembly モジュールの作成

最初に wasm モジュールにするためのコードを用意する。今回は単純な文字列を返す hello() という関数と、文字列を引数で受け取ってその長さを返す関数 string_length() の2つを定義して、Export している。これをビルドして解析すれば、引数と戻り値における文字列の扱いが分かるはず。

package main

func main() {}

//export hello
func hello() string {
	return "hello, world"
}

//export string_length
func stringLength(str string) int {
	return len(str)
}

上記のコードを適当に main.go として保存して、TinyGo でビルドする。ここでは string.wasm として出力することにした。

$ tinygo build -target wasm -o string.wasm .

関数定義の解析

ビルドした wasm ファイルは WABT に含まれる wasm2wat コマンドを使うと WAT に変換できる。WAT は WebAssembly のテキストフォーマットなので比較的容易に読める。2つの関数の定義は以下のように出力された。

$ wasm2wat string.wasm
...
  (func $hello (type 0) (param i32)
    local.get 0
    i32.const 12
    i32.store offset=4
    local.get 0
    i32.const 65756
    i32.store)
  (func $string_length (type 4) (param i32 i32) (result i32)
    local.get 1)
...

hello() の定義

hello() の定義にコメントをつけて整形すると以下のようになる。引数を1つとり、最初にその引数のアドレスの4バイトオフセットの位置に 12 を書き込んでいる。次に、引数のアドレスに 65756 を書き込んでいる。どうも最初に書き込んでいるのは戻り値である hello, world という文字列の長さのように見える。

(func $hello (type 0)
  ;; i32 の引数が1つ
  (param i32)
  
  ;; 引数のアドレスの4バイトオフセットに 12 を保存
  local.get 0
  i32.const 12
  i32.store offset=4
  
  ;; 引数のアドレスに 65756 を保存
  local.get 0
  i32.const 65756
  i32.store
)

string_length() の定義

string_length() の定義はビルド時に最適化されたようだ。定義としては2つ目の引数をそのまま返すようになっていた。つまり2つ目の引数は文字列の長さを示していると思われる。

  (func $string_length (type 4)
    ;; i32 の引数が2つ
    (param i32 i32)
    ;; i32 の戻り値
    (result i32)
    
    ;; 戻り値は2つ目の引数の値
    local.get 1
  )

これらの関数定義から、TinyGo の wasm ターゲットでビルドすると文字列は「アドレス」と「文字列長」のペアで扱っているっぽいということが分かった。なお、ここでいうアドレスは WebAssembly の線形メモリ上のアドレスであることを意識しておく必要がある。

WebAssembly モジュールを呼び出してみる

定義が分かったので、WebAssembly モジュールの関数を実際に呼び出してみて、アドレスと文字列長で関数が意図したように動くかを確認する。モジュールを動かすには WebAssembly ランタイムが必要になるが、今回は Go で実装された wazero を使うことにした。

モジュールの関数を呼び出すコードは次の通り。コードを簡略化するために WebAssembly モジュールのインスタンスを作るところまではエラー処理を省略している。また、wazero は現在も開発が進んでいてインターフェースが変わる可能性があるので、いずれこのコードは動かなくなるかもしれない。

package main

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"os"

	"github.com/tetratelabs/wazero/wasi"
	"github.com/tetratelabs/wazero/wasm"
	wasmbinary "github.com/tetratelabs/wazero/wasm/binary"
	"github.com/tetratelabs/wazero/wasm/wazeroir"
)

func main() {
	// wasm ファイルを読み込み
	buf, _ := os.ReadFile("string.wasm")
	// wasm ファイルをデコード
	mod, _ := wasmbinary.DecodeModule(buf)
	// Store を生成
	store := wasm.NewStore(wazeroir.NewEngine())
	// WASI のための環境をセットアップ
	_ = wasi.NewEnvironment().Register(store)
	// モジュールをインスタンス化
	_ = store.Instantiate(mod, "")

	// 線形メモリのアドレス 0 を指定して hello 関数を呼び出し
	_, _, err := store.CallFunction("", "hello", 0)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to call function: %v", err)
		os.Exit(1)
	}

	// 線形メモリからアドレスと文字列長を読み出し
	mem := store.ModuleInstances[""].Exports["memory"].Memory.Buffer
	strAddr := binary.LittleEndian.Uint32(mem[0:])
	strLen := binary.LittleEndian.Uint32(mem[4:])

	// アドレス、文字列長、線形メモリから読み出した文字列を表示
	fmt.Printf("[hello] Address: %d, Length: %d\n", strAddr, strLen)
	fmt.Printf("[hello] Return value: %s\n", mem[strAddr:strAddr+strLen])

	// string_length 関数の引数となる文字列を線形メモリに書き込み
	writeAddr := 10
	writeBuf := bytes.NewBuffer(mem[writeAddr:])
	writeLen, err := writeBuf.WriteString("hello from go")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to write: %v", err)
		os.Exit(1)
	}

	// 線形メモリのアドレスと文字列長を指定して string_length 関数を呼び出し
	result, _, err := store.CallFunction("", "string_length", uint64(writeAddr), uint64(writeLen))
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to call function: %v", err)
		os.Exit(1)
	}

	// 線形メモリのアドレス、文字列長、戻り値を表示
	fmt.Printf("[string_length] Address: %d, Length: %d\n", writeAddr, writeLen)
	fmt.Printf("[string_length] Return value: %d\n", result[0])
}

このコードを実行すると、次のように表示された。hello() の戻り値である hello, world という文字列が正しく取得できており、また string_length() からは線形メモリに書き込んだ hello from go という文字列の長さが返ってきたことが分かる。どうやらアドレスと文字列長でやりとりするという推測はあっていたようだ。

$ go run .
[hello] Address: 65756, Length: 12
[hello] Return value: hello, world
[string_length] Address: 10, Length: 13
[string_length] Return value: 13

わかったこと

  • TinyGo で文字列を扱う関数を wasm ターゲットでビルドすると、アドレスと文字列長でやりとりするように変換される
  • WebAssembly モジュールとホストの間では線形メモリを介して文字列をやりとりする必要がある
  • TinyGo でビルドした関数がどう変換されるかは WAT 形式にして読むと分かりやすい

付録: Go における string の定義

Go の string は内部的にどのように扱われているのかが気になったのでついでに調べてみた。最初に何かヒントが見つからないかと refrect パッケージの定義を読んでみたら StringHeader という構造体が見つかった。Data がポインタで Len が長さなので、まさに wasm で扱うアドレスと文字列長の組み合わせだ。

type StringHeader struct {
	Data uintptr
	Len  int
}

さらに runtime のコードを読み進めると、Go 1.17 では stringStruct という内部構造体が定義されて使われていることも分かった。

type stringStruct struct {
	str unsafe.Pointer
	len int
}

TinyGo の runtime にも同様に _string という構造体が定義されていた。型は本家とは異なるがほとんど同じ構造になっているっぽい。

type _string struct {
	ptr    *byte
	length uintptr
}

TinyGo の内部コードをちゃんと追いかけたわけではないので正確な答えではないかもしれないが、これらの構造から、wasm ターゲットでビルドした時の文字列は「アドレス」と「文字列長」のペアで扱われるようになっているのかもしれない。

Discussion