MODULE 1 · EVM 内部原理

Lesson 3: 存储槽

📖 阅读 ~15 分钟🧪 2 个实验❓ 2 道测验

合约的"硬盘"

上一课我们知道每个合约账户有自己的 storageRoot。这节课深入它的内部:Storage(存储)是合约的持久化数据层,类似硬盘。

Storage 本质上是一个巨大的 key-value 映射:2²⁵⁶ 个 slot,每个 slot 存 32 字节。初始值全为 0。

💡 关键理解 Storage 是持久的(交易结束后保留)且昂贵的(写入一个全新 slot 消耗 20,000 gas ≈ $0.5-$50)。这是以太坊上最贵的操作之一 —— 因为所有节点都要永久保存它。

Slot 编号规则

Solidity 编译器按照变量声明顺序分配 slot,从 slot 0 开始。

// Solidity 源码 contract Example { uint256 public value; // → slot 0 address public owner; // → slot 1 bool public active; // → slot 1 (packed!) uint256 public count; // → slot 2 } // 存储布局 Slot 0: [__________ value (32 bytes) __________] Slot 1: [___unused 11B___|active 1B|owner 20B] Slot 2: [__________ count (32 bytes) __________]

变量打包 (Packing)

多个小于 32 字节的变量会被打包到同一个 slot 中(右对齐,从低位开始填充)。这是 Solidity 的 Gas 优化机制。

❌ 浪费 Gas 的写法

  • 📝 uint256 a; // slot 0
  • 📝 bool b; // slot 1 (浪费!)
  • 📝 uint256 c; // slot 2
  • 📝 bool d; // slot 3 (浪费!)
  • 占用 4 个 slot

✅ 节省 Gas 的写法

  • 📝 uint256 a; // slot 0
  • 📝 uint256 c; // slot 1
  • 📝 bool b; // slot 2 (packed)
  • 📝 bool d; // slot 2 (packed)
  • 只占 3 个 slot
🔍 Gas 优化原则 把小类型变量(bool、uint8、address)集中声明,让编译器打包到同一个 slot。每少用一个 slot,部署和读写都更便宜。

动态类型的存储

固定大小的变量直接存在声明顺序对应的 slot 里。但 mapping 和动态数组怎么办?

Mapping 存储

// mapping(address => uint256) balances; 在 slot 3 // balances[addr] 存在哪个 slot? slot = keccak256(addr . 3) ↑ key ↑ mapping 声明的 slot 号 (拼接后做 Keccak-256 哈希) // 嵌套 mapping: mapping(address => mapping(uint => uint)) // map[addr][id] 的 slot: slot = keccak256(id . keccak256(addr . slot_of_map))

动态数组存储

// uint256[] items; 在 slot 5 Slot 5: 数组长度 (length) items[0] 的 slot: keccak256(5) items[1] 的 slot: keccak256(5) + 1 items[2] 的 slot: keccak256(5) + 2 // ... 连续排列
⚠️ 安全警告 Storage slot 的位置是确定性的,任何人都可以用 eth_getStorageAt 读取任意 slot。所以合约里标记为 private 的变量并不是真正私密的 —— 只是不能通过合约接口访问,但链上数据对所有人透明。

读取 Storage 的 RPC 方法

eth_getStorageAt
读取任意合约的任意 slot 值
参数: (address, slot, block)
eth_getCode
读取合约的字节码(runtime code)
返回: 0x608060... 字节码
eth_getProof
获取账户/存储的 Merkle 证明
用于跨链验证、轻客户端
debug_storageRangeAt
批量读取合约存储(调试用)
需要 archive node 支持

SSTORE 与 SLOAD 的 Gas 成本

// EVM 操作码的 Gas 成本 SLOAD (读取 storage) 2,100 gas (cold) / 100 gas (warm) SSTORE (写入 storage) └─ 0 → 非零 (新建) 22,100 gas ← 最贵! └─ 非零 → 非零 (修改) 5,000 gas └─ 非零 → 0 (删除) 退还 4,800 gas ← 鼓励清理 // 对比其他操作 MLOAD (读取 memory) 3 gas ← 便宜 700 倍 ADD (加法) 3 gas CALL (外部调用) 2,600 gas (cold)
💡 为什么存储这么贵 Memory 在交易结束后就丢弃了,但 Storage 要被全球所有节点永久保存。你写的每一个 bit 都会增加所有节点的磁盘负担。这就是为什么 SSTORE 的 Gas 成本比 MLOAD 高 7000 倍。

🧪 动手实验

🔬 实验 1:读取 USDC 合约的存储

使用 eth_getStorageAt 读取 USDC 合约的 slot 0,看看存了什么。

chainlab lab defi token 0xA0b8...eB48 --chain ethereum

思考:USDC 的 totalSupply 存在哪个 slot?name 和 symbol 呢?

🔬 实验 2:编译 HelloWorld 合约,观察存储布局

编译我们的 HelloWorld 合约,思考 message、owner、updateCount 分别在哪个 slot。

chainlab lab sol compile HelloWorld.sol

思考:string 类型的 message 如何存储?如果字符串很长会怎样?

❓ 自测

检验你的理解

Q1: mapping(address => uint) balances 声明在 slot 2,balances[0xABC...] 存在哪里?

✅ 正确!mapping 的值存在 keccak256(key . slot_number)。这种散列方式保证不同 key 的 slot 几乎不会碰撞。
❌ 提示:mapping 的值不存在声明的 slot 里。它用 keccak256 哈希来计算实际位置。

Q2: 将一个 storage slot 从 0 写为非零值,消耗多少 Gas?

✅ 正确!从零到非零(SSTORE_SET)是最贵的存储操作:22,100 gas。因为你在全球所有节点上创建了一条新的永久记录。
❌ 记住:创建新 slot(0→非零)是最贵的 = 22,100 gas。修改已有值只要 5,000 gas。

📝 本课小结

核心要点 1. Storage 是 2²⁵⁶ 个 slot 的 key-value 映射,每个 slot 32 字节
2. 固定变量按声明顺序分配 slot,小变量会被打包
3. mapping 用 keccak256(key . slot) 定位,数组用 keccak256(slot) + index
4. private 变量在链上完全透明,任何人可通过 eth_getStorageAt 读取
5. SSTORE(写存储)是 EVM 最贵的操作之一,因为所有节点永久保存
上一课
Lesson 2: 状态树
下一课
Lesson 4: EVM 操作码 (Opcodes)