一个只改了自己 owner 字段的实现合约,能把代理合约的管理员地址写成垃圾值。这类事故的根源通常是 Solidity 存放状态变量的方式。合约里没有“变量名到存储位置”的运行时索引,编译器按声明顺序把变量依次放进 32 字节(32 bytes)的槽(slot)。delegatecall 让实现合约的代码在代理的存储上运行,一旦两边对槽的分配理解不一致,写操作就落到对方的数据上。
槽既是存储的寻址单位,也是执行层记录一次状态访问时的最小粒度。变量落在哪个槽、打包后是否共享槽、数组与映射的元素如何由键推导位置,决定了两个很实际的问题:一次调用会改坏代理的哪个字段,以及一笔交易的状态读写集到底有多大。
变量按声明顺序线性占用 32 字节槽
根据 Solidity 官方文档《Layout of State Variables in Storage and Transient Storage》,除动态数组与映射之外,状态变量从第一个声明开始连续存放,第一个变量落在 slot 0。每个变量按类型确定字节数,连续的、总计不足 32 字节的变量会被装进同一个槽,规则有五条:槽内第一个条目存放在低位(lower-order aligned);值类型只占用它实际需要的字节;装不进当前槽剩余空间时移到下一个槽;结构体与数组数据总是从新槽开始;结构体或数组之后的变量也从新槽开始。
两个容易被忽略的例外会影响槽号计算。constant 变量不占用存储槽,其值在使用处内联;immutable 变量编码在部署字节码里,运行时不读存储。以官方文档的示例合约 C 为例,常量 c 与不可变量 d 都不参与布局,若把它们计入声明序号,之后所有变量的槽号都会偏移。瞬时存储(transient storage)是另一套独立布局,规则相同但空间完全分离,因此同一个合约里普通状态变量与瞬时变量可以随意交错而不互相影响。
打包省的是槽,未必省 gas
小类型共享同一个槽,称为打包(packing)。下面两段声明只差变量的排列顺序,占用的槽数差一个:
// 占用 3 个槽
uint128 a; // slot 0, offset 0
uint256 b; // 32 字节装不进 slot 0 的剩余空间,落到 slot 1
uint128 c; // slot 1 已满,落到 slot 2
// 占用 2 个槽
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1
官方文档明确指出,使用小于 32 字节的元素可能提高 gas 消耗:EVM 按 32 字节为单位运算,处理小类型需要额外的截断或移位操作。打包的实际收益在于“一次访问一个槽就能拿到多个值”,读写次数按槽计价。它的反面同样成立:如果一段逻辑只写其中一个变量,EVM 必须先读出整个槽,修改对应字节,再整体写回,否则会覆盖同槽的其它变量。把不常同时访问的变量塞进同一个槽,会把一次写入变成一次读加一次写。
这里已经埋下一个与并发相关的观察点:以槽为单位看,打包把若干逻辑上无关的变量绑定成了同一个可写对象。
定长数组内联,动态数组与映射靠哈希派生位置
定长数组(如 uint256[3])的元素按顺序内联存放在槽里,与逐个声明的变量没有区别;总字节数超过 32 时自然跨多个槽。结构体同理,只是它以及它之后的变量都必须从新槽开始。
动态数组与映射无法内联,因为大小不可预知。它们采取的做法是:在布局中只占用一个槽 p,槽本身不存放数据,数据位置由 keccak256 计算。
动态数组的槽 p 存放数组长度,元素从 keccak256(p) 开始,按定长数组的规则连续排列,元素不超过 16 字节时仍可共享槽。嵌套动态数组递归应用同一规则:对类型为 uint24[][]、声明在槽 p 的 x,元素 x[i][j] 所在槽为 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))。
映射的槽 p 保持为空,但必须保留,正是这个保留槽保证了相邻两个映射的数据不会重叠。键 k 对应的值位于 keccak256(h(k) . p),其中 . 表示拼接,h 对键做类型相关处理:值类型按与内存存储相同的方式补齐到 32 字节;string 与 bytes 类型的键不做补齐。文档给出的嵌套示例可以直接验证这一点:对 uint x; mapping(uint => mapping(uint => S)) data;,data[4][9].c 的槽是 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1,末尾的 +1 来自结构体成员 c 在 S 内的槽偏移,而 a、b 两个 uint16 已被打包进同一个槽。
bytes 与 string 的编码需要单独说明,因为它们不是 bytes1[] 的简单封装。当数据不超过 31 字节时,数据本身与长度存放在同一个槽内:数据左对齐放在高位字节,最低字节存放 length * 2。当数据达到或超过 32 字节时,槽 p 存放 length * 2 + 1,真正的数据放在 keccak256(p) 开始的区域。两种情况通过最低位区分:短数据该位为 0,长数据为 1。
组合类型的定位遵循同样的递归。按上述规则推导,uint8[4] 作为动态数组的元素时,4 个 1 字节值正好打包进一个槽;uint[3][] 的每个元素是占 3 个槽的定长数组,元素之间按 3 个槽为间隔依次排列,x[i] 的起点是 keccak256(p) + 3 * i。这两个例子是规则的特例推演,官方文档只给了 uint24[][] 的嵌套示例,没有逐字给出这两个。
这一切有一个直接后果:数组元素与映射值的槽位置依赖运行时的键或下标,编译期无法枚举,只读源码的静态分析也推不出完整集合。
布局是编译器的输出,可以被导出与比对
槽号不必靠推理猜测。Solidity 的标准 JSON 接口能导出合约的存储布局,输出包含 storage 与 types 两个键:storage 数组的每一项给出 astId、contract、label、offset、slot、type,types 则描述每种类型的编码方式,取值 inplace 表示内联排布,mapping 与 dynamic_array 表示基于 keccak256 派生,bytes 表示按长度在单槽与哈希区域之间二选一。slot 的数值可能非常大,在 JSON 里以字符串表示。文档同时提醒这套输出格式仍被视为实验性的,可能在 Solidity 的非破坏性版本中变化,因此它适合当作一次性核对工具,不宜作为长期依赖的接口。
继承顺序与槽边界决定升级的可行性
使用继承的合约,状态变量顺序由合约的 C3 线性化(C3-linearized)顺序决定,从继承链最基端的合约开始排列。允许打包时,来自不同合约的变量仍会共享同一个槽,基类与派生类的变量可以共处一槽。
这条规则解释了两类升级事故。一类是在基类新增变量:只要子类已经声明了自己的变量,基类新增的变量就会顶掉子类原有变量的槽位,把旧数据当成新变量读出来。另一类是在已有变量之前插入变量或改变变量类型,效果相同。OpenZeppelin 的升级文档把这条约束写得很直接:新变量只能加在末尾;若从末尾删除变量,存储不会被清空,之后新增的同位置变量会读到历史残留值。
对于希望主动控制布局的场景,Solidity 允许在合约上声明自定义布局起点。官方文档的示例写作 pragma solidity ^0.8.29; 与 contract C is A, B layout at 42,继承树中所有静态变量的槽号整体平移;文档没有说明该能力自哪个编译器版本引入,这里也不做版本断言。这份声明只作用于该继承树,A、B 单独部署时布局仍从 slot 0 开始。需要注意这只是平移起点,动态数组与映射的数据位置会因为基准槽的变化而连带改变。
代理升级:storage collision 为什么必然发生,保留槽如何规避
升级代理(proxy)的机制是:代理持有存储与余额,通过 delegatecall 执行实现合约的代码。由于 delegatecall 保留调用者的存储上下文,实现合约眼中的 slot 0、slot 1 就是代理的 slot 0、slot 1。如果代理自己也声明一个 address public admin;,它会占据 slot 0,而实现合约的第一个状态变量同样占据 slot 0,任何一方写入都会覆盖另一方。这就是 storage collision(存储冲突)。问题不出在某一方写错代码:共享存储加上两边各自独立编译,这个组合本身就会产生冲突。
EIP-1967 的解法是不使用编译器分配的槽,而是固定一组约定槽:
| 用途 | 槽位置 | 推导方式 |
|---|---|---|
| 实现合约地址 | 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc | bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) |
| 信标合约地址 | 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50 | bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) |
| 管理员地址 | 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 | bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) |
这些数字的参数选择有明确理由。槽位置取自某个字符串的 keccak256,而该字符串不以存储下标开头,因此不会与编译器从 0 开始递增分配的槽重合;数值本身很大,进一步远离常规变量区间。末尾减 1 则让哈希的原像不可知,避免有人构造一个映射键,通过 keccak256(h(k) . p) 恰好写到该槽。EIP-1967 同时建议任何改写这些槽的函数都发出对应事件,因为链上监控很难直接跟踪任意槽的变化。
EIP-1967 之外还有两种常见做法。较早的方案是存储间隙(storage gap),在基类末尾声明一个定长数组,例如 uint256[49] __gap;,为未来的变量预留槽;基类新增变量时同步缩小 __gap。OpenZeppelin 的文档指出这不会增加 gas 消耗,但它的失效方式也很具体:忘记缩小间隙,或者在子类已有变量时向基类添加变量,都会重新引入冲突。较新的方案是 ERC-7201 的命名空间存储布局,把一组变量放进结构体,并用 @custom:storage-location erc7201:<NAMESPACE_ID> 标注,位置由 keccak256(keccak256(id) - 1) & ~0xff 计算;末尾的 & ~0xff 把命名空间按 256 个槽对齐,文档给出的理由是这可作为未来的优化,Verkle 状态树迁移后可能出现 256 个槽一起变热的 gas 规则。OpenZeppelin Contracts 5.0 起的可升级版本采用了这一约定。
最后要补一个前提:Solidity 文档把存储布局视为语言外部接口的一部分,原因是存储指针可以传给库函数,任何布局规则的改动都算破坏性变更。按槽写死地址的代理方案之所以可行,建立在布局规则长期稳定这一承诺之上。
读写集的最小单位是槽
把上面的规则合起来看,就访问计价而言,执行层能观察到的最小状态访问单位是地址加槽。EIP-2929 是最直接的证据:它为每笔交易维护 accessed_addresses 与 accessed_storage_keys 两个集合,后者的元素类型是 Set[Tuple[Address, Bytes32]],并按该粒度计价。首次访问某个存储槽收取 COLD_SLOAD_COST,即 2100 gas;已访问过的槽收取 WARM_STORAGE_READ_COST,即 100 gas;首次访问某个账户地址收取 COLD_ACCOUNT_ACCESS_COST,即 2600 gas。冷热计价的单位是槽,说明执行层本身就以槽为访问对象。
由此可以推出三点与并发相关的工程推断。
EIP-2929 规定的是访问计价的最小单位,规范并没有定义并行执行时冲突判定该用多大粒度。下面这点是从计价粒度出发的推断:打包把小变量绑进同一个槽,意味着以槽为粒度记录读写集时,写同一个槽内不同变量的两笔交易仍然冲突,若要消除这类伪冲突,检测就必须细化到槽内偏移与字节差异,代价是每次比较的开销上升。
动态数组与映射的槽位置依赖键值,读写集在编译期不可知,只能在执行过程中记录、在执行后校验。乐观并发控制(optimistic concurrency control,OCC)之所以需要读集与写集,无法照搬数据库那种由开发者预声明的方式,原因也在这里(参见本站《乐观并发控制(OCC)入门》与《冲突热点与工作负载:并行 EVM 何时真的变快》)。
槽级读写集是上界而非精确集合。一笔交易可能只改某槽的一个字节,却记录为写了整个槽;也可能访问了某个槽,却因为后续路径回滚而没有实际生效。把读写集直接当作冲突判据,会得到偏高的冲突率,需要通过回滚与重执行来消化。
边界同样要说清楚。如果实现合约完全采用 ERC-7201 命名空间布局,原则上不再与代理的槽发生冲突,代价是任意槽号不再能从源码顺序读出,只认源码顺序的工具(部分静态分析器、区块浏览器)会失效。此外,内联汇编中的 sstore 可以写入编译器布局之外的任意槽,任何只解析 AST 的布局工具都无法覆盖这类写入,这也是分析合约存储行为时反复出现的不确定性来源。
资料来源
- Solidity 文档,Layout of State Variables in Storage and Transient Storage:https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- EIP-1967,Proxy Storage Slots:https://eips.ethereum.org/EIPS/eip-1967
- ERC-7201,Namespaced Storage Layout:https://eips.ethereum.org/EIPS/eip-7201
- EIP-2929,Gas cost increases for state access opcodes:https://eips.ethereum.org/EIPS/eip-2929
- OpenZeppelin 文档,Writing Upgradeable Contracts:https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable