Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

状态变量存储布局 (Storage Layout)

EVM 层面,storage 是每个合约账户一棵 slot → 32 字节值 的稀疏树(见 EVM 与内部状态)。Solidity 编译器的工作是决定状态变量如何映射到这些 slot。这条映射规则不是语言细节——代理合约的存储冲突、Gas 优化、forge inspect 的布局检查都建立在这套规则之上。

参考来源:Solidity 官方文档 · Layout of State Variables in Storage。

基本规则与打包

  • 变量从 slot 0 开始按声明顺序分配,逐个往后排。
  • 长度不足 32 字节的类型(uint128、bool、address…)会与相邻变量打包进同一个 slot,从 slot 的低位开始放置(这不表示整个 EVM 采用小端字节序)。
  • 打包的前提是放得下:任何变量都不会跨 slot。放不下就换新 slot,剩余空间作废。
  • 一个 slot 里多个变量时,gas 上省的是一次冷 SLOAD(2100),但每次写都要读写整个 slot——“读改写“成本需要权衡,这也是变量优化的出发点。
contract SlotLayout {
    uint256 a;      // slot 0:独占一个 slot
    uint128 b;      // slot 1:低 16 字节
    uint128 c;      // slot 1:高 16 字节(与 b 打包)
    address d;      // slot 2:低 20 字节
    bool e;         // slot 2:字节 20(与 d 打包)
    uint256 f;      // slot 3:独占(不与 slot 2 的剩余空间拼合)
}

mapping 与动态数组的定位

两者都为声明本身保留一个完整槽 p,实际元素通过哈希派生位置存放:

  • mapping:键 k 的值位于 keccak256(h(k) . p),p 是 mapping 声明所在的 slot 序号,. 拼接。h 按键类型做定宽填充(uint 等补齐 32 字节;string/bytes 使用未经填充的原始字节,不先单独哈希)。
  • 动态数组:p 存数组长度;对于 uint256[],第 i 个元素位于 keccak256(p) + i;较小的元素会在槽内打包,多槽结构体则按自身布局计算步长。
  • bytes/string 特例:长度 ≤ 31 字节时数据与(长度 × 2)一起内联在 slot p;更长时 slot p 存 长度 × 2 + 1,数据从 keccak256(p) 开始连续存放。
contract Locator {
    mapping(address => uint256) balances;   // 声明于 slot 0
    uint256[] items;                        // 声明于 slot 1

    function balanceSlot(address who) public pure returns (bytes32) {
        return keccak256(abi.encode(who, uint256(0)));
    }
    function itemSlot(uint256 i) public view returns (bytes32) {
        return bytes32(uint256(keccak256(abi.encode(uint256(1)))) + i);
    }
}

这套定位规则正是 forge inspect <contract> storage-layout、Foundry 的 stdStorage 以及各种链上存储读取工具的工作原理。

继承下的布局顺序

  • 状态变量的 slot 分配遵循 C3 线性化:从最基础的合约开始,按继承链顺序依次分配,同一合约内按声明顺序。
  • 基类与派生类的变量可能共享一个槽。升级风险来自改变已有字段类型、顺序或继承布局,而不是来自把升级函数放在基类。

immutable 与 constant

  • constant:编译期内联进字节码,不占任何 slot。
  • immutable:也不占 slot,构造结束时把值复制进运行时字节码(PUSH 指令)。代理调用也会读取实现合约字节码中的同一值,不能把它当作每个代理独有的可变状态。

与代理合约的关系

  • 代理模式下,代理合约与逻辑合约共享同一套 storage,两边各自的 slot 0 起步布局可能冲突。
  • EIP-1967 用“哈希值减一“的 slot(如 keccak256("eip1967.proxy.implementation") - 1)把 implementation 地址挪出常规空间,避开冲突。
  • 更彻底的方案是存储分离(如 EIP-2535 Diamond 或自定命名空间存储)。
  • 实例分析:USDC 代理合约。

布局检查工具

  • forge inspect <Contract> storage-layout:输出每个变量的 slot、偏移、类型。
  • solc --storage-layout:编译器原生 JSON 输出,适合 CI 中对比升级前后布局是否兼容。