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

レッスン1 — メモリ・ストレージ・ワールドステート

問い

EVM の bytecode が触る対象 = 5 記憶領域。Stack / Memory / Calldata / Storage / Code。それぞれ寿命・コスト・容量が違う。どれが永続化される / どれが揮発 / どれが読み専用 / どれが世界状態か

原理(最小モデル)

  • Stack. 1024 段の値置き場、tx 内のみ揮発、最も安価(3 gas / push)、現行計算の対象。
  • Memory. 線形配列、tx 内のみ揮発、MSTORE / MLOAD で読み書き、拡張するとガスコスト上昇(quadratic for large)。
  • Calldata. tx 入力の読み専用領域、外部呼び出し時のみセット、CALLDATALOAD / CALLDATASIZE で読み出し、最安。
  • Storage. 永続化される唯一の領域、コントラクトごと、SLOAD 2,100 gas(cold)/ 100 gas(warm)、SSTORE 20K gas(新規)/ 5K(変更)、最も高い。
  • Code. デプロイ済みコントラクトのバイトコード、読み専用、EXTCODECOPY / CODECOPY で読み出し。
  • World state. 全コントラクトの全 Storage + すべての EOA 残高、Merkle Patricia Trie で管理、state root がブロックヘッダに。
  • MPT(Merkle Patricia Trie). Ethereum 状態の永続化構造、3 種ノード(Branch / Extension / Leaf)+ keccak256 でルートハッシュ、L1 同期はこのトライを構築する。

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

メモリ・ストレージ・ワールドステート

dispatch loop で opcode が何かは見えた。ほとんどの opcode は 4 つのストアのうち 1 つに触れる。本レッスンではそれらを順に見て、Solidity が隠している world state モデルも確認する。

4 つのストア

ストア寿命コスト形状Solidity 表面
Stack1 コールフレーム安い (3 ガス / op)暗黙
Memory1 コールフレーム安いが二次曲線で増加memory キーワード
Calldata1 コールフレーム、読み取り専用読み取り安い関数引数
Storage永続 (コントラクトごと)高い (cold = 2100, warm = 100)状態変数

各ストアは固有の opcode を持つ。混同は Solidity 開発者の頻出バグの 1 つである。

Stack — EVM の主要スクラッチ空間

既に見た通り、最大 1024 アイテム、各 32 バイト(1 EVM word)である。算術・比較・論理 opcode はスタックトップから読み、トップへ書き戻す。

スタックオーバーフロー(深さ > 1024)とアンダーフロー(空スタックから pop)は、どちらもフレームを halt させる。

Memory — リニア、伸縮、フレームローカル

メモリは バイトのフラット配列。オフセット 0 からアドレス指定、必要に応じて伸びる。2 つの opcode が中心:

  • MLOAD offset → memory[offset..offset+32] から 32 バイト読み込み、スタックへ push
  • MSTORE offset value → スタックから 32 バイトの値を memory[offset..offset+32] へ書き込み

(MSTORE8 は 1 バイト書き込み。MCOPY はメモリ間コピー。)

重要なポイントが 2 つ:

1. メモリは要求時に伸びる — そしてその対価を払う

オフセット 1000 に書き込む時点でメモリサイズが 64 バイトなら、EVM は 書き込み前にオフセット 1000 まで拡張 する。拡張はガスを消費し、32 KB 超で二次曲線 になる:

gas_cost(size_in_words) = 3 × words + words² / 512

これが長いバイト配列操作が一気に高くなる理由。1 MB のメモリ拡張は 空間だけ で約 200 万ガス、書き込む前に。

2. メモリはフレーム終了で消える

CALL が return するか STOP が halt すると、メモリは消える。次の call は空のメモリをオフセット 0 から使う。

Calldata — 不変の入力バッファ

コントラクトを呼ぶとき、calldata は入力バイト — 関数セレクタ (4 バイト) と ABI エンコード済み引数。読み取り専用 で、オフセット 0 からアドレス指定。

CALLDATALOAD offset → calldata から 32 バイトロード
CALLDATASIZE        → calldata サイズを push
CALLDATACOPY        → calldata をメモリへコピー

Calldata 読み取りは安く、拡張コストもなし — call 作成時に既に支払い済み。

Storage — 永続マップ

ワールドステートを理解する上で最重要のストア。

各コントラクトは自分のストレージを持ちU256 キーから U256 値へのマップ としてモデル化されます:

storage[address]: HashMap<U256, U256>

キーは 32 バイトワード。値は 32 バイトワード。固定スロットはない — U256 空間内のすべてのキーが 仮想的に 存在し、デフォルトはゼロ。

2 つの opcode:

  • SLOAD key → storage[key] を読み、スタックへ push
  • SSTORE key value → storage[key] へ value を書き込み

Cold vs warm — ガストラップ

トランザクション内で同一スロットへの最初の SLOADcold — 2100 ガス。 同 tx 内の同一スロットへの後続 SLOADwarm — 100 ガス。

なぜか? 実装はそのスロットが触られたか確認する必要があり (Merkle Patricia Trie ルックアップ) — 後続アクセスはキャッシュされる。

これは EIP-2929、後付けで Ethereum に追加されたもの。攻撃者が cold スロットへの SLOAD 連発で安くネットワークを DoS できると判明したから。cold/warm 区別が修正策。

Solidity がストレージをどう使うか

Solidity はコンパイル時にストレージスロットを割り当てる。uint256 private balance はスロット 0、mapping(address => uint256) balanceskeccak256(address . slot_index) などで決まる。つまり raw U256 → U256 マップ の上 でスロット管理している。

中級レッスン 3 (Database トレイト) で:

fn storage(&mut self, address: Address, index: StorageKey)
    -> Result<StorageValue, Self::Error>;

— このシグネチャは 上のモデルそのまま。トレイトは「コントラクトアドレスとスロットキーを与えれば U256 値を返す」と言っている。それがストレージマップ。

ワールドステート — どこにでもアカウント

ここまで 1 コントラクトを記述してきた。Ethereum のワールドステート全体は アドレスからアカウントへのマップ

world_state: HashMap<Address, Account>

struct Account {
    nonce: u64,
    balance: U256,
    code_hash: B256,        // このアカウントのバイトコードの keccak256 (EOA は空)
    storage_root: B256,     // このコントラクトのストレージ trie のルート
}

Ethereum のすべてのアカウント — あなたのも、各コントラクトも、各ウォレットも — このマップの 1 行。興味深いフィールド:

  • code_hash: 外部所有アカウント (EOA) なら空、コントラクトならバイトコードを指す
  • storage_root: このコントラクトの ストレージマップの Merkle ルート (Expert で扱う trie)

トランザクションを送ると、このマップを更新している: nonce のインクリメント、残高の振替、コントラクトストレージの変更。

Revm の Database トレイトで、fn basic(&mut self, address: Address) はアドレスに対する Option<AccountInfo> を返す。それがこのマップでの行ルックアップ。

全部まとめると

Solidity で書いた 1 つの SSTORE がこうなる:

  1. Solidity がスロットキーを計算 (例: keccak256(msg.sender . 5))
  2. コンパイラが PUSH32 <key> の後に SSTORE を吐く
  3. EVM が SSTORE を実行: cold → 22100 ガス (書き込み + 初回触れ)、warm → 5000 ガス
  4. インタープリタが Database のストレージ書き込みパスを呼ぶ
  5. このコントラクトの MPT が更新され、最終的に Account の storage_root が変わり、最終的にグローバル stateRoot が変わる

Solidity 1 行とチェーンの state root の間に 5 つのレイヤー。5 つすべてが中級と Expert で読むソースの中にある

読み物リスト

  1. evm.codes を開いて MLOAD・MSTORE・SLOAD・SSTORE・CALLDATALOAD を探す。ガスメモを読む。
  2. Etherscan で実コントラクトを探し、バイトコードを見る、SLOAD (0x54) と SSTORE (0x55) のバイトを検索。至る所にある。
  3. Foundry で 1 つの uint256 状態変数を持つコントラクトを書く。1 関数内で 2 度読む。forge test --gas-report でガス計測。2 度目の読みが約 2000 ガス安い — それが cold-vs-warm の実例。

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

  • Stack・memory・calldata・storage は 4 つの異なるストア で、寿命・コスト・API が異なる。
  • Storage はコントラクトごとの U256 → U256 マップ — Solidity のスロット割り当てはその上のパッキング。
  • ワールドステートAddress → Account マップ。各 Account が自分のストレージ trie を指す。
  • Revm Database トレイトの 3 つのコアメソッド (basiccode_by_hashstorage) は このワールドステートモデルを直接ミラー している。

中級レッスン 3 で Database トレイトを見たとき、これがまさにこの絵を Rust トレイトで表現したものだと認識できるはず。

まとめ(3行)

  • 5 記憶領域 = Stack 1024 段揮発 / Memory 揮発 quadratic コスト / Calldata 読み専用入力 / Storage 永続化最高コスト / Code 読み専用 bytecode。
  • 永続化されるのは Storage のみ、World state = 全コントラクトの Storage + EOA 残高、MPT で管理して state root がブロックヘッダに。
  • 次レッスンでガス機構とコールフレームに踏み込み、各 opcode のコスト構造とコントラクト間呼び出しを理解。