レッスン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 に続く リテラル値
最初のバイト 0x60 は PUSH1 opcode。2番目のバイト (0x80) は push する 1 バイトのリテラル。
次の 0x60 0x40 — もう一度 PUSH1、リテラル 0x40。
次の 0x52 — MSTORE (スタックトップ 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 行。
面白い部分:
instruction_table— 256 エントリの配列(バイト値 0x00–0xFF それぞれ 1 スロット)。各スロットは opcode ハンドラへの関数ポインタ。- PC 管理 — ほとんどの opcode は PC を 1 進める。ただし:
PUSH1は 2 進む(リテラル 1 バイトをスキップ)。PUSH32は 33 進む。JUMPとJUMPIは PC を任意の値(分岐先)に設定。
- Halt —
STOP、RETURN、REVERT、INVALID、Out-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 で出てきます)。
読み物リスト — 中級前にやること
- evm.codes を開いて クリックして回る。各 opcode、ガスコスト、スタック効果。ブックマーク必須。
- Yellow Paper の EVM セクション、9–13 ページをスキム。通読しなくていい。ループと opcode の形式定義を見るだけ。見た目より読みやすい。
- 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 が触る対象を理解する。