WASMに文字列を渡す方法
WebAssemblyからexportした関数に文字列を渡したい。しかし、32ビットの整数型しか渡せない[1]。どうやって文字列を渡せばいい?
JavascriptとGoからC(libc)のWASMモジュールへ文字列を渡す方法について説明しようと思う。WASIをターゲットとする。
そもそも文字列って何?
文字列は様々な形やエンコーディングがあるが、もっとも一般的なUTF-8のcstringについて見てみよう。
Cの文字列はこのように構成されている。
[文字列のデータ]*, [NULL]
つまり、文字列の尻にNULL (\0x00
)を付ける。長さの情報は持たない。これは数多くの脆弱性の原因である。
ちなみにJavascriptの文字列はよりによってUTF-16で、UTF-8へ変換するにはTextEncoder
を使う。
const utf8 = new TextEncoder().encode("hoge");
// Uint8Array(4) [ 104, 111, 103, 101 ] = char{'h', 'o', 'g', 'e'}
渡し方
- ホストからゲストのmallocを呼んで適切なメモリを確保する。
- 直接メモリをいじって文字列を書き込む。
- 文字列のポインタ(最初のバイトのアドレス)を渡す。
- 終わったらメモリをfreeする。
wit-bindgenで自動化
wit-bindgenというツールは関数の定義ファイル(wit)を使ってCやJSのコードを自動生成出来る。
現在、破壊的変更が多くて正直使いづらいと思うので手動でやっていく。
WASMのモジュールがRustの場合はおそらくbindgenの方が楽だと思う。
下準備
WASMからmalloc
系の関数をexportする必要がある。
これはWASIの標準?の関数を少し綺麗にしたコード。
#include <stdlib.h>
__attribute__((weak, export_name("canonical_abi_realloc")))
void *canonical_abi_realloc(void *ptr, size_t orig_size, size_t org_align, size_t new_size) {
void *ret = realloc(ptr, new_size);
if (!ret)
abort();
return ret;
}
__attribute__((weak, export_name("canonical_abi_free")))
void canonical_abi_free(void *ptr, size_t size, size_t align) {
free(ptr);
}
同じように他にエキスポートしたい関数にattributeを付けとく。
#include <stdio.h>
__attribute__((export_name("print_test")))
void print_test(const char *str) {
printf("%s", str);
}
Javascriptから渡す
クラスにまとめてみた。以下はtrealla-jsから転用したコード。
export const ALIGN = 1;
export const NULL = 0;
export class CString {
instance;
ptr;
size;
constructor(instance, text) {
this.instance = instance;
const realloc = instance.exports.canonical_abi_realloc;
const buf = new TextEncoder().encode(text);
this.size = buf.byteLength + 1;
// 1. ホストからゲストのmalloc (realloc)を呼んで適切なメモリを確保する。
this.ptr = realloc(NULL, 0, ALIGN, this.size);
if (this.ptr === NULL) {
throw new Error("could not allocate cstring: " + text);
}
try {
// WASIの場合はメモリが"memory"という名前でエキスポートされている
const mem = new Uint8Array(instance.exports.memory.buffer, this.ptr, this.size);
// 2. 直接メモリをいじって文字列を書き込む。
mem.set(buf);
mem[buf.byteLength] = NULL;
} catch (err) {
this.free();
throw err;
}
}
free() {
if (this.ptr === NULL) {
return;
}
const free = this.instance.exports.canonical_abi_free;
free(this.ptr, this.size, ALIGN);
this.ptr = NULL;
this.size = 0;
}
}
この風に使う。
const print_test = instance.exports.print_test;
const str = new CString(instance, "hello world");
try {
// 3. 文字列のポインタ(最初のバイトのアドレス)を渡す。
print_test(str.ptr);
} finally {
// 4. 終わったらメモリをfreeする。
str.free();
}
// stdoutを取得 (wasmer-jsの場合)
const stdout = wasi.getStdoutString();
// "hello world"
free
を忘れてしまうとメモリリークが発生するので要注意。
また、関数の仕様によってはゲストがfreeしてくれることがあるのでそれも要注意。free
を2回呼んでしまうとやばいことが起きる。
動的メモリ確保の楽しい世界へようこそ。
Goから渡す
wasmer-go
を使った場合。GoはUTF-8なので変換する必要がない。
以下はtrealla-prolog/goから転用したコード。
type process struct {
instance *wasmer.Instance
wasi *wasmer.WasiEnvironment
memory *wasmer.Memory
realloc wasmFunc
free wasmFunc
print_test wasmFunc
}
// Goの場合はexportされた関数がreflectによってごちゃごちゃされてそうなので
// instanceのconstructorで一回取得しとく
func newProcess() (*process, error) {
p := new(process)
builder := wasmer.NewWasiStateBuilder("hoge").
CaptureStdout()
wasiEnv, err := builder.Finalize()
if err != nil {
return fmt.Errorf("failed to init WASI: %w", err)
}
p.wasi = wasiEnv
importObject, err := wasiEnv.GenerateImportObject(wasmStore, wasmModule)
if err != nil {
return err
}
instance, err := wasmer.NewInstance(wasmModule, importObject)
if err != nil {
return err
}
p.instance = instance
mem, err := instance.Exports.GetMemory("memory")
if err != nil {
return err
}
p.memory = mem
realloc, err := instance.Exports.GetFunction("canonical_abi_realloc")
if err != nil {
return err
}
p.realloc = realloc
free, err := instance.Exports.GetFunction("canonical_abi_free")
if err != nil {
return err
}
p.free = free
print_test, err := instance.Exports.GetFunction("print_test")
if err != nil {
return err
}
p.print_test = print_test
return nil
}
type cstring struct {
ptr int32
size int
}
func newCString(p *process, str string) (*cstring, error) {
cstr := &cstring{
size: len(str) + 1,
}
// 1. ホストからゲストのmalloc (realloc)を呼んで適切なメモリを確保する。
ptrv, err := p.realloc(0, 0, 1, cstr.size)
if err != nil {
return nil, err
}
cstr.ptr = ptrv.(int32)
if cstr.ptr == 0 {
return nil, fmt.Errorf("failed to allocate string: %s", str)
}
// 2. 直接メモリをいじって文字列を書き込む。
data := p.memory.Data()
ptr := int(cstr.ptr)
copy(data[ptr:], []byte(str))
data[ptr+len(str)] = 0
return cstr, nil
}
func (str *cstring) free(p *process) error {
if str.ptr == 0 {
return nil
}
_, err := p.free(str.ptr, str.size, 1)
str.ptr = 0
str.size = 0
return err
}
この風に使う。
func (p *process) printTest(text string) (string, error) {
cstr, err := newCString(p, text)
if err != nil {
return err
}
// 4. 終わったらメモリをfreeする。
defer cstr.free()
// 3. 文字列のポインタ(最初のバイトのアドレス)を渡す。
_, err = p.print_test(cstr.ptr)
if err != nil {
return err
}
stdout := string(pl.wasi.ReadStdout())
return stdout, nil
}
func main() {
p := newProcess()
got, _ := p.printTest("hello world")
// "hello world"
}
実装例
最近Trealla PrologのWASM化を開発しているので実際に文字列を渡しているところをオープンソース(MITライセンス)で確認出来る。
- C: https://github.com/guregu/trealla/blob/main/src/wasi.c
- JS: https://github.com/guregu/trealla-js
- Go: https://github.com/trealla-prolog/go
WASM Component Model
WASM Component Modelのスペックが進んでいるらしいので近いうちにこのようなことをやらなくても済む日が来るだろう。
-
float32も渡せるらしい。64-bitなWASMも準備中? ↩︎
Discussion