EVM 存储布局详解:插槽、打包与 Yul 读写

理解 EVM 256 位存储插槽的分配规则、变量打包优化与 sload/sstore 底层操作

7 分钟阅读
EVM 存储布局详解:插槽、打包与 Yul 读写

EVM 存储布局详解:插槽、打包与 Yul 读写

目录


一、为什么代理合约必须懂存储布局

代理模式(proxy)用 delegatecall 让逻辑合约操作代理合约的存储。如果两个合约的存储布局对不齐,逻辑合约写 “slot 0” 时会改掉代理合约 slot 0 里完全不相干的变量——这是升级合约头号事故来源。所以理解”哪个变量在哪个插槽”是学代理的地基。

二、合约数据存在哪里

合约的变量分布在两个地方:

  1. 字节码(Bytecode):存不可变数据——constantimmutable 变量,以及编译后的源码、硬编码的局部变量。这些不占存储插槽;
  2. 存储(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 / uint81
uint324
uint12816
address20
uint25632

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 计算插槽,避免冲突:

  • mappingmapping(k => v) 声明在插槽 p,键 k 对应的值存在 keccak256(abi.encode(k, p))
  • 动态数组:数组声明在插槽 pp 存数组长度,元素 i 存在 keccak256(abi.encode(p)) + i
  • 嵌套 mappingkeccak256(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

  1. 声明一组变量制造打包和动态类型:
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
  1. readRaw(uint256 slot) external view returns (bytes32):汇编 sload 返回原始数据;
  2. mappingSlot(address key) public pure returns (uint256):返回 uint256(keccak256(abi.encode(key, uint256(2))))
  3. arraySlot(uint256 index) public pure returns (uint256):返回 uint256(keccak256(abi.encode(uint256(3)))) + index
  4. unpackSlot0() external view returns (uint128, uint128):读插槽 0 后用位运算拆出 a、b(b = 高 128 位,a = 低 128 位掩码);
  5. 提供 setBalancepushArr 写入数据供验证。

测试要求(Foundry)

  1. test_Packing:a、b 都在插槽 0,readRaw(0) 的高低 128 位分别等于 b、a;
  2. test_MappingSlot:setBalance 后,readRaw(mappingSlot(addr)) == 设置的值;
  3. test_ArraySlot:pushArr 三个值后,readRaw(arraySlot(1)) == 第二个元素;
  4. test_ArrayLengthreadRaw(3) == 数组长度;
  5. test_Unpack:unpackSlot0 正确拆出 (1, 2)。

Sepolia 部署与验证步骤

  1. 部署 StorageInspector,setBalance 和 pushArr 写入数据;
  2. cast storage <地址> <插槽号> --rpc-url $SEPOLIA 读取真实链上插槽;
  3. mappingSlot(addr)arraySlot(1) 的计算结果喂给 cast storage 验证位置正确;
  4. 对照 readRawcast storage 的输出确认一致。

进阶挑战(可选)

  • 实现嵌套 mapping mapping(address => mapping(address => uint)) 的插槽计算,双层 keccak256;
  • 写注释回答:为什么 mapping 的插槽 p 本身(声明位置)在链上永远是空的(0)?

💬 评论