深入Solidity数据存储位置 - 存储

研究Solidity存储引用和智能合约存储布局

img

这是深入Solidity数据存储位置系列的另一篇。在今天的文章中,我们将更详细地介绍EVM中的一个重要数据位置:存储(Storage)。

我们将看到合约存储的布局是如何工作的,storage引用。我们还将使用OpenZeppelinCompound中的一些合约来学习storage引用在实践中如何工作,同时顺便学习这些流行合约和协议背后的Solidity代码。

目录

  • 介绍
  • 存储的布局
  • 存储器的基础知识
  • 与存储交互
  • 函数参数中的存储指针
  • 函数体中的存储指针
  • 读取存储的成本。
  • 结论

介绍

了解以太坊和基于EVM的链中的存储模型对于良好的智能合约开发至关重要。

你可以在智能合约上永久地存储数据,以便将来执行时可以访问它。每个智能合约都在自己的永久存储中保持其状态。它就像"智能合约的迷你数据库 ",但与其他数据库不同,这个数据库是可以公开访问的。所有存储在智能合约存储器中的值可供外部免费读取(通过静态调用),无需向区块链发送交易。

然而,向存储空间写入是相当昂贵的。事实上,就Gas成本而言,它是EVM中最昂贵的操作。存储的内容可以通过sendTransaction调用来改变。这种调用会改变状态。这就是为什么合约变量被称为状态变量的原因。

需要记住的一件事是,在以太坊和EVM的设计中,一个合约既不能读也不能写非自身定义的任何存储。合约A可以从另一个合约B的存储中读取或写入的唯一方法是当合约B暴露出使其能够这样做的函数。

存储的基本原理

智能合约的存储是一个持久的可读可写的数据位置。意思是说,如果数据在一次交易中被写入合约存储,一旦交易完成,它就会持久存在。在这个交易之后,读取合约存储将检索到之前这个交易所写入/更新的数据。

每个合约都有自己的存储,可以用以下规则来描述和绑定:

  • 持有状态变量
  • 在交易和函数调用之间持久存在
  • 读取是免费的,但写入是昂贵的
  • 合约存储在合约构建期间被预先分配。

驻留在存储中的变量在 Solidity 中被称为状态变量。

你应该记住关于合约存储的唯一事情是:

存储是持久保存和昂贵的!

将数据保存到存储中是EVM中需要最多的Gas的操作之一。

写入存储的实际成本是多少?

成本并不总是相同的,计算写入存储的Gas是相当复杂的公式,尤其是在最新的以太坊2.0升级后)。

作为一个简单的总结,写入存储的成本如下:

  • 初始化一个存储槽(第一次,或如果该槽不包含任何值),从零到非零值,花费20,000 gas
  • 修改一个存储槽的值需要5,000个Gas
  • 删除存储槽中的数值,需要退还15,000 Gas。

读取合约存储真的是免费的吗?

智能合约的存储是免费的,可以从外部读取(从EOA),此时,不需要支付Gas。

然而,如果读取操作是修改该合约、另一个合约或区块链上的状态的交易的一部分,则必须支付Gas。

一个合约可以读取其他合约的存储吗?

默认情况下,一个智能只能在执行环境中读取自己的存储(通过SLOAD)。但是,如果一个智能合约在其公共接口(ABI)中公开了能够从特定的状态变量或存储槽中读取数据的函数,那么该智能合约也可以读取其他智能合约的存储。

存储的布局

正如OpenZeppelin在他们的深入 EVM 第二部分文章中所解释的那样,智能合约的存储是一个字长寻址空间。这与内存或调用数据相反,后者是线性数据位置(增长的字节数组),你通过偏移量(字节数组中的索引)访问数据。

相反,智能合约的存储是一个键值映射(=数据库),其中键对应于存储中的一个槽号,而值是存储在这个存储槽中的实际值。

智能合约的存储是由槽组成的,其中:

  • 每个存储槽可以包含长度不超过32字节的字。
  • 存储槽从位置0开始(就像数组索引)。
  • 总共有2²⁵⁶个存储槽可用(用于读/写)。

综上所述:

一个智能合约的存储由2²⁵⁶个槽组成,其中每个槽可以包含大小不超过32字节的值。

在底层,合约存储是一个键值存储,其中256位的键映射到256位的值。每个存储槽的所有值最初都被设置为零,但也可以在合约部署期间(即 "构造函数")初始化为非零或一些特定的值,。

合约存储像货架

在他的文章中,Steve Marx将智能合约的存储描述为 "一个天文数字的大数组,最初充满了零,数组中的条目(索引)就是合约的存储槽。"

这在现实世界中会是什么样子?如何用我们可能最熟悉的东西来表示一个智能合约的存储?

img

合约的存储布局与货架很相似。

从货架上把东西拿出来。这相当于EVM在读取状态变量时的做法。

contract Owner {

    address _owner;

    function owner() public returns (address) {
        return _owner;
    }}

在上面的合约中,只有一个架子(=一个槽)。EVM从 "0号架子 "上加载变量,并将其卸载(到堆栈上)以呈现给你。

状态变量的布局

Solidity的主要开发者chriseth这样描述合约的存储:

"你可以把存储看作是一个具有虚拟结构的大数组......一个在运行时不能改变的结构--它是由你合约中的状态变量决定的"。

从上面的例子中,我们可以看到,Solidity为你合约中的每一个定义的状态变量分配了一个存储槽。对于静态大小的状态变量,存储槽是连续分配的,从0号槽开始,按照定义状态变量的顺序。

Chriseth在这里的意思是: "存储不能在函数调用中创建"。事实上,如果必须是永久存在,通过调用函数来创建新的存储变量,也没有什么意义(不过,映射的情况略有不同)。

智能合约的存储是在合约构建过程中(在合约被部署时)预置的。这意味着合约存储的布局在合约创建时就已经确定了。该布局是基于你的合约级变量声明而 "成型 "的,并且这种布局不能被未来的方法调用所改变。

让我们用solc命令行工具看看上一个合约的实际存储布局,如果你运行下面的命令。

solc contracts/Owner.sol --storage-layout --pretty-json

你将得到以下JSON输出:

======= contracts/Owner.sol:Owner =======
Contract Storage Layout:
{
  "storage":
  [
    {
      "astId": 3,
      "contract": "contracts/Owner.sol:Owner",
      "label": "_owner",
      "offset": 0,
      "slot": "0",
      "type": "t_address"
    }
  ],
  "types":
  {
    "t_address":
    {
      "encoding": "inplace",
      "label": "address",
      "numberOfBytes": "20"
    }
  }
}

从上面的JSON输出中,我们可以看到一个storage字段,它包含一个对象数组。这个数组中的每个对象都是指一个状态变量名。我们还可以看到,每个变量都被映射到一个 插槽(slot),并有一个基本的 类型(type)

这意味着变量_owner可以被改变为同一类型(在我们的例子中为地址)的任何有效值。然而,槽0是为这个变量保留的,并将永远在那里。

现在让我们来看看状态变量是如何在存储中布局的(进一步了解请看Solidity文档)。

考虑一下下面的Solidity代码:

pragma solidity ^0.8.0;

contract StorageContract {

    uint256 a = 10;
    uint256 b = 20;
}

所有静态大小的变量都是按照它们被定义的顺序依次放入存储槽的。

记住:每个存储槽最多可以容纳32字节长的值。

在我们上面的例子中,ab是32字节长(因为它们的类型是uin256)。因此,它们被分配了自己的存储槽。

将状态变量打包在一个存储槽中

在我们之前的例子中没有什么特别之处。但是现在让我们考虑这样的情况:你有几个不同大小的uint变量,如下所示:

pragma solidity ^0.8.0;contract StorageContract {

    uint256 a = 10;
    uint64 b = 20;
    uint64 c = 30;
    uint128 d = 40;

    function readStorageSlot0() public view returns (bytes32 result) {
        assembly {
            result := sload(0)
        }
    }

    function readStorageSlot1() public view returns (bytes32 result) {
       assembly {
            result := sload(1)
        }
    }}

我们已经写了两个基本的函数来读取低级别的合约存储槽。看一下输出,我们得到以下结果:

img

Solidity文档中指出:

"如果可能的话,少于32字节的多个连续项目会被打包到一个存储槽中...。

存储槽中的第一个项目被低阶对齐存储

因此,当变量小于32字节时,Solidity尝试将一个以上的变量打包到一个存储槽中,如果它们能被容纳的话。因此,一个存储槽可以容纳一个以上的状态变量。

如果一个基本类型不适合存储槽的剩余空间,它将被移到下一个存储槽。对于以下Solidity合约。

pragma solidity ^0.8.0;contract StorageContract {

    uint256 a = 10;
    uint64 b = 20;
    uint128 c = 30;
    uint128 d = 40;

}

它的存储布局会是这样的:

img

在存储槽0处读取1个值

img

读取存储槽1的数值.

img

读取存储槽2的值

让我们看一个更具体的例子,一个流行的Defi协议: Aave。

例子: Aave Pool.sol 合约

img

AAVE协议使用Pools作为管理流动性的主要智能合约。这些是主要的 "面向用户的合约"。用户直接与 Aave pool合约交互,以提供或借用流动性(通过 Solidity 的其他合约,或使用 web3/ethers 库)。

定义在 Pool.sol 中的主要 Aave Pool 合约继承了一个名字很有趣的合约,与本文的主题有关:PoolStorage

img

来源:Aave v3 Protocol, Pool.sol

正如协议的Aave v3的Natspec注释中所描述的,PoolStorage合约有一个目的:定义了Pool合约的存储布局

如果我们看一下PoolStorage合约的Solidity代码,我们可以看到一些状态变量由于其类型而被包装在同一个存储槽中。

  • 下面的绿色部分:与闪电款有关的状态变量(_flashLoanPremiumTotal_flashLoanPremiumToProtocol)都是uint128。它们打包在一起占据了一整个存储槽(槽号6)。
  • 下面是蓝色部分:最后...

剩余50%的内容订阅专栏后可查看

0 条评论

请先 登录 后评论
翻译小组
翻译小组

首席翻译官

167 篇文章, 29224 学分