レッスン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. 永続化される唯一の領域、コントラクトごと、
SLOAD2,100 gas(cold)/ 100 gas(warm)、SSTORE20K 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 表面 |
|---|---|---|---|
| Stack | 1 コールフレーム | 安い (3 ガス / op) | 暗黙 |
| Memory | 1 コールフレーム | 安いが二次曲線で増加 | memory キーワード |
| Calldata | 1 コールフレーム、読み取り専用 | 読み取り安い | 関数引数 |
| 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 バイト読み込み、スタックへ pushMSTORE 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] を読み、スタックへ pushSSTORE key value→ storage[key] へ value を書き込み
Cold vs warm — ガストラップ
トランザクション内で同一スロットへの最初の SLOAD は cold — 2100 ガス。
同 tx 内の同一スロットへの後続 SLOAD は warm — 100 ガス。
なぜか? 実装はそのスロットが触られたか確認する必要があり (Merkle Patricia Trie ルックアップ) — 後続アクセスはキャッシュされる。
これは EIP-2929、後付けで Ethereum に追加されたもの。攻撃者が cold スロットへの SLOAD 連発で安くネットワークを DoS できると判明したから。cold/warm 区別が修正策。
Solidity がストレージをどう使うか
Solidity はコンパイル時にストレージスロットを割り当てる。uint256 private balance はスロット 0、mapping(address => uint256) balances は keccak256(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 がこうなる:
- Solidity がスロットキーを計算 (例:
keccak256(msg.sender . 5)) - コンパイラが
PUSH32 <key>の後にSSTOREを吐く - EVM が SSTORE を実行: cold → 22100 ガス (書き込み + 初回触れ)、warm → 5000 ガス
- インタープリタが
Databaseのストレージ書き込みパスを呼ぶ - このコントラクトの MPT が更新され、最終的に Account の
storage_rootが変わり、最終的にグローバルstateRootが変わる
Solidity 1 行とチェーンの state root の間に 5 つのレイヤー。5 つすべてが中級と Expert で読むソースの中にある。
読み物リスト
- evm.codes を開いて MLOAD・MSTORE・SLOAD・SSTORE・CALLDATALOAD を探す。ガスメモを読む。
- Etherscan で実コントラクトを探し、バイトコードを見る、
SLOAD(0x54) とSSTORE(0x55) のバイトを検索。至る所にある。 - 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 つのコアメソッド (basic、code_by_hash、storage) は このワールドステートモデルを直接ミラー している。
中級レッスン 3 で Database トレイトを見たとき、これがまさにこの絵を Rust トレイトで表現したものだと認識できるはず。
まとめ(3行)
- 5 記憶領域 = Stack 1024 段揮発 / Memory 揮発 quadratic コスト / Calldata 読み専用入力 / Storage 永続化最高コスト / Code 読み専用 bytecode。
- 永続化されるのは Storage のみ、World state = 全コントラクトの Storage + EOA 残高、MPT で管理して state root がブロックヘッダに。
- 次レッスンでガス機構とコールフレームに踏み込み、各 opcode のコスト構造とコントラクト間呼び出しを理解。