Skip to content

wasm(最小 WebAssembly インタプリタ)

bytecode 編で作ったバイトコード VM の、業界標準版が WebAssembly になる。実際の .wasm をパースし、スタックマシンで動かす最小インタプリタを Go で書く。特徴は制御フローが構造化されていることだ。任意アドレスへ飛ぶ命令はなく、入れ子のブロックの深さを狙う分岐しかない。飛び先が静的に決まるので、実行前に検証できる。ブラウザが他人のコードを安心して走らせられる理由だ。

この章で作るもの

bytecode 編では、自分で決めた命令セットを自分の VM で回した。WebAssembly は、それを業界標準にした移植可能なスタックマシンのバイトコードで、ブラウザ・サーバ・エッジ・プラグイン基盤で動く。本章では、その実物のバイト列を読んで動かす。

  .wasm バイト列                          実行
  ┌──────────────────────────┐
  │ \0asm  01 00 00 00       │ マジック + バージョン
  ├──────────────────────────┤          値スタック      制御スタック
  │ Type   section (関数の型)│  parse   ┌─────┐         ┌─ loop ─┐
  │ Func   section (型の割当)│ ──────▶  │  15 │         │ block  │
  │ Export section (名前→id) │          │   5 │  ◀─ br ─▶│ ...    │
  │ Code   section (命令列)  │          └─────┘         └────────┘
  └──────────────────────────┘        i32.add 等で計算   深さを狙う脱出/継続
        LEB128 で可変長圧縮
.wasm バイナリの構造と実行。先頭のマジック + バージョンに続き、型・関数・エクスポート・コードのセクションが並ぶ。数値は LEB128 で可変長圧縮されている。パーサがこれを Module へ復元し、スタックマシンが構造化制御フロー(block/loop/if と br)で実行する

順に見ていく。

  1. バイナリをそのまま読む: LEB128(可変長整数)で長さやインデックスを読み、セクションを順に解釈する
  2. スタックマシン: bytecode 編と同じく値スタックで演算。ローカル変数は番号でアクセス
  3. 構造化制御フロー: br L は「今いるブロックの L 個外側」を狙う。block/if は脱出、loop は継続。飛び先が構造で決まる = 検証できる

① バイナリを読む: LEB128 とセクション

WASM のバイト列は人間には読めないが、規則は単純だ。まず数値の詰め方はLEB128(可変長整数)だ。7 ビットずつ小端から並べ、各バイトの最上位ビットが「まだ続く」印。小さい数は 1 バイト、大きい数だけ長くなる。セクション長・インデックス・即値はほぼこれで書かれている:

go

// reader は .wasm バイト列を先頭から順に読むカーソル。
type reader struct {
	b   []byte
	pos int
}

func newReader(b []byte) *reader { return &reader{b: b} }

func (r *reader) eof() bool { return r.pos >= len(r.b) }

// readByte は 1 バイト読み進める。
func (r *reader) readByte() (byte, error) {
	if r.pos >= len(r.b) {
		return 0, errors.New("wasm: 予期しない終端")
	}
	b := r.b[r.pos]
	r.pos++
	return b, nil
}

// readBytes は n バイトぶんの部分列を返す。
func (r *reader) readBytes(n int) ([]byte, error) {
	if r.pos+n > len(r.b) {
		return nil, errors.New("wasm: バイト不足")
	}
	out := r.b[r.pos : r.pos+n]
	r.pos += n
	return out, nil
}

// uleb は符号なし LEB128 を読む。7 ビットずつ、最上位ビットが「続く」印。
// セクション長・インデックス・要素数など、WASM の可変長整数はほぼこれ。
func (r *reader) uleb() (uint64, error) {
	var result uint64
	var shift uint
	for {
		b, err := r.readByte()
		if err != nil {
			return 0, err
		}
		result |= uint64(b&0x7f) << shift
		if b&0x80 == 0 {
			break
		}
		shift += 7
		if shift >= 64 {
			return 0, errors.New("wasm: LEB128 が長すぎる")
		}
	}
	return result, nil
}

// u32 は符号なし LEB128 を uint32 として読む。
func (r *reader) u32() (uint32, error) {
	v, err := r.uleb()
	if err != nil {
		return 0, err
	}
	return uint32(v), nil
}

// sleb は符号つき LEB128 を読む。最後のバイトの符号ビットで負数へ拡張する。
// i32.const の即値などはこちら。
func (r *reader) sleb() (int64, error) {
	var result int64
	var shift uint
	var b byte
	for {
		bb, err := r.readByte()
		if err != nil {
			return 0, err
		}
		b = bb
		result |= int64(b&0x7f) << shift
		shift += 7
		if b&0x80 == 0 {
			break
		}
		if shift >= 64 {
			return 0, errors.New("wasm: LEB128 が長すぎる")
		}
	}
	// 符号拡張: 読み切ったビット幅の外側を、符号ビットで埋める。
	if shift < 64 && b&0x40 != 0 {
		result |= -1 << shift
	}
	return result, nil
}

// s32 は符号つき LEB128 を int32 として読む。
func (r *reader) s32() (int32, error) {
	v, err := r.sleb()
	if err != nil {
		return 0, err
	}
	return int32(v), nil
}

// expect は指定バイト列が続くことを確かめて読み飛ばす(マジック等の検査)。
func (r *reader) expect(want []byte, what string) error {
	got, err := r.readBytes(len(want))
	if err != nil {
		return err
	}
	for i := range want {
		if got[i] != want[i] {
			return fmt.Errorf("wasm: %s が不正", what)
		}
	}
	return nil
}

.wasm は、マジック \0asm とバージョンのあとにセクションが並ぶ形をしている。すなわち型セクション(関数のシグネチャ)、関数セクション(各関数の型)、エクスポートセクション(名前 → 関数番号)、コードセクション(ローカル宣言 + 命令列)。パーサはこれらを順に読み、Module を組み立てる。知らないセクションは長さぶん読み飛ばせる(前方互換)。

② 構造化制御フロー: 飛び先を静的に決める

ここが WASM の要になる。bytecode 編の if は「任意のオフセットへ飛ぶジャンプ」にコンパイルされた。WASM は違う。制御は必ず block / loop / if という入れ子ブロックの形をしていて、br は「今いるブロックの N 個外側」しか狙えない。どこへ飛ぶかはコード構造だけで静的に決まる。パース時に各ブロックの end(と else)の位置を求めておけば、実行時の飛び先は表引きで済む:

go

// matchBlocks は入れ子ブロックの開始命令に、対応する else / end のインデックスを埋める。
// これで実行時の br の飛び先が静的に決まる——WASM が実行前に検証できる理由の一端。
func matchBlocks(instrs []Instr) {
	var stack []int
	for i := range instrs {
		switch instrs[i].Op {
		case opBlock, opLoop, opIf:
			stack = append(stack, i)
		case opElse:
			if len(stack) > 0 {
				instrs[stack[len(stack)-1]].Else = i
			}
		case opEnd:
			if len(stack) > 0 {
				top := stack[len(stack)-1]
				stack = stack[:len(stack)-1]
				instrs[top].End = i
				if instrs[top].Op == opIf && instrs[top].Else == -1 {
					instrs[top].Else = i // else 無き if は、偽なら end へ
				}
			}
		}
	}
}

これが「なぜ WASM は実行前に検証できるのか」の答えになる。任意番地ジャンプがあると、スタックの整合性や到達性を静的に確かめるのは難しい。構造化されていれば、型検査もスタック高さの追跡も静的にできる。信頼できないコードを走らせる前に安全だと確かめられるので、ブラウザは他人の WASM を安心して実行できる。

③ 実行: 値スタック + 制御スタック

VM は bytecode 編と同じくスタックマシンだが、制御スタックが加わる。block/loop/if に入るとフレームを積み、br はそのフレームを狙う。ポイントは loop と block で br の意味が変わることだ。loop を狙えば本体先頭へ戻り(繰り返し)、block/if を狙えば end の先へ抜ける(脱出):

go

// doBranch は br <label> の飛び先と、畳んだあとの制御スタックを返す。
// label は「今いるブロックの何個外側か」。loop を狙ったら本体先頭へ戻る(継続)、
// block/if を狙ったら end の先へ抜ける(脱出)——同じ br が、狙う種類で意味を変える。
func doBranch(label int, control []ctrl) (int, []ctrl) {
	idx := len(control) - 1 - label
	target := control[idx]
	if target.op == opLoop {
		return target.target, control[:idx+1] // loop フレームは残す(次の周回のため)
	}
	return target.target, control[:idx] // block/if フレームは畳んで抜ける
}

同じ br 命令が、狙うブロックの種類で「継続」にも「脱出」にもなる。これで whilebreak が、任意ジャンプ無しに表現できる。関数呼び出し(call)は呼び先を実行して結果をスタックへ戻すだけなので、再帰もそのまま動く。

動かす

下のデモは、この最小 WASM インタプリタをそのままブラウザで動かしている。プログラム(実バイト列を組んだ WASM モジュール)を選ぶと、逆アセンブルした命令列が出る。「1手すすめる」で VM が1命令ずつ実行し、値スタックの伸び縮みと、loopbr制御が本体先頭へ戻る様子(=ループが回る)を追える。任意ジャンプが無くても、構造化ブロックと br だけでループも分岐も書けているのが見えるはずだ。

デモwasm(最小 WebAssembly VM)ip 0
sum_to(5)add(3,4)max(9,4)

設計の観点: なぜ WebAssembly か

  • 移植可能性と速度の両立: WASM は「1 度コンパイルすればどこでも動く」バイトコードでありながら、JIT/AOT で機械語に落としてネイティブに近い速度が出る。JVM バイトコードの発想を、言語非依存・Web 標準にしたもの
  • なぜ構造化制御フローか: 任意ジャンプを禁じ block/loop/if に限ることで、実行前の静的検証が可能になる。型の整合・スタック高さ・到達性を確かめてから走らせられるので、信頼できないコードでも安全に実行できる。これが Web で他人のコードを動かす前提を成立させる
  • 線形メモリとサンドボックス: WASM の状態は 1 本の連続バイト配列(線形メモリ)に閉じ、境界チェック付きでしかアクセスできない。ホスト(ブラウザ)のメモリには触れない。この隔離が安全性の要(本章では未実装、container 編の隔離と同じ発想)
  • ホストとの境界(WASI 等): WASM 自体は計算しかできない。ファイルやネットワークはインポートしたホスト関数経由でしか触れない。何を import させるかで能力を絞れる = ケイパビリティベースの安全性
  • bytecode 編との関係: 自作 VM で「命令列 + スタックマシン」を理解していれば、WASM は「それを標準化し、型と構造化制御フローと検証を足したもの」と一言で言える

メリット・デメリットと実例

技術移植性速度安全な隔離実例
WebAssembly高い(Web 標準)ネイティブに近い(JIT/AOT)強い(検証+線形メモリ)ブラウザ、Cloudflare Workers、Envoy、Wasmtime
JVM バイトコードJVM の上なら動く速い(HotSpot JIT)ある(バイトコード検証)Java、Kotlin、Scala
ネイティブ(.so/.dll)無い(CPU/OS 依存)最速無い(同一プロセス)C/C++ プラグイン
コンテナOS が合えば動く最速ある(namespace/cgroup)Docker、K8s

裏どり:

  • ブラウザ: 4 大ブラウザすべてが WASM を実装。C/C++/Rust/Go を .wasm にコンパイルして高速処理(ゲーム・画像処理・暗号)を Web で動かす。JS より予測可能な性能
  • サーバ/エッジ: Cloudflare Workers・Fastly は WASM でユーザコードを安全に隔離実行。起動が軽く(コンテナより速い)、検証済みで安全。Wasmtime / WasmEdge がランタイム
  • プロキシ拡張: Envoy / Istio は WASM フィルタでデータプレーンを拡張。ホストを再ビルドせず安全にプラグインできる
  • WASI: WebAssembly System Interface。ブラウザ外で WASM にファイル/時刻/ネットワークを与える標準。「何を import させるか」で能力を制御するケイパビリティモデル

簡略化したこと

  • i32 のみ: i64 / f32 / f64・型変換命令は扱わない
  • 線形メモリなし: WASM の目玉であるサンドボックス付き線形メモリ(memory + i32.load/store)は未実装(仕組みは設計の観点の節で説明)
  • import/global/table なし: ホスト関数のインポート、グローバル変数、call_indirect・テーブルは扱わない
  • ブロック結果型は void: block (result i32) のような値を返すブロックは使わず、結果はローカルと関数戻り値でやり取り
  • 検証はしない: 実機は実行前に型・スタック整合を静的検証する。本章は信頼できる入力を前提に実行のみ(「なぜ検証できるか」= 構造化制御フローは本文で説明)

参考資料