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 如何存储?如果字符串很长会怎样?
❓ 自测
📝 本课小结
核心要点
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 4: EVM 操作码 (Opcodes)
→