Solidity 中的存储槽:存储分配和低级汇编存储操作

  • RareSkills
  • 更新于 2024-09-12 17:52
  • 阅读 100

本文探讨了以太坊智能合约的存储架构。它解释了变量如何保存在 EVM 存储中,以及如何使用低级汇编(Yul)读取和写入存储槽。

这些信息是理解 Solidity 中代理工作原理以及如何优化智能合约 gas 的前提条件。

作者

本文由 RareSkills 的研究实习生 Aymeric Taylor(LinkedInTwitter)共同撰写。

智能合约存储架构

智能合约中的变量将其值存储在两个主要位置:存储字节码

Variables store their value in either the bytecode or storage

字节码

字节码存储不可变信息。这些包括immutableconstant变量类型的值,

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的值也将存储在字节码中。

由于本文重点讨论存储方面,我们将不详细讨论字节码。

存储

存储保存可变信息。将其值存储在存储中的变量称为状态变量存储变量

storage variables store their data in the storage

它们的值在存储中无限期地保留,直到进一步的交易改变它们或合约自毁。

存储变量是声明在合约全局范围内的所有类型的变量(除了 immutable 和 constant 变量)。

contract StorageVariables{
    uint256 x;
    address owner;
    mapping(address => uint256) balance;
    // and more...
}

当我们与存储变量交互时,实际上是在读取和写入存储,特别是在变量保存其值的存储槽中。

存储槽

智能合约的存储组织成存储槽。每个槽的存储容量固定为 256 位或 32 字节(256 ÷ 8 = 32)。

Storage slots visualized diagrammatically

存储槽的索引从02²⁵⁶- 1。这些数字充当定位各个槽的唯一标识符。

Solidity 编译器根据合约中的声明顺序,以顺序和确定性的方式为存储变量分配存储空间。

考虑下面的合约,它包含两个存储变量:uint256 xuint256 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 中保留其值。

Animation of storage variables storing their value in their allocated storage slots

当查询时,xy将始终从其各自存储槽中存储的值读取。变量一旦部署到区块链上,就不能更改其存储槽。

如果xy的值初始化,则默认为零。所有存储变量在显式设置之前默认值为零。

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

State change animation of the variable x triggered by a function

本质上,对智能合约所做的所有状态更改都对应于这些存储槽内的更改。

存储槽内部:256 位数据

单个存储槽以 256 位格式存储数据;它存储存储变量值的位表示。

在我们之前的例子中,uint256 x将其值存储在槽 0。一个uint256变量的大小为 256 位/32 字节,因此它将使用槽 0 内的 256 位存储空间来存储其值。

  • 调用set_x(20)之前,槽 0处于默认状态(全为零)

storage slot default state visualized in text and raw bitsan

上图中的所有绿色零对应于用于存储x值的位。

  • 调用set_x(20)之后,槽 0 的状态更改为uint256 20的位表示。

Text and raw bit representation of Storage slot 0 keeping the value of 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 字节的存储槽。其他原始数据类型,如uint8uint32uint128addressbool,尺寸较小,使用的存储空间较少。它们可以在同一个存储槽中打包在一起。

顺便说一下,任何 8 的倍数直到 256 都是有效的uint,并且bytes1bytes2,所有固定字节大小的bytes1bytes2,一直到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 映射到其字节序列

如上图所示,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 从右到左打包变量。

显示属于地址 owner 和 bool boolean 变量的字节序列的图

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

将三个变量(thirdvar、Boolean、owner)映射到其各自字节序列的图

如果我们引入第四个存储变量,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 auint32 b不会被打包在一起。

相反,a将存储在槽 0 中,x在槽 1 中,b在槽 2 中,使用了三个存储槽。存储槽分配如下图所示:

在两个较小的存储变量之间声明一个 uint256 变量的低效存储槽分配

更好的做法是重新排列声明,以便较小的数据类型可以打包在一起。

uint256 public x; 
// 打包在一起  
uint16 public a; 
uint32 public b;

这种配置允许 a 和 b 共享一个存储槽,从而优化存储空间。

三个存储变量的高效存储槽分配

现在我们已经理解了原始变量在存储中的理论,我们终于准备好学习如何在汇编中使用 YUL 来操作它们。

汇编(YUL)中的存储槽操作

低级汇编(Yul)在执行与存储相关的操作时提供了更高的自由度。它允许我们直接读取和写入单个存储槽,并访问存储变量的属性。

在 Yul 中有两个与存储相关的操作码:sload()sstore()

  • sload()读取特定存储槽中存储的值。

  • sstore()用新值更新特定存储槽的值。

另外两个重要的 Yul 关键字是.slot.offset

  • .slot返回存储槽中的位置。

  • .offset返回变量的字节偏移量。(将在第 2 部分讨论)

.slot关键字

下面的合约包含三个 uint256 存储变量。

contract StorageManipulation {
    uint256 x;
    uint256 y;
    uint256 z;
}

你应该能够推断出xyz分别在槽 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

x.slot returns the slot number of the storage variable x

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

y.slot returns the slot number of the storage variable y

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

z.slot returns the slot number of the storage variable z

直接从存储槽读取变量值: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
        }
    }
}

Visual explanation of returning the value of a storage slot in bytes32

使用 sstore() 操作码写入存储槽

Yul 允许我们使用 sstore() 操作码直接修改存储槽的值。

sstore(slot, value) 将一个 32 字节长的值直接存储到存储槽中。该操作码需要两个参数,slotvalue

  • slot:这是我们要写入的目标存储槽。

  • value:要存储在指定存储槽中的 32 字节值。如果值小于 32 字节,将用零填充左侧。

sstore(slot, value) 用新值覆盖整个存储槽。

下面的合约演示了如何使用 sstore();我们用它来更改 xy 的值:

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)
        }
    }
}

在 Yul 中操作存储打包变量 第 2 部分

sstoresload 操作长度为 32 字节的数据。这在处理 uint256 类型时非常方便,因为读取或写入的整个 32 字节直接对应于 uint256 变量。然而,当处理打包在同一个存储槽中的变量时,情况变得更加复杂。它们的字节序列仅占用 32 字节的一部分,并且在汇编中,我们没有操作码可以直接修改或读取存储中的字节序列。

在第 2 部分中,我们将介绍使用位操作和位掩码技术在 Yul 中操作存储打包变量。

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

  • 翻译
  • 学分: 7
  • 标签:
点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/