レッスン5 — 命令テーブルをステップで組み立てる
問い
EVM がバイトコード中に 0x01 を見たとき、どんな仕組みで add が呼ばれると決まるか? それがディスパッチ — 1 バイトを 1 関数呼び出しに変換、ホットパスのホットパス。素朴な match から本物の 256 スロット const テーブルまで、どう積み上げるか?
原理(最小モデル)
- 本物の形.
const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>() -> InstructionTable<WIRE, H> { let mut table = [Instruction::unknown(); 256]; table[ADD as usize] = Instruction::new(arithmetic::add); ... }。 - 5 ステップ積み上げ. 256 アーム
match→ 配列ルックアップ →const fn→Instruction構造体ラップ → 完成形。 - 配列が 256 サイズの理由. Opcode は 1 バイト = 256 通り、固定サイズ配列が空間網羅、各バイトは Opcode を持つか
unknownにマップ。 const fnの意味. コンパイル時に評価、構築済みテーブルがバイナリのデータセクションに焼き込まれる、起動コストゼロ、実行時のTABLEは手書き配列リテラルと同一。Instruction構造体ラップ. ① 将来のメタデータ拡張(gas_cost、name 等)、② 型規律(シグネチャ不一致は代入行でコンパイルエラー)。Instruction::unknown()初期化. 未定義 Opcode が UB や静かな見逃しではなくクリーンUnknown停止を生む。
具体例
ステップ 0 — 素朴 match:
fn dispatch(byte: u8, ctx: &mut Context) -> Result {
match byte {
0x01 => add(ctx),
0x02 => mul(ctx),
// ... アーム 256 個
_ => return Err(Unknown),
}
}
問題: ① ジャンプテーブル変換が LLVM 任せ(保証なし)、② 巨大 match 編集 UX。
ステップ 1 — 関数ポインタ配列:
let mut table: [fn(&mut Context) -> Result; 256] = [unknown; 256];
table[0x01] = add;
table[0x02] = mul;
fn dispatch(byte: u8, ctx: &mut Context) -> Result {
(table[byte as usize])(ctx)
}
インデックス参照 1 回、O(1) 保証、ハッシュなし。Opcode は 1 バイト = 256 通り、固定サイズ配列がバイト空間網羅。
ステップ 2 — テーブルを const にする:
const fn build_table() -> [fn(&mut Context) -> Result; 256] {
let mut t = [unknown; 256];
t[0x01] = add;
t[0x02] = mul;
// ...
t
}
const TABLE: [fn(&mut Context) -> Result; 256] = build_table();
const fn = コンパイル時に評価可能。コンパイラが build_table() をコンパイル中に実行、結果配列を凍結してバイナリのデータセクションに焼き込む。実行時の TABLE は手書き配列リテラルと同一、ディスパッチ準備の実行時コストはゼロ。
ステップ 3 — 関数ポインタを構造体で包む:
#[derive(Debug)]
pub struct Instruction<W: InterpreterTypes, H: ?Sized> {
fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult,
}
impl<W: InterpreterTypes, H: Host + ?Sized> Instruction<W, H> {
#[inline]
pub const fn new(fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult) -> Self {
Self { fn_ }
}
}
2 利点: ① 将来のメタデータ拡張(gas_cost / name 等)をディスパッチシグネチャを変えずに追加、② 型規律 = Instruction::new(arithmetic::add) は裸の関数ポインタ代入より型安全、シグネチャ不一致は 代入の行 でコンパイルエラー(バグを実行時から構築時へ前倒し)。ジェネリクス W: InterpreterTypes, H: ?Sized は 2 レッスン前に積み上げた IT と H と同形。
ステップ 4 — 完成形:
#[derive(Debug)]
pub struct Instruction<W: InterpreterTypes, H: ?Sized> {
fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult,
}
impl<W: InterpreterTypes, H: Host + ?Sized> Instruction<W, H> {
#[inline]
pub const fn new(fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult) -> Self {
Self { fn_ }
}
}
ステップ 5 — 完成形:
const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>() -> InstructionTable<WIRE, H> {
use bytecode::opcode::*;
let mut table = [Instruction::unknown(); 256];
table[ADD as usize] = Instruction::new(arithmetic::add);
table[MUL as usize] = Instruction::new(arithmetic::mul);
table[SUB as usize] = Instruction::new(arithmetic::sub);
// ... 残り全 Opcode
table[LT as usize] = Instruction::new(bitwise::lt);
// ...
table
}
Opcode バイトマップ(参考):
| バイト | Opcode |
|---|---|
| 0x01 | ADD |
| 0x02 | MUL |
| 0x60–0x7F | PUSH1–PUSH32 |
| 0x80–0x8F | DUP1–DUP16 |
| 0xA0–0xA4 | LOG0–LOG4 |
| 0x0C–0x0F | 未割当 ← カスタム Opcode のためのギャップ |
| 0x21–0x2F | 未割当 |
失敗例(誤解)
「match を LLVM が必ずジャンプテーブルに変換する」— 間違い。「普通」やってくれるがコンセンサス契約に「普通」は載せられない、配列インデックス参照が O(1) 保証。
「HashMap<u8, Instruction> で十分」— 間違い。ハッシュ計算オーバーヘッド + アロケーション + キャッシュミス、配列が最速。
「裸の fn ポインタで十分、構造体ラップは飾り」— 間違い。① 将来のメタデータ拡張、② 型規律 = シグネチャ不一致が代入行でコンパイルエラー、裸 fn だと実行時まで気付かない。
ステップで組み立てる
Step 1: 素朴 match の 2 問題
ジャンプテーブル保証なし + 巨大 match 編集 UX。
Step 2: 関数ポインタ配列
インデックス参照 1 回、O(1) 保証、256 サイズで空間網羅。
Step 3: const fn でコンパイル時構築
バイナリのデータセクションに焼き込み、起動コストゼロ。
Step 4: Instruction { fn_ } ラップ
将来メタデータ + 型規律(シグネチャ不一致を代入時に検出)。
Step 5: 完成形
const fn instruction_table_impl<WIRE, H>() + unknown() 初期化 + 定義済み Opcode 上書き。
答え合わせ
- 配列を
matchやHashMapでなく使う理由: ① O(1) 保証(ハッシュなし、LLVM のジャンプテーブル変換の匙加減なし)、② 256 サイズでバイト空間網羅、各バイトは定義済み Opcode かunknownにマップ、形と最悪レイテンシ両方が契約の一部。 const fnの節約: 実行時にテーブル構築コードは 走らない = スロットを埋めるシーケンスがコンパイル時に解決、バイナリのデータセクションに焼き込まれた配列リテラルを読むだけ。ディスパッチ準備の起動コストゼロ。unknown()初期化の理由: 未定義バイトがどれも UB や静かな見逃しではなく クリーンUnknown停止 を生む。unknown()は安全なデフォルト、定義済み Opcode が上書き、意図的な安全選択。
合格基準
- 完成形の
const fn instruction_table_impl<WIRE, H>()4 構成要素を即書ける。 - 配列が 256 サイズである理由(バイト空間網羅 + O(1) 保証)を即答できる。
const fnの実行時節約を 1 文で説明できる。Instruction構造体ラップの 2 利点(メタデータ + 型規律)を言える。unknown()初期化の安全選択理由を即答できる。
まとめ(3行)
- 本物の命令テーブル = 256 スロット
[Instruction; 256]配列 +const fnでバイナリに焼き込み +unknown()初期化 + 定義済み Opcode 上書き。 - 5 ステップ積み上げ(
match→ 配列 →const fn→Instructionラップ → 完成形)、各ステップに O(1) 保証 / 起動コストゼロ / 型規律の明確な動機。 - ジェネリクス
<WIRE, H>は前 2 レッスンのITとHと同形 = 1 テーブルで全実行モード × 全ホスト型対応、次レッスンで自前 Opcode を配線。