🤠

WASMに文字列を渡す方法

2022/10/16に公開

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'}

渡し方

  1. ホストからゲストのmallocを呼んで適切なメモリを確保する。
  2. 直接メモリをいじって文字列を書き込む。
  3. 文字列のポインタ(最初のバイトのアドレス)を渡す。
  4. 終わったらメモリを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ライセンス)で確認出来る。

WASM Component Model

WASM Component Modelのスペックが進んでいるらしいので近いうちにこのようなことをやらなくても済む日が来るだろう。

脚注
  1. float32も渡せるらしい。64-bitなWASMも準備中? ↩︎

Discussion