本文探讨了以太坊智能合约的存储架构。它解释了变量如何保存在 EVM 存储中,以及如何使用低级汇编(Yul)读取和写入存储槽。
这些信息是理解 Solidity 中代理工作原理以及如何优化智能合约 gas 的前提条件。
作者
本文由 RareSkills 的研究实习生 Aymeric Taylor(LinkedIn,Twitter)共同撰写。
智能合约中的变量将其值存储在两个主要位置:存储和字节码。
字节码存储不可变信息。这些包括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
的值也将存储在字节码中。
由于本文重点讨论存储方面,我们将不详细讨论字节码。
存储保存可变信息。将其值存储在存储中的变量称为状态变量或存储变量。
它们的值在存储中无限期地保留,直到进一步的交易改变它们或合约自毁。
存储变量是声明在合约全局范围内的所有类型的变量(除了 immutable 和 constant 变量)。
contract StorageVariables{
uint256 x;
address owner;
mapping(address => uint256) balance;
// and more...
}
当我们与存储变量交互时,实际上是在读取和写入存储,特别是在变量保存其值的存储槽中。
智能合约的存储组织成存储槽。每个槽的存储容量固定为 256 位或 32 字节(256 ÷ 8 = 32)。
存储槽的索引从0
到2²⁵⁶- 1
。这些数字充当定位各个槽的唯一标识符。
Solidity 编译器根据合约中的声明顺序,以顺序和确定性的方式为存储变量分配存储空间。
考虑下面的合约,它包含两个存储变量:uint256 x
和uint256 y
。
contract StorageVariables {
uint256 public x; // first declared storage variable
uint256 public y; // second declared storage variable
}
由于x
是首先声明的,而y
是其次声明的,x
被分配到第一个存储槽,槽 0,而y
被分配到第二个存储槽,槽 1。因此,x
将在槽 0 中保留其值,而y
将在槽 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;
}
此交易触发槽 0 的状态变化,将其状态从0
更新为20
。
本质上,对智能合约所做的所有状态更改都对应于这些存储槽内的更改。
单个存储槽以 256 位格式存储数据;它存储存储变量值的位表示。
在我们之前的例子中,uint256 x
将其值存储在槽 0。一个uint256
变量的大小为 256 位/32 字节,因此它将使用槽 0 内的 256 位存储空间来存储其值。
set_x(20)
之前,槽 0处于默认状态(全为零)上图中的所有绿色零对应于用于存储x
值的位。
set_x(20)
之后,槽 0 的状态更改为uint256 20的位表示。以原始 256 位格式读取存储槽的内容不太易于人类阅读,因此,Solidity 开发者通常以十六进制格式读取它。
原始 256 位: 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
十六进制格式:
0x0000000000000000000000000000000000000000000000000000000000000014
256位的 1 和 0 可以简化为64个十六进制数字。1 个十六进制字符代表 4 位。2 个十六进制字符代表 1 字节。十六进制 0x14 同样转换为十进制数20
。0x14(十六进制)= 10100(二进制)= 20(十进制)。二进制到十六进制转换器。
我们将在即将到来的部分中演示如何使用汇编以十六进制格式或 bytes32 类型输出存储槽的值。
在本文中,我们的示例将仅围绕原始数据类型,如无符号整数(uint
)、整数(int
)、地址(address
)和布尔值(bool
)。
contract PrimitiveTypes {
uint256 a;
int256 b;
address owner;
bool isTrue;
}
这些变量最多占用一个存储槽。
复杂的数据类型如结构体(struct{}
)、数组(array[]
)、映射(mapping(address => uint256)
)、字符串(string
)和字节(bytes32
)有更复杂的存储槽分配。它们需要单独的文章来详细讨论。
到目前为止,我们方便地处理了uint256
变量,它们占用了整个 32 字节的存储槽。其他原始数据类型,如uint8
、uint32
、uint128
、address
和bool
,尺寸较小,使用的存储空间较少。它们可以在同一个存储槽中打包在一起。
顺便说一下,任何 8 的倍数直到 256 都是有效的uint
,并且bytes1
、bytes2
,所有固定字节大小的bytes1
、bytes2
,一直到bytes32
都是有效的数据类型。
下表说明了一些原始数据类型的存储大小。
类型 | 大小 |
bool |
1 字节 |
uint8 |
1 字节 |
uint32 |
4 字节 |
uint128 |
16 字节 |
address |
20 字节 |
uint256 |
32 字节 |
例如,一个类型为address
的存储变量将需要 20 字节的存储空间来存储其值,如上表所示。
contract AddressVariable{
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
}
在上面的合约中,owner 将使用槽 0 中可用的 32 字节中的 20 字节来存储其值。
Solidity 从最低有效字节(最右边的字节)开始在存储槽中打包变量,并向左推进。
我们可以通过读取槽的 bytes32 表示来验证这一点:
如上图所示,owner 的值0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
从最右边的字节或最低有效字节开始存储。槽 0 中剩余的 12 字节将是未使用的存储空间,可以由另一个变量占用。
当按顺序声明时,如果它们的总大小小于 256 位或 32 字节,较小尺寸的变量将位于同一个存储槽中。
假设我们声明了第二个和第三个类型为bool
(1 字节)和uint32
(4 字节)的存储变量,它们的值将存储在与owner
相同的存储槽 0 中的未使用存储空间中。
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
// 新增
bool Boolean = true;
uint32 thirdvar = 5_000_000;
}
Boolean,第二个声明的存储变量,将在 owner 字节序列左边的第一个字节或未使用存储空间的最低有效字节存储其值。记住,solidity 从右到左打包变量。
uint32 thirdVar,第三个存储变量,将在 Boolean 字节序列的左边存储其值。
如果我们引入第四个存储变量,address admin,它的值将存储在下一个存储槽,即槽 1 中。
contract AddressVariable {
address owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
bool Boolean = true;
uint32 thirdVar = 5_000_000;
// 新增
address admin = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
}
这是因为 admin 的值整体上不能适应槽 0 的未使用存储空间。剩余 7 字节的存储空间,但需要 20 字节的连续存储空间。因此,admin 的值将存储在一个新的存储槽中,即槽 1,而不是将 admin 的数据分割在槽 0 和槽 1 之间(槽 0 中的 7 字节和槽 1 中的 13 字节)。
如果一个变量的值不能完全适应当前存储槽的剩余空间,它将存储在下一个可用槽中。
uint16 public a;
uint256 public x; // 中间的 uint256
uint32 public b;
在这种安排中,uint16 a
和uint32 b
不会被打包在一起。
相反,a
将存储在槽 0 中,x
在槽 1 中,b
在槽 2 中,使用了三个存储槽。存储槽分配如下图所示:
更好的做法是重新排列声明,以便较小的数据类型可以打包在一起。
uint256 public x;
// 打包在一起
uint16 public a;
uint32 public b;
这种配置允许 a 和 b 共享一个存储槽,从而优化存储空间。
现在我们已经理解了原始变量在存储中的理论,我们终于准备好学习如何在汇编中使用 YUL 来操作它们。
低级汇编(Yul)在执行与存储相关的操作时提供了更高的自由度。它允许我们直接读取和写入单个存储槽,并访问存储变量的属性。
在 Yul 中有两个与存储相关的操作码:sload()
和sstore()
。
sload()
读取特定存储槽中存储的值。
sstore()
用新值更新特定存储槽的值。
另外两个重要的 Yul 关键字是.slot
和.offset
。
.slot
返回存储槽中的位置。
.offset
返回变量的字节偏移量。(将在第 2 部分讨论)
.slot
关键字下面的合约包含三个 uint256 存储变量。
contract StorageManipulation {
uint256 x;
uint256 y;
uint256 z;
}
你应该能够推断出x
、y
和z
分别在槽 0、槽 1 和槽 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
存储状态的存储槽—槽 0。
y.slot
将返回 1,对应 y
的存储槽—槽 1。
z.slot
将返回 2,对应 z
的存储槽—槽 1。
sload()
Yul 允许我们读取单个存储槽中存储的值。sload(slot)
操作码用于此目的。它需要一个输入 slot
,即存储槽标识符,并返回指定槽位置存储的整个 256 位数据。
槽标识符可以是 .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
(槽 0)中的 256 位数据,并以 uint256
格式返回,等于 11。
function readSlotX() external view returns (uint256 value) {
assembly {
value := sload(x.slot)
}
}
sload(0)
从槽 0 读取,存储值为 11。
sload(1)
从槽 1 读取,存储值为 22。
sload(2)
从槽 2 读取,存储值为 33。
sload(3)
从槽 3 读取,没有存储任何值,仍处于默认状态。
下方动画展示了 sload
操作码的工作原理。
video
函数 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
}
}
我们将在第二部分详细讨论为什么会这样。简单来说,在汇编中,每个变量本质上都被视为 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 字节长的值直接存储到存储槽中。该操作码需要两个参数,slot 和 value:
slot
:这是我们要写入的目标存储槽。
value
:要存储在指定存储槽中的 32 字节值。如果值小于 32 字节,将用零填充左侧。
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)
操作码时发生的情况。
video
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 中,它会覆盖槽 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)
}
}
}
sstore
和 sload
操作长度为 32 字节的数据。这在处理 uint256
类型时非常方便,因为读取或写入的整个 32 字节直接对应于 uint256
变量。然而,当处理打包在同一个存储槽中的变量时,情况变得更加复杂。它们的字节序列仅占用 32 字节的一部分,并且在汇编中,我们没有操作码可以直接修改或读取存储中的字节序列。
在第 2 部分中,我们将介绍使用位操作和位掩码技术在 Yul 中操作存储打包变量。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!