本文探讨了 Ethereum 智能合约的存储架构。文章解释了变量是如何保存在 EVM 存储中的,以及如何使用底层汇编 (Yul) 读取和写入存储槽。
这些知识是理解 Solidity 中代理(proxies)工作原理以及如何对智能合约进行 Gas 优化的先决条件。
作者
本文由 RareSkills 的研究实习生 Aymeric Taylor(LinkedIn,Twitter)共同撰写。
智能合约存储架构
智能合约中的变量将其值存储在两个主要位置:storage(存储)和 bytecode(字节码)。

Bytecode
bytecode 存储不可变的信息。这些包括 immutable 和 constant 变量类型的值,
contract ImmutableVariables{
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
}
以及编译后的源代码(源代码即下方全部文本)。
contract ImmutableVariables {
uint256 constant myConstant = 100;
uint256 immutable myImmutable;
constructor(uint256 _myImmutable) {
myImmutable = _myImmutable;
}
function doubleX() public pure returns (uint256) {
uint256 x = 20;
return x * 2;
}
}
在上述 doubleX() 函数中,诸如 uint256 x = 20 这种硬编码局部变量的值也会被存储在 bytecode 中。
由于本文侧重于探讨存储(storage)方面,我们将不会详细讨论 bytecode。
Storage
storage 保存可变的信息。将其值存储在 storage 中的变量被称为状态变量(state variables)或存储变量(storage variables)。

它们的值会无限期地持久保存在 storage 中,直到被后续的交易修改,或者合约发生自毁(self-destructs)。
存储变量是在合约的全局作用域内声明的所有类型的变量(immutable 和 constant 变量除外)。
contract StorageVariables{
uint256 x;
address owner;
mapping(address => uint256) balance;
// and more...
}
当我们与存储变量交互时,在底层,我们实际上是在读取和写入 storage,具体来说是在变量保存其值的**存储槽(storage slot)**处进行操作。
Storage slots
智能合约的 storage 被组织成一个个的存储槽(storage slots)。每个槽具有 256 bits 或 32 bytes()的固定存储容量。

存储槽的索引范围从 到 。这些数字充当定位单个槽的唯一标识符。
Solidity 编译器会根据存储变量在合约中的声明顺序,以顺序且确定性的方式为它们分配存储空间。
思考下方这个合约,它包含两个存储变量:uint256 x 和 uint256 y。
contract StorageVariables {
uint256 public x; // first declared storage variable
uint256 public y; // second declared storage variable
}
由于 x 是首先声明的,而 y 是其次声明的,x 被分配到第一个存储槽(slot 0),而 y 被分配到第二个存储槽(slot 1)。因此,x 将在 slot 0 中保留其值,y 将在 slot 1 中保留其值。

在查询时,x 和 y 将始终读取存储在它们各自存储槽中的值。一旦合约部署到区块链上,变量就不能再改变其所在的存储槽。
如果 x 和 y 的值未被初始化,它们将默认为零。所有的存储变量在被显式设置之前都默认为零。
contract StorageVariables {
uint256 public x; // Uninitialized storage variable
function return_uninitialized_X() public view returns (uint256) {
return x; // returns zero
}
}
为了将 x 的值设置为 20,我们可以调用 set_x(20) 函数。
function set_x(uint256 value) external {
x = value;
}
此交易会触发 slot 0 的状态变更,将其状态从 0 更新为 20。

从本质上讲,对智能合约进行的所有状态更改都对应于这些存储槽内部的变更。
深入存储槽:256-bit 数据
单个存储槽以 256-bit 格式存储数据;它存储的是存储变量值的位(bit)表示形式。
在我们前面的示例中,uint256 x 将其值存储在 slot 0 中。uint256 变量的大小为 256 bit / 32 bytes,因此它将耗尽 slot 0 内的 256 bit 存储空间来存储其值。
- 在调用
set_x(20)之前,slot 0 处于其默认状态(全零)

上图中看到的所有绿色零都对应于用于存储 x 值的位。
- 在调用
set_x(20)之后,slot 0 的状态变更为 uint256 20 的位表示形式。

以原始 256 bit 格式读取存储槽的内容缺乏人类可读性,因此,Solidity 开发者通常以十六进制(hexadecimal)格式来读取它。
原始 256 bit: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
十六进制格式:
0x0000000000000000000000000000000000000000000000000000000000000014
这 256 bit 的 1 和 0 可以简化为仅仅 64 个十六进制数字。1 个十六进制字符代表 4 bits。2 个十六进制字符代表 1 byte。十六进制的 0x14 等同于十进制数字 20。0x14 (十六进制) = 10100 (二进制) = 20 (十进制)。二进制转十六进制转换器。
在接下来的章节中,我们将演示如何使用汇编(assembly)以十六进制格式或 bytes32 类型输出存储槽的值。
原始数据类型与复杂数据类型
在本文中,我们的示例将仅围绕原始数据类型展开,例如无符号整数(uint)、整数(int)、地址(address)和布尔值(bool)。
contract PrimitiveTypes {
uint256 a;
int256 b;
address owner;
bool isTrue;
}
这些变量最多占据一个存储槽。
结构体(struct{})、数组(array[])、映射(mapping(address => uint256))、字符串(string)和字节(bytes32)等复杂数据类型拥有更为复杂的存储槽分配机制。它们需要另外单独的一篇文章来进行深入探讨。
存储打包
到目前为止,我们很方便地处理了跨越存储槽整整 32 bytes 的 uint256 变量。其他原始数据类型(如 uint8、uint32、uint128、address 和 bool)的尺寸较小,使用的存储空间也更少。它们可以被打包在一起存放于同一个存储槽中。
顺便提一下,任何达到 256 且是 8 的倍数的数字都是有效的 uint,并且 bytes1、bytes2,一直到 bytes32 的所有固定字节大小都是有效的数据类型。
下表展示了一些原始数据类型的存储大小。
| Type | Size |
|---|---|
bool |
1 byte |
uint8 |
1 byte |
uint32 |
4 bytes |
uint128 |
16 bytes |
address |
20 bytes |
uint256 |
32 bytes |
例如,如上表所示,address 类型的存储变量将需要 20 bytes 的存储空间来存储其值。
contract AddressVariable{
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
}
在上述合约中,owner 将消耗 slot 0 可用的 32 bytes 中的 20 bytes 来存储其值。

Solidity 在存储槽中打包变量时,是从最低有效字节(最右侧的字节)开始,向左依次进行的。
我们可以通过读取该槽的 bytes32 表示形式来验证这一点:

如上图所示,owner 的值 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 是从最右侧字节(即最低有效字节)开始存储的。slot 0 中剩余的 12 bytes 将作为未使用的存储空间,可供另一个变量占据。
当按顺序声明时,如果较小尺寸变量的总大小小于 256 bits 或 32 bytes,它们将驻留在同一个存储槽中。
假设我们声明了类型为 bool(1 byte)和 uint32(4 bytes)的第二个与第三个存储变量,它们的值将会存储在与 owner 相同的存储槽(slot 0)中的未使用存储空间内。
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
// new
bool Boolean = true;
uint32 thirdvar = 5_000_000;
}
第二个声明的存储变量 Boolean,会将其值存储在 owner 字节序列左侧的第一个字节处,或者说,存储在未使用存储空间的最低有效字节处。请记住,Solidity 是从右向左打包变量的。

第三个存储变量 uint32 thirdVar 会将其值存储在 Boolean 的字节序列左侧。

如果我们要引入第四个存储变量 address admin,它的值将会存储在下一个存储槽,即 slot 1 中。
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
bool Boolean = true;
uint32 thirdVar = 5_000_000;
// new
address admin = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
}

这是由于 admin 的值作为一个整体无法塞入 slot 0 未使用的存储空间。这里还剩下 7 bytes 的存储空间,但需要连续的 20 bytes 存储空间。因此,admin 的数据不会被拆分到 slot 0 和 slot 1 之间(在 slot 0 中放 7 bytes 并在 slot 1 中放 13 bytes),而是会存储在一个新的存储槽——slot 1 中。
如果一个变量的值无法完全塞入当前存储槽的剩余空间内,它将被存储在下一个可用的存储槽中。
将较小变量声明在一起
uint16 public a;
uint256 public x; // uint256 in the middle
uint32 public b;
在这种排列方式下,uint16 a 和 uint32 b 不会被打包在一起。
相反,a 将被存储在 slot 0,x 在 slot 1,b 在 slot 2,从而占用了三个存储槽。其存储槽分配如下方图表所示:

一种更好的做法是重新对这些声明进行排序,以允许较小的数据类型能够打包在一起。
uint256 public x;
// packed together
uint16 public a;
uint32 public b;
这种配置允许 a 和 b 共享一个存储槽,从而优化了存储空间。

既然我们已经理解了原始变量如何在 storage 中保存的底层原理,我们终于可以开始学习如何使用 Yul 在汇编中操作它们了。
汇编 (Yul) 中的存储槽操作
底层汇编(Yul)在执行与存储相关的操作时提供了更高的自由度。它允许我们直接对单个存储槽进行读取和写入,并访问存储变量的属性。
Yul 中有两个与存储相关的操作码(opcodes):sload() 与 sstore()。
sload()读取特定存储槽中保存的值。sstore()使用新值更新特定存储槽的值。
其他两个重要的 Yul 关键字是 .slot 和 .offset。
.slot返回在存储槽中的位置。.offset返回变量的字节偏移量。(这将在第 2 部分中讨论)
.slot 关键字
下方的合约包含三个 uint256 存储变量。
contract StorageManipulation {
uint256 x;
uint256 y;
uint256 z;
}
你应该能够推断出 x、y 和 z 分别将它们的值存储在 slot 0、slot 1 和 slot 2 中。我们可以通过使用 .slot 关键字访问存储变量的属性来证明这一点。
.slot 告诉我们一个变量是将其值保留在哪一个存储槽中。
例如,为了查询 x 的存储槽,可将 .slot 附加到变量名称之后:在汇编中即为 x.slot。
function getSlotX() external pure returns (uint256 slot) {
assembly {// yul
slot := x.slot // returns slot location of x
}
}
x.slot 返回一个值为 0,这对应于 x 存储其状态的存储槽——slot 0。

y.slot 将返回 1,这对应于 y 的存储槽——slot 1。

z.slot 将返回 2,这对应于 z 的存储槽——slot 1。

直接从存储槽读取变量的值:sload()
Yul 允许我们读取由单个存储槽存储的值。sload(slot) 操作码就是用于此目的的。它需要一个输入,即 slot(存储槽标识符),并返回存储在指定槽位置的完整 256 bit 数据。
槽标识符可以是 .slot 关键字(sload(x.slot))、局部变量(sload(localvar))或硬编码的数字(sload(1))。
以下是有关如何使用 sload() 操作码的几个示例:
contract ReadStorage {
uint256 public x = 11;
uint256 public y = 22;
uint256 public z = 33;
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
}
函数 readSlotX() 检索存储在 x.slot(slot 0)中的 256 bit 数据,并以 uint256 格式将其返回,等于 11。
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
sload(0)从 slot 0 中读取,其中存储的值为 11。sload(1)从 slot 1 中读取,其中存储的值为 22。sload(2)从 slot 2 中读取,其中存储的值为 33。sload(3)从 slot 3 中读取,其中未存储任何内容,它仍处于默认状态。
下面的动画可视化了 sload 操作码是如何运作的。
函数 sloadOpcode(slotNumber) 允许我们读取任意存储槽的值。然后它以 uint256 格式返回该值。
function sloadOpcode(uint256 slotNumber)
external
view
returns (uint256 value)
{
assembly {
value := sload(slotNumber)
}
}
值得注意的是,sload() 不执行类型检查。
在 Solidity 中,我们不能以 bool 格式返回一个 uint256 变量,因为这会引发类型错误。
function returnX() public view returns (bool ret) {
// type error
ret = x;
}
但是,如果在 Yul 中执行相同的操作集,代码依然能够编译通过。
function readSlotX_bool() external view returns(bool value) {
// return in bool
assembly{
value:= sload(x.slot) // will compile
}
}
我们将在第 2 部分详细讨论为何这是可行的。简单来说,在汇编中,每个变量本质上都被当作 bytes32 类型来处理。在汇编作用域之外,该变量将恢复其原始类型并相应地格式化数据。
因此,我们可以利用此特性以 bytes32 格式检查存储槽的值。
contract ReadSlotsRaw {
uint256 public x = 20;
function readSlotX_bool() external view returns (bytes32 value) {
assembly {
value := sload(x.slot) // will compile
}
}
}

使用 sstore() 操作码写入存储槽
Yul 为我们提供了使用 sstore() 操作码直接修改存储槽值的途径。
sstore(slot, value) 将 32-byte 长的数据直接存储到存储槽中。该操作码接受两个参数:slot 和 value:
slot:这是我们正要写入的目标存储槽。value:要存储在指定存储槽处的 32-byte 值。如果该值小于 32 bytes,则会在左侧填充零。
sstore(slot, value) 会用新值覆盖整个存储槽。
下方的合约演示了如何使用 sstore();我们使用它来更改 x 和 y 的值:
contract WriteStorage {
uint256 public x = 11;
uint256 public y = 22;
address public owner;
constructor(address _owner) {
owner = _owner;
}
// sstore() function
function sstore_x(uint256 newval) public {
assembly {
sstore(x.slot, newval)
}
}
// normal function
function set_x(uint256 newval) public {
x = newval;
}
}
sstore_x(newVal) 直接更新由 x 引用的存储槽中所存储的值,从而有效地更改了 x 的值。下面的动画展示了在调用操作码 sstore_x(88) 时发生的过程。
sstore_x(newVal) 和 set_x() 两者执行相同的功能:它们都会将 x 的值更新为一个新值。
下面的函数 sstoreArbitrarySlot(slot, newVal) 能够更改任意存储槽的值,因此,强烈建议永远不要将其放到生产环境中。
function sstoreArbitrarySlot(uint256 slot, uint256 newVal) public {
assembly {
sstore(slot, newVal)
}
}
调用 sstoreArbitratySlot(1 , 48),将会把 y 的值从 22 更改为 48。由于 y 将其值保存在存储槽 1(slot 1)中,它会覆盖掉 slot 1 中 22 的值并将其更改为 48。
sstore() 同样也不执行类型检查。
通常情况下,当我们尝试将 address 类型分配给 uint256 类型时,它会返回一个类型错误,且该合约将无法通过编译:
address public owner;
function TypeError(uint256 value) external {
owner = value; // ERROR: Type uint256 is not implicitly convertible to expected type address.
}
ERROR: Type uint256 is not implicitly convertible to expected type address.
此错误不会通过 sstore() 触发,因为它不执行类型检查。
contract WriteStorage {
address public owner;
function sstoreOpcode(uint256 value) public {
assembly {
sstore(owner.slot, value)
}
}
}
在 Yul 中操作被打包的存储变量(第 2 部分)
sstore 和 sload 以 32 bytes 的长度为单位进行操作。这在处理 uint256 类型时很方便,因为所读取或写入的整个 32 bytes 恰好直接对应于 uint256 变量。然而,在处理被打包在同一个存储槽内的变量时,情况就变得更加复杂了。它们的字节序列仅占据这 32 bytes 的一部分,而在汇编中,我们并没有一个操作码可以直接在存储中修改或读取它们的字节序列。
在第 2 部分中,我们将介绍如何使用位操作(bit-manipulation)和位掩码(bit-masking)技术,在 Yul 中对被打包存储的变量进行操作。
最初发布于 2024 年 7 月 15 日