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 で可変長圧縮順に見ていく。
- バイナリをそのまま読む: LEB128(可変長整数)で長さやインデックスを読み、セクションを順に解釈する
- スタックマシン: bytecode 編と同じく値スタックで演算。ローカル変数は番号でアクセス
- 構造化制御フロー:
br Lは「今いるブロックの L 個外側」を狙う。block/if は脱出、loop は継続。飛び先が構造で決まる = 検証できる
① バイナリを読む: LEB128 とセクション
WASM のバイト列は人間には読めないが、規則は単純だ。まず数値の詰め方はLEB128(可変長整数)だ。7 ビットずつ小端から並べ、各バイトの最上位ビットが「まだ続く」印。小さい数は 1 バイト、大きい数だけ長くなる。セクション長・インデックス・即値はほぼこれで書かれている:
// 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)の位置を求めておけば、実行時の飛び先は表引きで済む:
// 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 の先へ抜ける(脱出):
// 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 命令が、狙うブロックの種類で「継続」にも「脱出」にもなる。これで while や break が、任意ジャンプ無しに表現できる。関数呼び出し(call)は呼び先を実行して結果をスタックへ戻すだけなので、再帰もそのまま動く。
動かす
下のデモは、この最小 WASM インタプリタをそのままブラウザで動かしている。プログラム(実バイト列を組んだ WASM モジュール)を選ぶと、逆アセンブルした命令列が出る。「1手すすめる」で VM が1命令ずつ実行し、値スタックの伸び縮みと、loop の br で制御が本体先頭へ戻る様子(=ループが回る)を追える。任意ジャンプが無くても、構造化ブロックと br だけでループも分岐も書けているのが見えるはずだ。
定数 0 を積む
loop への br は本体先頭へ戻り(継続)、block/if への br は end の先へ抜ける(脱出)。任意ジャンプ無しでループも分岐も書ける = 実行前に検証できる。
設計の観点: なぜ 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)のような値を返すブロックは使わず、結果はローカルと関数戻り値でやり取り - 検証はしない: 実機は実行前に型・スタック整合を静的検証する。本章は信頼できる入力を前提に実行のみ(「なぜ検証できるか」= 構造化制御フローは本文で説明)
参考資料
- WebAssembly Specification — バイナリ形式と実行の一次資料
- WebAssembly binary format — セクションと LEB128 の詳細
- MDN, "Understanding WebAssembly text format" — WAT(人間可読形式)と対応づけると理解が早い
- 実装: foundations/wasm