LoginSignup
13
3

WebAssembly のメモリ構造

Last updated at Posted at 2023-06-26

WebAssembly の直観力を鍛え、コピペ駆動開発を卒業しよう!

本記事は wasmtime で書く予定だったことの一部を抽出したものだ。
メモリ構造を把握すれば WebAssembly では何が出来るのか、開発ツールはどのような仕組みを隠蔽しているのか等、何となく想像がつくようになる。
wasmtime 以外にも役に立つと考えて独立した記事とした。

前回の復習

前回の勉強会の詳細については、 WEBASSEMBLY - What's the right thing to write? をご参照ください。

System Call と User Land

OS の上で動くアプリケーションの処理は大きく 2 種類に分かれる。

  • OS へのリクエスト (System Call)
    • Input / Output
    • Memory 確保、解放
    • ...
  • CPU と Memory を直接つかった計算 (User Land 上の処理)
    • 四則演算
    • 確保済みの Memory へのアクセス (Read / Write)
    • ...

WebAssembly の方針

WebAssembly とは、多くのコンピューターで動作する事を目指した VM の仕様である。
しかし System Call を行うと、その処理は OS 依存となってしまいポータビリティーを下げる。

それを防ぐため、WebAssembly は User Land 上の処理しか出来ないように規格で定められている。
そして System Call が使えないという制限を補うために WebAssembly は他のプログラム言語と連携して動く事を前提としている。

WebAssembly の使い方としては、下記の 2 通りになる。

  • WebAssembly では System Call を使わない部分のコードだけを記載、外部プログラムからライブラリの様に使用する
  • WebAssembly から(System Call を使うかもしれない)外部プログラムの関数をライブラリーの様に実行する

プログラマー目線の WebAssembly

本題に入る前に、プログラマー目線の WebAssembly の仕様を簡単に紹介する。
興味のない人は Section まで読み飛ばして欲しい。

外部プログラムから見た WebAssembly

多くのドキュメントで言われているように、WebAssembly は VM の規格である。
(「WebAssembly のプログラムを書く」とは「WebAssembly の規格に沿った VM で動作するアプリケーションを書く」と同義である。)

この説明は間違いでは無いのだが、プログラマー目線では「class 定義の規格」と考えた方が分かりやすいかもしれない。

例えば、JavaScript では下記の様に Person という class を定義できる。

class Person {
    constructor(name, age) {
        this._name = name;
        this._age = age;
    }

    name() {
        return this._name;
    }

    age() {
        return this._age;
    }
}

JavaScript で上記の class を使うには、多くの場合は下記の様に instance 化して使うだろう。

let person = new Person("Shin Yoshida", 42);
console.log(`name: ${person.name()}, age: ${person.age()}`);

上記のコードの欠点として、(当たり前だが)JavaScript 以外のプログラム言語では動作しない事が挙げられる。
上記のコードを C++ のソースにコピペしてもコンパイルエラーとなるはずだ。

WebAssembly は様々な言語に対応する class 定義を記載するための規格の様な物だ。
WebAssembly のコンパイルされたバイナリファイルは、他の言語から(その言語が WebAssembly に対応している限り)class 定義のソースコードの様に読み取る事が出来る。

そう考えると、WebAssembly には例えば下記の様な特徴がある事は理解できるだろう。

  • instance 化してから使う
  • 同じ WebAssembly のコードから複数の instance を作成する事が可能
  • 各 instance の property に(アクセス権が有れば)アクセス(read/write)可能
  • パブリックな関数(メソッド)を呼び出す事が可能

(開発ツールによっては上記の仕組みを隠蔽しているかもしれない)

Primitive な変数型

WebAssembly の変数や関数は静的に型付けされている。
(コンパイル時に型が決まっている。)

WebAssembly の Primitive な変数の型は、下記の 6 種類だけだ。

  • i32(符号付き 32 bit 整数)
  • f32(符号付き 32 bit 浮動小数)
  • i64(符号付き 64 bit 整数)
  • f64(符号付き 64 bit 浮動小数)
  • v128(128 bit byte 列)
  • 抽象ポインター

(2023 年 7 月 23 日修正。符号無し整数はコンパイラー依存であり、WebAssembly の仕様上は認められていなかった。)

v128 は少し特殊だが、それ以外はただの数値である。
外部プログラムと WebAssembly は、これらの値を受け渡しが可能だ。

細かい仕様は言語やライブラリー毎にきまっている。

例えば JavaScript の number は 64 bit の浮動小数点だ。
JavaScript の number を整数として WebAssembly に渡す時は整数部分の下位の 32 bit を渡す。
JavaScript の BigInt は可変 bit 長の整数だが、WebAssembly に渡す時は下位の 64 bit を渡す。
(桁あふれ注意)

難しければ、下記の 2 個だけ覚えておけばよい

  • 外部プログラムと WebAssembly の間では数値やポインターは直接受け渡しが可能
  • それ以外のオブジェクト(String 等)は、(開発ツール等)誰かが工夫しているはず

また、WebAssembly では「オブジェクト」を直接扱う事は出来ないがポインターを使用する事は可能である。
(ポインターとは、ただの整数を Memory 上の番地と解釈しているだけだ。)

抽象ポインター

抽象ポインター型について補足しておく。

「抽象ポインター」とは、現在の所「関数ポインター」と「(主に外部プログラムの)オブジェクトを指すポインター」のみサポートされている。

これらのポインターを通して他の WebAssembly 上の関数を実行したり、外部プログラムから import した関数に引数として外部プログラムのオブジェクトのポインターを渡す事が出来る。

ただし、WebAssembly 内から抽象ポインターの指す番地の byte 列を読むことは出来ない。
出来る事は「関数があるはず」と信じて実行するか、「ポインター(をあらわす整数)の受け渡し」のどちらかだけである。

ちなみに 以前書いた記事 では下記の様なコードで WebAssembly (Rust)からブラウザー上の JavaScript のオブジェクトを扱った。

    let window = web_sys::window().unwrap();

これは JavaScript の関数を実行して JavaScript のオブジェクトを指す抽象ポインターを取得し、 window と命名している。
この window は後で別の JavaScript の関数を呼ぶ際の引数に使っている。

関数の型

WebAssemly の各関数の型は、下記の 2 個の配列で定義される。

  • 0 個以上の引数の数と、各引数の型(各型は上記の 8 個の Primitive な変数の型のいずれか)
  • 0 個以上の戻り値の数と、各戻り値の型(各型は上記の 8 個の Primitive な変数の型のいずれか)

ちなみに、戻り値に複数の値を使えるようになったのは最近だ。
外部プログラムと WebAssembly の関数のインターフェースでこの機能を使っている開発ツールは、まだ少ないようだ。
(筆者は見たことが無い。)

筆者はこの機能を使えばオブジェクトを返す WebAssembly の関数を外部プログラムから呼ぶ際のパフォーマンスが向上すると考えているので、興味のある人は作ってほしい。

Section

(WebAssembly に限らず、一般的にコンピューター上で)実行中のアプリケーションのメモリは、グローバル変数、ヒープメモリ、関数の実装等、様々な領域に分かれている。
(補足しておくと、バイナリファイルに保存されている関数の実装は、メモリに読み込んでから CPU が実行する。関数ポインターを使用可能なのは、そのためである。)

この各領域の事を「section」と呼ぶ。

ドキュメント によると、WebAssembly には、以下の様な section が存在する。

  • types
  • funcs
  • tables
  • mems
  • globals
  • elems
  • datas
  • start
  • imports
  • exports

後ほど実際に手を動かして詳細を確認するが、ここでは全体像を把握するために各項目について簡単に説明する。
(実は他にもドキュメントに掲載されていない section も存在する事が後でわかる。)

types

定義されている関数の型の一覧(固定配列。)
配列の各値は関数の型であり、前述の様に引数と戻り値 2 個の配列から成る。

例えば、以下の様な値の配列になるだろう。

  • 引数: [i32, i32], 戻り値: []
  • 引数: [f64], 戻り値: [i32, i32]
  • 引数: [i32, i64], 戻り値: [i64]
  • ...

WebAssembly では関数の型付けは「i32 型(符号付 32 bit 整数)の引数を 2 個とって、i32 型を 1 個返す」ではなく「types[2] の型」の様に行っている。
types[2] 型の関数が複数存在しても問題無い。)

types section の配列はハードコーディングされており、instance 起動後に変更できない。

funcs

定義されている関数の一覧(固定配列。)
各関数の型と、実行されるバイナリコードに関する情報が記載されている。
(当然、配列の数は定義した関数の数と一致する。)
外部に公開されていない関数(プライベートな関数)も記載されている。

また、関数には名前を付ける必要が無い事も記載しておく。
(関数名の主な用途はデバッグ情報である。)

WebAssembly の内部から各関数にアクセスする場合は funcs section 配列の index を指定している。
外部のプログラムから関数を呼ぶ場合は、後述する exports section の名前が使用される。

前述の様に WebAssembly は外部プログラムで instance 化されてから使用される。
「関数」というより「メソッド」と言った方が直感的に感じる人も居るかもしれない。

funcs section の配列はハードコーディングされており、instance 起動後に変更できない。

tables

抽象ポインターの配列の一覧。
配列の各値は、下記の 3 個のメタデータを持つ配列である。
(つまり、tables section は形式的には 2 次元配列の形状を取る。)

  • 初期長
  • 最大長
  • 抽象ポインターの型(関数ポインター、又は外部オブジェクトを指すポインター)

外部プログラムからこの配列を変更する事でメタプログラミングの様な事ができる。
また、ここに他の WebAssembly instance の関数ポインターを設定すれば動的ライブラリのリンクの様な事ができる。

オブジェクト指向の言語に慣れている人は「メソッドテーブル」等の言葉に聞き覚えが有るかもしれない。
tables という section 名は、このような言葉に由来するのでは無いかと筆者は予想している。

tables section 自体はハードコーディングされており、instance 起動後に変更する事は出来ない。
しかし tables section の値である各配列は(最大長や抽象ポインターの型等の制限を満たす限り)変更可能である。

(2023 年 7 月 14 日追加)
ただし tables section は形式的には 2 次元配列の形状なのだが、現在の仕様ではその長さは最大 1 となっている。
つまり、実質的に 1 次元配列であり、関数ポインターと外部オブジェクトを指すポインターのいずれか一方しか保持できない。
これは「外部オブジェクトを指すポインター」が新しく仕様に加わったためであると筆者は考えている。
現在、tables section の最大長を増やす提案はされているようだ。

mems

前回の復習 で記載したように、一般にアプリケーションの Memory 確保は OS へのリクエスト(System Call)によって行われる。
そして WebAssembly からは System Call を実行できない。つまり、WebAssembly からは Memory の確保を出来ない。

しかし instance 初期化時に外部プログラムが Memory の塊を確保して渡す事は可能だ。
このような Memory を WebAssembly では「線形 Memory」と呼び、WebAssembly の各インスタンスは、この線形 Memory の中でやり繰りを行う。

mems はこの線形 Memory のセクションである。

WebAssembly のソースコードでは、線形メモリの初期サイズと最大サイズを定義できる。
ただし、線形 Memory のサイズには下記のような制限が存在する。

  • WebAssembly は線形 Memory を 64 KB (65536 byte) の Page 単位で管理する。例えば「初期サイズ 1」とは「初期サイズ 1 Page(= 64 KB)」という意味であり、サイズは常に 64 KB の倍数である。(Page という概念は、現在のところ線形 Memory のサイズでしか使われていないとの認識だ。)
  • 2023 年 6 月現在、WebAssembly のアドレス空間は 32 bit であり最大サイズは常に「2 の 31 乗 byte(= 2 GB)以下」である。(多くの OS 上のプログラムと同様に、最上位 bit はエラーフラグのために残してある。)ただし こちら では 64 bit 化が提案されている。

mems 自体は配列となっているが、その長さは最大で 1 である。
つまり各 WebAssembly の各 instance は線形 Memory を最大で 1 個しか使えない。

ただし こちら では複数の線形 Memory を使用するための提案がされているので近い将来は長さが 2 以上になる可能性もある。

mems section 自体はハードコーディングされており、instance 起動後に変更する事は出来ない。
しかし、渡された Memory の塊は設定の範囲内で後から拡張可能である。

ところで WebAssembly の Memory の取り扱いは少々特殊だ。
mems section とは直接関係無いかもしれないが、ここで軽く紹介しておく。
(興味のない人は globals まで読み飛ばして下さい。)

線形 Memory と抽象ポインター以外のポインターは使用できない

C 言語や Rust を書いている人はローカル変数のポインターを使用したことが有るかもしれないが、WebAssembly では出来ない。

その様なコードを書いた場合、多くはコンパイラーが裏でコードを書き換えている。
なのでコンパイルは通るだろうしアプリケーションは正常動作すると思うが、パフォーマンスは良くない可能性がある。

Null ポインターにアクセス可能である。

Null ポインターとはアドレスの 0 番地のことだ。通常のアプリケーションでアクセスしようとするとエラーとなる。
しかし WebAssembly から見た 0 番地とは線形メモリの最初であり、外部プログラムから渡されたメモリの一部である。
プロセスとして見ると 0 番地では無いのでアクセスしてもエラーにならない。

WebAssembly の Memory 関連のライブラリの多くは 0 番地を使わないし null チェック自体は問題なくできる。
普通に WebAssembly のコードを書く際には、この仕様を意識する必要は無いので安心してほしい。

ハードウェアの機能を使うことが出来ない

例えば最近の CPU には MPU というメモリ関連の専用のデバイスが搭載されている。
同様にメモリ操作に特化した様々なハードウェアが有るだろう。
残念な事に、WebAssembly では基本的にそれらの機能を使うことは出来ない。

特に C 言語の realloc に相当する機能は遅くなるだろう。

Alignment を(普通は)考慮しない

通常、Memory に値を保存する際には CPU が効率的にアクセスするために Alignment と呼ばれる物を気にする。
例えば 4 byte の整数を保存するならば、そのアドレスは 4 の倍数である方が CPU は素早くアクセスできるのだ。
(プログラマーが意識していなくとも、コンパイラーが考慮しているはずだ。)

しかし、WebAssembly はこの Alignment を(普通は)考慮しない。
基本的に WebAssembly は Memory アクセスが遅いと思ったほうが良いだろう。

ただし Alignment に関する設定を独自拡張しているツールも存在する様だ。(なので「普通は」と書いた。)
移植性が気になるので個人的には独自拡張は使いたくないが。

globals

グローバル変数の一覧(固定配列。)
配列の各値は、下記の 3 個の情報からなる。

  • 可変、又は不変
  • 変数の型(上記 8 種類の Primitive な型のいずれか)
  • 初期値(又は初期化関数)

funcs 同様に、各グローバル変数に名前は必要無い。

前述の様に WebAssembly は外部プログラムで instance 化されてから使用される。
「global 変数」という表現だが、実際には「instance 内で global な変数」である。
「instance 変数」、つまり「ただのプロパティ」と言った方が直感的に感じる人も居るかもしれない。

globals section の配列の長さや型はハードコーディングされており instance 起動後に変更できないが、「可変」の値は変更可能である。

elems

elems section は table section の初期化やコンパイル時に使用したりする。
詳細は本ドキュメントの範疇を超えてしまうので省略するが、気になる人は独学して欲しい。

ここでは「抽象ポインターの 2 次元配列」とだけ記述しておく。

elems section の配列は全てハードコーディングされており、instance 起動後に変更出来ない。

datas

instance 初期化時に線形 Memory にコピーする元データの一覧(固定配列。)
各配列の値はバイト列。

後で実例を見れば一目瞭然だろう。
ここでは、これ以上の説明は省略する。

datas section はハードコーディングされており、instance 起動後に変更できない。

start

WebAssembly のインスタンス作成時に実行される関数の指定。
他の section と異なり、start section だけは配列では無い。

前述の様に WebAssembly は外部プログラムで instance 化されてから使用される。
start section は「コンストラクター method の指定」と言った方が直感的に感じる人も居るかもしれない。

start section はハードコーディングされており、instance 起動後に変更出来ない。

exports

外部へ公開している名前の一覧(固定配列。)
配列の各値は下記の 3 個の値の配列からなる。

  • 公開する名前
  • section の種類(funcs, tables, mems, globals のいずれか)
  • 各 section 配列上の index

例えば外部プログラムから foo という関数を実行する場合は、

  1. exports section の配列から foo という名前を検索
  2. foo が funcs である事を確認し、その index を確認
  3. funcs section から index を用いて関数にアクセス、実行

という手順になる。

内部で bar という名前の関数を foo という名前で exports する事も WebAssembly の仕様上は可能だ。
(外部から実行する時は foo という名前のみ使用可能。)

mems を export すれば、線形 Memory に外部プログラムからアクセス可能になる。
(普通はそうする。)
文字列などのオブジェクトを外部プログラムと WebAssembly で受け渡しする際は、この線形 Memory を使う。

tabless section の配列の値を export して他の WebAssembly instance で import すれば、この instance 上のアドレスを指す抽象ポインターを他の instance で使用できるだろう。

exports section の配列はハードコーディングされており、instance 起動後に変更出来ない。

imports

外部から import する名前の一覧(固定配列。)
配列の各値は、下記の 3 個の値のからなる。

  • 定義されている外部のモジュール名
  • 外部の名前
  • 型情報(下記のいずれか)
    • funcs の場合、types section 配列の index
    • tables の場合、その最小サイズ、最大サイズ、抽象ポインターの型(関数かオブジェクトか)情報
    • mems の場合、初期 page 数、最大 page 数
    • globals の場合、可変情報と型情報

例えば globals を import する事で他の WebAssembly のインスタンスと変数を共有する事も可能である。

また、外部プログラムの関数を import する事で WebAssembly から実行できるようになる。
この方法を使えば、外部プログラムを通じて WebAssembly から System call を間接的に実行することも可能だ。

imports section の配列はハードコーディングされており、instance 起動後に変更出来ない。

動作確認

section 情報について、実際に手を動かして確認してみる。
今回は Rust, WebAssembly Text Format, WASI を用いるが、これらの前提知識が無くとも理解可能だと考えている。

なお、WebAssembly Text Format とは WebAssembly 専用の低レイヤーのプログラム言語である。
文法は lisp に似ている。

ここでは、WASI とは WebAssembly を使用する外部プログラムの規格と思って良い。
WASI について、より詳細に知りたい人は WebAssembly System Interface の精神に触る の記事を読んでほしい。

環境構築

Rust インストール

こちら を参照。

wabt インストール

github の README.md を読んでコンパイルする。
C++ のコンパイル環境が必要なので注意。

wat2wasm, wasm2wat 等のコマンドがコンパイルされるので、必要に応じて Path を通しておくと良いだろう。

Rust の WASI コンパイル環境を整える

下記のコマンドを実行する。
(Rust を 上記 の方法でインストールした場合、 rustup というコマンドもインストールされているはずだ。)

$ rustup target add wasm32-wasi

筆者のデモ環境

  • Debian 12 (WSL2 on Windows11)
  • Rust
    • cargo 1.70.0
    • rustup 1.26.0
  • wabt
    • wat2wasm 1.0.33
    • wasm2wat 1.0.33

空の WebAssembly バイナリ

他の情報と比較するために WebAssembly Text Format で空の WebAssembly を作ってみる。
エディターで下記の内容のファイルを作成し、 nothing.wat というファイル名で保存する。

(module)

WebAssembly Text Format のソースコードは、全てこのカッコ内、module の後に記述する。
(つまり、今回は空だ。)

wat2wasm は WebAssembly Text Format のソースコードをコンパイルするコマンドだ。
-v オプションを使用すると、コンパイル時に詳細情報を表示してくれる。

$ wat2wasm -v nothing.wat
0000000: 0061 736d                                 ; WASM_BINARY_MAGIC
0000004: 0100 0000                                 ; WASM_BINARY_VERSION

すると、 nothing.wasm というコンパイルされた WebAssembly のファイルが作成されるはずだ。
ちなみに、中身を 16 進数で表示すると下記の様になる。

$ hexdump nothing.wasm
0000000 6100 6d73 0001 0000
0000008

WebAssembly は little endian を採用しているので順番が多少前後するが、wat2wasm 実行時に表示された内容が WebAssembly のバイナリデータとコメントである事が分かるだろう。

WASM_BINARY_MAGIC とはファイルの最初の 4 byte の固定 bit 列の事で WebAssembly のファイルであれば必ずこの値にする決まりだ。
次の 4 byte は WebAssembly のバイナリフォーマットの規格のバージョンの事で、今回は Version 1 との事だ。

wasm ファイルのバイナリを直接読む事は困難だが、wat2wasm -v を使えばコメントを頼りに解読できる。

Hello World

次に Hello World の WASI ファイルを作成してみる。

上記の方法 でインストールした場合、cargo というコマンドもインストールされているはずだ。
まず、cargo で Rust のプロジェクトを作成する。

$ cargo new hello_world
$ cd hello_world

すると src/main.rs という Rust のソースファイルが作成されている。
プロジェクト作成直後はサンプルとして下記の様な Hello World が実装されていると思う。
もし違った場合は、下記の内容でファイルを上書きしてほしい。

fn main() {
    println!("Hello, world!");
}

まずは下記の様に普通にコンパイル、実行して Rust のプロジェクトとして問題が無い事を確認してみる。

$ cargo run
Hello, world!

次に、この状態で下記の様に WASI としてコンパイルしてみる。

$ cargo build --target wasm32-wasi

すると、target/wasm32-wasi/debug/hello_world.wasm というファイルが作成されているはずだ。
WebAssembly System Interface の精神に触る でも記載したが、WASI とは WebAssembly を使用する外部プログラムの規格の様な物であり、WASI としてコンパイルして出来るのは WebAssembly バイナリである。

この hello_world.wasm のファイルサイズを見ると驚くほど大きい。(筆者の環境では約 2.1 MB だった。)
これは Rust の標準ライブラリ関数等が(使用されていない物も)コンパイルされているためである。
最適化すればファイルサイズを小さくする事も可能だが、今回はそこまではやらない。

この wasm ファイルを読むために、まずは WebAssembly Text Format に直してみる。

$ wasm2wat -o hello_world.wat target/wasm23-wasi/debug/hello_world.wasm

すると hello_world.wat というファイルが出来るはずだ。
このファイルは WebAssembly Text Format のファイルなのでエディターで読む事が出来る。

hello_world.wat を読む

いきなり wasm バイナリを読む事はハードルが高いので、まずは wat ファイルを斜めに読んでみよう。

$ less hello_world.wat

上の方から順を追って見てみる。

type コマンド

筆者の環境では、wat ファイルの最初の方は、下記の様になっていた。

(module
  (type (;0;) (func))
  (type (;1;) (func (param i32)))
  (type (;2;) (func (param i32) (result i64)))
  (type (;3;) (func (param i32) (result i32)))
  (type (;4;) (func (param i32 i32 i32)))
...

最初の行は WebAssembly Text Format のおまじないなので、気にしない。(ファイルの最後に、対応する閉じカッコが有る。)

2 行目からは、types section に関する表記だ。

例えば types section の配列の index 0 は、引数を取らず戻り値も取らない関数の型を表す。
同様に index 6 は、引数に i32 型(符号付 32 bit 整数)を 3 個取り、戻り値が 1 個の i32 型である事を表す。

import コマンド

筆者の環境では、types の次に imports section に関する記述があっった。

...
  (import "wasi_snapshot_preview1" "fd_write" (func $_ZN4wasi13lib_generated22wasi_snapshot_preview18fd_write17h7ef83300274a0defE (type 8)))
  (import "wasi_snapshot_preview1" "environ_get" (func $__imported_wasi_snapshot_preview1_environ_get (type 7)))
  (import "wasi_snapshot_preview1" "environ_sizes_get" (func $__imported_wasi_snapshot_preview1_environ_sizes_get (type 7)))
  (import "wasi_snapshot_preview1" "proc_exit" (func $__imported_wasi_snapshot_preview1_proc_exit (type 1)))
...

imports section の配列の index 0 は、外部プログラムの wasi_snapshot_preview1 というモジュール内の fd_write という名前の物という事が分かる。

これは関数だそうだ。WebAssembly 内では _ZN4wasi13lib_generated22wasi_snapshot_preview18fd_write17h7ef83300274a0defE という名前を付けている。
(ただし funcs で記述したように、この名前はデバッグ情報以上の意味は持たない。)
また、この関数の型は types section の配列内の index 8 で定義されているらしい。

この関数は外部プログラムから合わせて 4 個の関数を import している。
「WASI は WebAssembly の外部プログラムの仕様」と記述した様に、実際にこの wasm ファイルを読み込んで instance 化するためには外部プログラムはこれらの関数を持たなくてはいけない。

func コマンド

筆者の環境では、import の次に各関数の実装が有った。

...
  (func $__wasm_call_ctors (type 0))
  (func $_start (type 0)
    (local i32)
    block  ;; label = @1
      block  ;; label = @2
        i32.const 0
        i32.load offset=1056072
        br_if 0 (;@2;)
        i32.const 0
        i32.const 1
        i32.store offset=1056072
        call $__wasm_call_ctors
        call $__main_void
        local.set 0
...

最初の行は、1 行で 1 個の関数だ。
__wasm_call_ctors という名前で関数の型は types section の index 0、つまり「引数は何も取らず、戻り値も何もない」という型である。
中身は空である。(意味のない関数だ。)

次の行から _start という関数が始まる。
この関数の型も、先ほどと同様に types section の index 0 であるらしい。

次の行は local 変数の宣言だ。

(local i32)

VM として見たとき、WebAssembly は stack マシンである。
通常の stack マシンは変数を保管する可変配列のような物を持ち、関数はその配列から値を入れたり出したりして変数を使う。
しかし WebAssembly は変数の可変配列を補う形で local 変数も使用できる。(変数のハイブリッド方式である。)

この関数は i32 型の local 変数を 1 個宣言している。
(WebAssembly Text Format では local 変数の宣言は関数の最初に行う必要がある。)

もし i32 型の変数 2 個と i64 型の変数を 1 個宣言するならば

(local i32 i32 i64)

等と記載されていただろう。

その後に実際の関数の内容が記述してあるが、ここでは細かく読む事は控える。
ただし、下記の様な call というコマンドの後に関数名が記載されている事を覚えておいてほしい。

        call $__wasm_call_ctors

call は他の関数を呼び出すコマンドだ。
上記の行では、最初に定義した「何もしない関数」である __wasm_call_ctors を呼んでいる。
後ほどバイナリを読むが、その際にはこの関数名は使われない。

蛇足だが、筆者にとって _start という関数名は興味深い。

筆者の Linux 環境で C 言語のソースファイルを gcc でコンパイルすると、Entry point address(プロセスが立ち上がった時、最初に実行する命令)が _start という関数の最初と一致している。
この _start は glibc という Linux の基本的なライブラリで定義されているが、主な処理フローは下記の通りである。

  1. 環境変数からコマンドライン引数を取得し、main の引数に適合する形に整形する
  2. 整形したコマンドライン引数を引数として main 関数を実行
  3. main 関数の戻り値を終了コードにする形でプロセスを終了

この wat ファイルの関数 _start にも、この後で call $__main_void という命令が見られる。
そして関数の最後は

    call $__wasi_proc_exit
    unreachable)

で終わっている。

table コマンド

筆者の環境では、func コマンドの次は下記のように table コマンドが記載してあった。

  (table (;0;) 88 88 funcref)

前述 のように、tables section は 2 次元配列だが、最大長は 1 である。

実際にこの wat ファイルには table コマンドが 1 個しかないので、table section の長さは 1 である。
そして、tables section 唯一の値である 1 次元配列は、初期サイズ、最大サイズともに 88 で関数ポインターを保存するとの事だ。

memory コマンド

筆者の環境では、table コマンドの次は下記の様に memory コマンドが記載してあった。

  (memory (;0;) 17)

前述 の様に mems section は値がたかだか 1 個の配列である。
ここでは 1 個の線形 memory を 17 page (1 page は 64 KB) で初期化している。
最大サイズは指定していないので WebAssembly の仕様上の上限である 2 GB となる。

global コマンド

筆者の環境では、memory コマンドの次は下記の様に global コマンドが記載してあった。

  (global $__stack_pointer (mut i32) (i32.const 1048576))

global コマンドは 1 個しか無いので、この WebAssembly の global section は長さ 1 の配列である。
(つまり、global 変数を 1 個しか使わない。)

この global 変数は __stack_pointer という名前で、(mut i32) 型、つまり可変な 32 bit 符号付整数である。
初期値は (i32.const 1048576) つまり 1048576 に設定されているらしい。

export コマンド

筆者の環境では、global コマンドの次は下記の様に export コマンドが記載してあった。

  (export "memory" (memory 0))
  (export "_start" (func $_start))
  (export "__main_void" (func $__main_void))

この WebAssembly が外部に公開している section の値は 3 個である。

まず memory という名前で mems section 配列の index 0 線形 Memory を公開している。
(繰り返しになるが、mems section 配列は長さは 1 なので、線形 Memory を全て公開している事になる。)
これによって線形 Memory を WebAssembly instance と外部プログラム間における文字列の受け渡し等に利用できるようになる。

次に、_start という名前で関数 _start を公開している。
次の行では、同様に __main_void という関数を公開している。
(この WebAssembly コンパイラは内部で使用する関数名と外部に公開する時の関数名を同じ物にする方針なのだろう。)

これらの関数は WebAssembly instance の public method として外部プログラムから実行できる様になる。

elem コマンド

筆者の環境では、export コマンドの次は下記の様に elem コマンドが記載してあった。

  (elem (;0;) (i32.const 1) func $_ZN11hello_world4main17h5a8997a1daf0eaf4E...

前述 のように、elems section は 2 次元配列である。
elems コマンドは 1 個しか無いので、elems section の配列の長さは 1 だ。

elems section の唯一の値である 1 次元配列の中身は func $_ZN11hello_world4main17h5a8997a1daf0eaf4E... だそうだ。
func で始まる様に関数を指す抽象ポインターの配列であり、以降に構成する関数名が列挙してある。

data コマンド

筆者の環境では、elem コマンドの次は下記の様に data コマンドが記載してあった。

  (data $.rodata (i32.const 1048576) "Hello, world!\0a\00\00\00\00\10\00\0e...
  (data $.data (i32.const 1056056) "\01\00\00\00\ff\ff\ff\ff\d4\10\10\00"))

なお、wat ファイルはここで終了している。
上記の最後の閉じカッコは、先頭行の開きカッコに対応している。

data コマンドは 2 個あるので、この WebAssembly の data section の配列は長さが 2 である。

配列の index 0 の値には .rodata という名前を付けており、 Hello, world!\0a\00\00\00\00\10\00\0e... というバイト列を保存している。
(i32.const 1048576) と有る様に、このバイト列は instance 作成時に線形メモリの 1048576 byte 目からコピーされる。
(起動直後の線形 Memory の 1048576 byte 目は .rodata の先頭の 'H' に対応する ascii code の値が入っているはずだ。)

同様に、index 1 の値には .data という名前を付けており、 \01\00\00\00\ff\ff\ff\ff\d4\10\10\00 というバイト列を保存している。

.rodata の先頭は "Hello, world!" となっている事に気づいただろうか?
実行時に表示する文字列を、ここに保存しているのだろう。
.rodata の続きを見てみると、他にもエラーメッセージで使いそうな文字列等が見つかるはずだ。

また、.rodata のコピー先の index である 1048576 という数字は、先ほど読んだ global 変数の __stack_pointer の初期値と同じである。
この値が一致するのは偶然では無い。
興味のある人は WebAssembly 用の Rust メモリ管理関数を読んで欲しい。

蛇足だが、バイナリハックが好きな人には .rodata や .data という命名になじみが有るかもしれない。
例えば筆者の Linux 環境で C 言語のソースコードを gcc でコンパイルすると .data という global 変数の section が出来る。
同様に .rodata は "Read Only .data" の略で読み込み専用の global 変数 section の名前に使われている。

おそらく WebAssembly に関わる人達は data section に対して global 変数というイメージを持っているのでは無いだろうか?。
(global section は別に存在するのだが。)

固定文字列を内部的に read only な global 変数の section に保存する事は良くある事だ。
ただ WebAssembly は global 変数に文字列やバイト列を保存出来ないし、global 変数へのポインターを使用できない(線形 Memory 以外を指すポインターは使用できない。)
そこで「instance 起動時に線形 Memory へコピーする」という苦肉の策で対応しているのだろう。

start コマンド

ここまで読んで気づいた人も居るかもしれないが、この wat ファイルには start コマンドが記載していない。
なので start section は存在しない。
外部プログラムが instance を作成した際に自動で走る関数は無い。

instance 作成後に外部プログラムから関数を実行すれば start section と同じ事ができる。
筆者の調べた限りでは start section はあまり使われていないようだ。

hello_world.wasm を読む

wat ファイルを読んで概要を理解出来たと思うので、いよいよコンパイルされた WebAssembly のバイナリファイルを読んでみる。

空の WebAssembly の時と同じように wat2wasm コマンドに -v オプションを付けて実行する。
(このコマンドは wat を指定する必要が有る。なので 先ほど読んだ hello_world.wat を使用する。)

wat2wasm の詳細情報は標準エラー出力にされるのでパイプで別のコマンドにつなぐ時などは注意されたし。

$ wat2wasm -v hello_world.wat 2>&1 | less

上から順に読んでいこう。

最初の 8 byte は空の WebAssembly の時と同じはずだ。

0000000: 0061 736d                                 ; WASM_BINARY_MAGIC
0000004: 0100 0000                                 ; WASM_BINARY_VERSION

空の WebAssembly の場合はここで終わったが、hello_world はこの後がある。

section "Type" (1)

9 byte 目からは types section の情報だ。

; section "Type" (1)
0000008: 01                                        ; section code
0000009: 00                                        ; section size (guess)
000000a: 11                                        ; num types

最初の 3 byte の意味は下記だ。

  • section code 0x01 (これは WebAssembly では types section を意味する)の内容が始まる
  • section のバイト数を記載。しかしバイト数はこの section のコンパイルが終わるまで分からない。なので、とりあえず 0 byte と仮定(guess)して次に進む
  • types section の配列の長さは 0x11(= 17)である。(つまり、配列の最後の index は 16 である。)

次から types section 配列の各値が記載される。
ここはコメントを読めば、なんとなく分かるだろう。

; func type 0
000000b: 60                                        ; func
000000c: 00                                        ; num params
000000d: 00                                        ; num results
; func type 1
000000e: 60                                        ; func
000000f: 01                                        ; num params
0000010: 7f                                        ; i32
0000011: 00                                        ; num results
...

; func type 0 、つまり types section 配列の index 0 の情報であるとのコメントだ。
配列の最初の値は下記の 3 byte である。

  • 0x60: func の型情報(筆者の認識では types section に func 以外の型情報が来る事は無いので、この byte は使わないはずだ。おそらく今後の拡張を考えた仕様だろう。)
  • 0x00: 引数の数は 0
  • 0x00: 戻り値の数も 0

次に ; func type 1 、つまり types section 配列の index 1 の情報が来る。
違いは引数の数が 0x01 である事。
なので引数の数の後に、1 個目の型 (i32) を表す 0x7f が来ている。

上記のような記載を 17 個繰り返し、types section の最後は下記の様に終了している。

; func type 16
0000077: 60                                        ; func
0000078: 03                                        ; num params
0000079: 7e                                        ; i64
000007a: 7f                                        ; i32
000007b: 7f                                        ; i32
000007c: 01                                        ; num results
000007d: 7f                                        ; i32
0000009: 74                                        ; FIXUP section size

最後の index 16 の型情報まで作ったところで、types section の byte 数が 0x74 であると分かった。
そこで先に「とりあえず 0 byte と仮定(guess)」した所を 0x74 で書き換えたのだ。

section "Import" (2)

次に imports section の情報が来る。
コメントを参考に types section と同様の方法で読むことが可能だろう。

; section "Import" (2)
000007e: 02                                        ; section code
000007f: 00                                        ; section size (guess)
0000080: 04                                        ; num imports
; import header 0
0000081: 16                                        ; string length
0000082: 7761 7369 5f73 6e61 7073 686f 745f 7072  wasi_snapshot_pr
0000092: 6576 6965 7731                           eview1  ; import module name
0000098: 08                                        ; string length
0000099: 6664 5f77 7269 7465                      fd_write  ; import field name
...

ただし、imports section の最後は types section と少し異なる。

; move data: [80, 116) -> [81, 117)
000007f: 9601                                      ; FIXUP section size

最初に section の byte 数を 0x00 と仮定していたが、実際の byte 数を表すのに 2 byte 必要な事が分かったのだ。
そこで、imports section の内容を 1 byte ずつ後ろへシフト( ; move data: [80, 116) -> [81, 117) )して byte を表す数として 0x9601 を記載した。

もちろん、imports section の長さが 0x9601 byte もあるわけではない。
詳細な説明は避けるが、WebAssembly 流の表記方法である。

section "Function" (3)

次は funcs section の情報が来る。
funcs section の説明と矛盾するように聞こえるかもしれないが、実際の関数の中身はここには記載していない。

中身は後ほど出てくる code section に記載してある。
ここには、関数の型情報や「実装は code section の何 byte 目に記載してあるか」等の情報のみを記載している。

code section については、公式ドキュメントに記載は無い。
しかし、バイナリデータを読めば存在を確認できる。

その他の section

これ以降の section は、コメントを頼りにすれば上記と同様の方法で何となく読めるだろう。
ただ、WebAssembly 内で関数名を使っていない事は注目する価値がある。

例えば wat ファイルでは elem コマンドで多くの関数名を並べていた。
しかしバイナリファイルの section "Elem" (9) では funcs section の index のみ記載されている事に気が付くだろう。

同様に wat ファイルの funcs コマンド内では、call コマンドの引数として関数名を使っていた事を思い出して欲しい。
しかし section "Code" (10) のコメントから call コマンドの部分を探すと、やはり後ろには funcs section の index が記載されている。

一方で section "Export" (7) では関数名として文字列が使われている。

WebAssembly の関数の呼び出しにおいて、内部から呼ぶ時と外部から呼ぶ時で明確に差がある事が理解できるだろう。
(最適化の状況にも依るが)外部プログラムから呼ぶ時は exports section の配列から一致する文字列を順に探す手間が必要だ。
これは外部から関数を呼ぶときにパフォーマンスが若干悪くなる理由の 1 個だ。

もっとも、これは WebAssembly 特有の仕様ではない。
OS 上の普通のアプリケーションでも動的ライブラリより静的ライブラリの方がパフォーマンスが良い事は聞いたことが有るだろう。
どこでも似たような事をやっているのだ。

まとめ

「最新技術だ、ウェーイ」良いが、1 つの技術の深くまで掘り下げる事も楽しい。
特に WebAssembly は歴史が浅く、これから専門家を目指すには良い分野だろう。
この記事がきっかけで WebAssembly に興味を持つ人が 1 人でも居てくれれば、筆者の喜びである。

13
3
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
13
3