EVM 存储布局详解:插槽、打包与 Yul 读写
目录
- 一、为什么代理合约必须懂存储布局
- 二、合约数据存在哪里
- 三、存储插槽基础
- 四、插槽内部:256 位数据长什么样
- 五、存储打包(Storage Packing)
- 六、Yul 汇编读写存储
- 七、动态类型的插槽(keccak256 公式)
- 八、总结
- 九、动手练习项目:存储插槽侦探 StorageInspector
一、为什么代理合约必须懂存储布局
代理模式(proxy)用 delegatecall 让逻辑合约操作代理合约的存储。如果两个合约的存储布局对不齐,逻辑合约写 “slot 0” 时会改掉代理合约 slot 0 里完全不相干的变量——这是升级合约头号事故来源。所以理解”哪个变量在哪个插槽”是学代理的地基。
二、合约数据存在哪里
合约的变量分布在两个地方:
- 字节码(Bytecode):存不可变数据——
constant、immutable变量,以及编译后的源码、硬编码的局部变量。这些不占存储插槽; - 存储(Storage):存可变的状态变量。它们持久保存,直到被交易修改或合约自毁。声明在合约全局作用域的状态变量(除了常量/immutable)都在这里。
三、存储插槽基础
EVM 存储是一个巨大的数组,插槽编号从 0 到 2²⁵⁶−1,每个插槽存恰好 256 位(32 字节)。
分配规则:编译器按声明顺序依次分配插槽,部署后永久固定。
contract StorageVariables {
uint256 public x; // 插槽 0
uint256 public y; // 插槽 1
}
未初始化的存储变量默认值都是 0。
四、插槽内部:256 位数据长什么样
每个插槽是 256 位二进制,通常用十六进制表示。
例:值 20 存进插槽:
- 十六进制:
0x0000000000000000000000000000000000000000000000000000000000000014 0x14= 十进制 20
五、存储打包(Storage Packing)
5.1 打包规则
小于 32 字节的变量可以共享一个插槽。打包从最低有效字节往高位填(从右往左):
- 变量按声明顺序依次塞入;
- 只有当变量完整地装得下剩余空间时,才放入当前插槽;
- 装不下就换下一个插槽(剩余空间留空)。
5.2 混合大小的例子
contract AddressVariable {
address owner = 0x5B38...ddC4; // 20 字节,插槽 0
bool Boolean = true; // 1 字节,插槽 0
uint32 thirdVar = 5_000_000; // 4 字节,插槽 0
address admin = 0xAb84...5cb2; // 20 字节,插槽 1
}
- 插槽 0:owner(20) + bool(1) + uint32(4) = 25 字节,剩 7 字节;
- admin 需要 20 字节,7 字节装不下 → 换插槽;
- 插槽 1:admin 单独占用。
常见类型大小:
| 类型 | 字节 |
|---|---|
| bool / uint8 | 1 |
| uint32 | 4 |
| uint128 | 16 |
| address | 20 |
| uint256 | 32 |
5.3 优化最佳实践
把小类型聚在一起,避免碎片化:
// 低效:用了 3 个插槽
uint16 public a; // 插槽 0
uint256 public x; // 插槽 1(uint16 后无法和 uint256 同槽)
uint32 public b; // 插槽 2
// 高效:只用 2 个插槽
uint256 public x; // 插槽 0
uint16 public a; // 插槽 1(与 b 打包)
uint32 public b; // 插槽 1
每个存储插槽的首次写入要 22,100 gas,节省插槽就是节省真金白银。
六、Yul 汇编读写存储
6.1 .slot 关键字
返回变量所在的插槽编号:
uint256 x; // 插槽 0
function getSlotX() external pure returns (uint256 slot) {
assembly { slot := x.slot } // 返回 0
}
6.2 sload 读取
sload(slot) 读取整个 256 位插槽:
uint256 public x = 11; // 插槽 0
function readSlotX() external view returns (uint256 value) {
assembly { value := sload(x.slot) } // 读插槽 0,返回 11
}
// 也可以读任意插槽号
function sloadOpcode(uint256 n) external view returns (uint256 v) {
assembly { v := sload(n) }
}
6.3 sstore 写入
sstore(slot, value) 把 32 字节值完全覆盖写入插槽:
function sstore_x(uint256 newval) public {
assembly { sstore(x.slot, newval) }
}
// 危险:可写任意插槽
function sstoreArbitrary(uint256 slot, uint256 val) public {
assembly { sstore(slot, val) }
}
调用 sstoreArbitrary(1, 48) 会把插槽 1 改成 48——如果插槽 1 是变量 y,y 就变成了 48。代理合约升级 bug 往往就是这种”误写插槽”。
6.4 sload/sstore 不做类型检查
汇编里的 sload/sstore 不检查类型:
address public owner;
function sstoreOpcode(uint256 value) public {
assembly { sstore(owner.slot, value) } // 不报类型错误
}
正常 Solidity 里把 uint256 赋给 address 会报错,但汇编绕过了类型系统。这是双刃剑:灵活但危险。
七、动态类型的插槽(keccak256 公式)
结构体、定长数组按顺序占连续插槽,但 mapping、动态数组、string、bytes 因为大小不定,用 keccak256 计算插槽,避免冲突:
- mapping:
mapping(k => v)声明在插槽p,键k对应的值存在keccak256(abi.encode(k, p)); - 动态数组:数组声明在插槽
p,p存数组长度,元素i存在keccak256(abi.encode(p)) + i; - 嵌套 mapping:
keccak256(abi.encode(k2, keccak256(abi.encode(k1, p))))。
这些位置是哈希出来的”伪随机”插槽,离顺序分配的 0,1,2… 极远,所以普通变量和动态类型几乎不可能冲突。这也正是下一篇 ERC-1967 把代理变量藏在哈希插槽里的原理。
八、总结
- 存储 = 2²⁵⁶ 个 32 字节插槽,按声明顺序分配,永久固定;
- 小变量从右往左打包进同一插槽,能装下才放,省 Gas;
.slot取插槽号,sload/sstore读写整槽且不做类型检查;- mapping/动态数组/string 用 keccak256 算插槽,天然避开顺序插槽。
九、动手练习项目:存储插槽侦探 StorageInspector
项目目标
写一个能读取任意插槽、并亲手用 keccak256 公式定位 mapping/数组元素的合约,部署到 Sepolia,用 cast storage 交叉验证你的计算。
合约要求
StorageInspector.sol
- 声明一组变量制造打包和动态类型:
uint128 public a = 1; // 插槽 0(低 16 字节)
uint128 public b = 2; // 插槽 0(高 16 字节)
uint256 public c = 3; // 插槽 1
mapping(address => uint256) public balances; // 插槽 2
uint256[] public arr; // 插槽 3
readRaw(uint256 slot) external view returns (bytes32):汇编 sload 返回原始数据;mappingSlot(address key) public pure returns (uint256):返回uint256(keccak256(abi.encode(key, uint256(2))));arraySlot(uint256 index) public pure returns (uint256):返回uint256(keccak256(abi.encode(uint256(3)))) + index;unpackSlot0() external view returns (uint128, uint128):读插槽 0 后用位运算拆出 a、b(b = 高 128 位,a = 低 128 位掩码);- 提供
setBalance、pushArr写入数据供验证。
测试要求(Foundry)
test_Packing:a、b 都在插槽 0,readRaw(0)的高低 128 位分别等于 b、a;test_MappingSlot:setBalance 后,readRaw(mappingSlot(addr))== 设置的值;test_ArraySlot:pushArr 三个值后,readRaw(arraySlot(1))== 第二个元素;test_ArrayLength:readRaw(3)== 数组长度;test_Unpack:unpackSlot0 正确拆出 (1, 2)。
Sepolia 部署与验证步骤
- 部署 StorageInspector,setBalance 和 pushArr 写入数据;
- 用
cast storage <地址> <插槽号> --rpc-url $SEPOLIA读取真实链上插槽; - 把
mappingSlot(addr)、arraySlot(1)的计算结果喂给cast storage验证位置正确; - 对照
readRaw和cast storage的输出确认一致。
进阶挑战(可选)
- 实现嵌套 mapping
mapping(address => mapping(address => uint))的插槽计算,双层 keccak256; - 写注释回答:为什么 mapping 的插槽 p 本身(声明位置)在链上永远是空的(0)?