FABRKNT
スタックを読む — 中級への橋渡し
EVM をバイト単位で
レッスン 1 / 10·CONTENT12 分25 XP
コース
スタックを読む — 中級への橋渡し
レッスンの役割
CONTENT
順序
1 / 10

レッスン0 — Solidity からバイトコードへ — ディスパッチループ

問い

Solidity を書いて Foundry でデプロイしてきた。デプロイ後の EVM が実際に何をしているか — バイトの世界へ降りる。これがないと revm/crates/interpreter ソースは雑音にしか読めない。

原理(最小モデル)

  • Bytecode = バイト列. 0x60 0x80 0x60 0x40 0x52 ... 等、各バイトが opcode or リテラル、EVM 版の x86 機械語。
  • PUSH1 0x60 等のリテラル付き opcode. 1 バイトの literal を読み込んでスタックに push、PC は 2 進む(PUSH32 は 33 進む)。
  • PC(Program Counter)+ コアループ. loop { opcode = bytecode[pc]; handler = instruction_table[opcode]; handler(...); pc++; if halted break; } の擬似コード 3 行 = EVM の全部。
  • 256 エントリの instruction_table. バイト値 0x00-0xFF 各 1 スロット、各スロットが opcode ハンドラへの関数ポインタ、O(1) ディスパッチ。
  • Halt opcode. STOP / RETURN / REVERT / INVALID / Out-Of-Gas、すべてループを break、結果(成功 / 失敗 / 状態巻き戻し)は異なる。
  • JUMP / JUMPI で PC 任意設定. Solidity の if / for / 関数呼び出しが JUMP に compile される、PC を直接動かす制御フロー。
  • Solidity ABI と function selector. コントラクト最初の 4 バイトが function selector(keccak256(signature)[..4])、ディスパッチが「どの関数を呼ぶか」を判定。

具体例 + ステップで組み立てる

Solidityからバイトコードへ — ディスパッチループ

Solidity を書き、Foundry でデプロイとテストもしてきた。次の問いは、デプロイ後の EVM が実際に何をしているかである。このレッスンは 1 段下のレイヤー、つまりバイトの世界へ降りる。

これが 中級レッスンが暗黙の前提にしているレイヤー。これがないと revm/crates/interpreter のソースは雑音にしか読めません。

Solidity が変身する先

Solidity コントラクトをコンパイルすると、出力は bytecode — 文字通りバイトの列。実際のデプロイ済みコントラクトの一部:

0x60 0x80 0x60 0x40 0x52 0x34 0x80 0x15 0x60 0x10 0x57 ...

各バイトは:

  • opcode(EVM が知っている命令)
  • ある種の push opcode に続く リテラル値

最初のバイト 0x60PUSH1 opcode。2番目のバイト (0x80) は push する 1 バイトのリテラル。

次の 0x60 0x40 — もう一度 PUSH1、リテラル 0x40。 次の 0x52MSTORE (スタックトップ 2 つをメモリへ書き込み)。

魔法ではない。これは EVM 版の x86 機械語 — フラットなバイト列で、ランタイムにとって特定の意味を持つ。

EVM はそのバイト列で何をやるか

EVM は program counter (PC)、すなわち現在のバイトを指す整数を持つ。コアループ:

loop {
    let opcode = bytecode[pc];                 // 1 バイト fetch
    let handler = instruction_table[opcode];   // O(1) 配列参照
    handler(stack, memory, gas, ...);          // 実行
    pc = pc + 1;                               // (または jump)
    if halted { break; }
}

これが EVM の全部。擬似コード 3 行。

面白い部分:

  1. instruction_table256 エントリの配列(バイト値 0x00–0xFF それぞれ 1 スロット)。各スロットは opcode ハンドラへの関数ポインタ。
  2. PC 管理 — ほとんどの opcode は PC を 1 進める。ただし:
    • PUSH1 は 2 進む(リテラル 1 バイトをスキップ)。PUSH32 は 33 進む。
    • JUMPJUMPI は PC を任意の値(分岐先)に設定。
  3. HaltSTOPRETURNREVERTINVALIDOut-Of-Gas すべてループを break する。ただし結果(成功 / 失敗 / 状態巻き戻し / 巻き戻しなし)が異なる。

あなたが使ったことのある opcode: ADD (0x01)

ADD はスタックトップ 2 つを取って加算、結果を push する。擬似コード:

fn add(stack, gas) {
    gas.charge(3);                  // ADD は固定 3 ガス
    let a = stack.pop();
    let b = stack.pop();
    stack.push(a.wrapping_add(b));  // mod 2^256、panic しない
}

3 つの重要な詳細:

  • ガス: 各 opcode はガスを払う。ADD は固定 3。SLOAD は動的(cold = 2100, warm = 100)。実行中の Out-of-gas はフレームを halt させる。
  • wrapping_add: EVM の演算は mod 2²⁵⁶。U256::MAX + 1 = 0。例外なし。Solidity ≥ 0.8 はオーバーフローチェックを EVM の上に 追加したが、根底の ADD opcode はラップする。
  • スタック規律: pop, pop, push。スタックは 1 縮む。EVM スタックは 1024 アイテム上限、オーバーフローは halt。

bytecode の出どころ

デプロイ済みコントラクトには 2 つの bytecode がある:

部分いつ走る何をする
コンストラクタ (init code)デプロイ時に 1 度ストレージ初期化、runtime code を返す
runtime codeコントラクトへの各 call ごとdispatch ロジック + あなたの関数群

Foundry のテスト出力で「creation code」が出てくる時、それが init code。runtime code が eth_getCode(address) の返り値。

bytecode: 0x60 0x80 0x60 0x40 0x52 0x34 0x80 ...
                 │
                 │   PC = 0
                 ▼
            ┌────────────┐
            │ バイト fetch│  ← bytecode[PC] = 0x60
            └────────────┘
                 │
                 ▼
       ┌────────────────────┐
       │  instruction_table │  ← table[0x60] = fn push1
       │     [0x00..0xFF]   │
       └────────────────────┘
                 │
                 ▼
            ┌────────────┐
            │   push1    │  ← 実行: リテラル読んでスタックへ push
            └────────────┘
                 │
                 ▼
              PC += 2     ← (opcode 1 + リテラル 1)

halt opcode に当たるか、ガス枯渇か、無効 opcode に出会うまで繰り返し。

なぜ中級で重要か

中級コースで revm/crates/interpreter/src/instructions/arithmetic.rs を開くと、こう書いてあります:

pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
    popn_top!([op1], op2, context.interpreter);
    *op2 = op1.wrapping_add(*op2);
    Ok(())
}

このレッスン抜きだと「何かの Rust 関数」にしか見えない。本レッスンを経た目で見れば:

  • これは 256 エントリ命令テーブルのスロット 0x01 にある関数ポインタ
  • インタープリタループが bytecode から 0x01 を取り出して、これを呼び出した
  • 関数は 1 つ pop し (popn_top!([op1]))、新しいトップへの 可変参照 (op2) を取得し、op1 + op2 を参照経由で直接書き込む。pop-pop-push ではなくメモリ書き込み 1 回。これは最適化だが、セマンティクスは上の擬似コードと同一。

Rust ソースは 擬似コードそのもの をやっている — キャッシュと CPU 向けに最適化されているだけ。

なぜ match ではなく配列なのか

妥当な代替案はこうなる:

match opcode {
    0x01 => add(...),
    0x02 => mul(...),
    // 254 個の arm
}

なぜ関数ポインタの配列が選ばれているか?

  • 予測可能なパフォーマンス: 配列アクセスは CPU 命令 1 つ。match は分岐ツリーかジャンプテーブルにコンパイルされる — 大抵速いが、配列は 常に 速い。
  • コンパイル時構築: 256 エントリのテーブルは const fn でコンパイル時に組める。実行時セットアップコストゼロ。
  • カスタマイズ容易: フォークは 1 スロット を置き換えるだけでカスタム opcode を追加できる(中級レッスン 2 で出てきます)。

読み物リスト — 中級前にやること

  1. evm.codes を開いて クリックして回る。各 opcode、ガスコスト、スタック効果。ブックマーク必須。
  2. Yellow Paper の EVM セクション、9–13 ページをスキム。通読しなくていい。ループと opcode の形式定義を見るだけ。見た目より読みやすい。
  3. 1 行の Solidity コントラクトを forge build でコンパイルout/Contract.sol/Contract.json を開いて bytecode.object を見る。認識できるバイト (PUSH, MSTORE, JUMP) を探す。

このレッスンで持ち帰るもの

  • EVM は バイト駆動の dispatch ループ: バイトを fetch、256 スロットの関数テーブルを引く、ハンドラを実行、PC を進める。
  • 各 opcode は 決まった規約 を持つ小さな Rust 関数(Revm の場合): スタック・メモリ・ガス・必要ならストレージに触れて、制御を返す。
  • 中級レッスン 1 で見るすべての詳細(add<IT, H>、命令テーブル、PC、halt)はこのモデルに直接マッピングされる。

中級に進むと、最初のレッスンで まったく同じ add 関数が出てくる。驚く点はなく、既に理解している中身の本番実装を読むだけである。

📺 関連動画

RxL_1AfV7N4 | EVM: From Solidity to byte code, memory, and storage

まとめ(3行)

  • EVM コアループ = loop { fetch → table lookup → handler → pc++ } の擬似コード 3 行、256 エントリ関数ポインタテーブル、O(1) ディスパッチ。
  • PUSH1 等のリテラル付き opcode は PC を進めるバイト数が違う、JUMP/JUMPI は PC 任意設定、halt opcode が結果別れる。
  • 次レッスンでメモリ・ストレージ・ワールドステートの 5 記憶領域に踏み込み、bytecode が触る対象を理解する。