研究Solidity存储引用和智能合约存储布局
这是深入Solidity数据存储位置系列的另一篇。在今天的文章中,我们将更详细地介绍EVM中的一个重要数据位置:存储(Storage)。
我们将看到合约存储的布局是如何工作的,storage
引用。我们还将使用OpenZeppelin和Compound中的一些合约来学习storage
引用在实践中如何工作,同时顺便学习这些流行合约和协议背后的Solidity代码。
了解以太坊和基于EVM的链中的存储模型对于良好的智能合约开发至关重要。
你可以在智能合约上永久地存储数据,以便将来执行时可以访问它。每个智能合约都在自己的永久存储中保持其状态。它就像"智能合约的迷你数据库 ",但与其他数据库不同,这个数据库是可以公开访问的。所有存储在智能合约存储器中的值可供外部免费读取(通过静态调用),无需向区块链发送交易。
然而,向存储空间写入是相当昂贵的。事实上,就Gas成本而言,它是EVM中最昂贵的操作。存储的内容可以通过sendTransaction
调用来改变。这种调用会改变状态。这就是为什么合约变量被称为状态变量的原因。
需要记住的一件事是,在以太坊和EVM的设计中,一个合约既不能读也不能写非自身定义的任何存储。合约A可以从另一个合约B的存储中读取或写入的唯一方法是当合约B暴露出使其能够这样做的函数。
智能合约的存储是一个持久的可读可写的数据位置。意思是说,如果数据在一次交易中被写入合约存储,一旦交易完成,它就会持久存在。在这个交易之后,读取合约存储将检索到之前这个交易所写入/更新的数据。
每个合约都有自己的存储,可以用以下规则来描述和绑定:
驻留在存储中的变量在 Solidity 中被称为状态变量。
你应该记住关于合约存储的唯一事情是:
存储是持久保存和昂贵的!
将数据保存到存储中是EVM中需要最多的Gas的操作之一。
写入存储的实际成本是多少?
成本并不总是相同的,计算写入存储的Gas是相当复杂的公式,尤其是在最新的以太坊2.0升级后)。
作为一个简单的总结,写入存储的成本如下:
读取合约存储真的是免费的吗?
智能合约的存储是免费的,可以从外部读取(从EOA),此时,不需要支付Gas。
然而,如果读取操作是修改该合约、另一个合约或区块链上的状态的交易的一部分,则必须支付Gas。
一个合约可以读取其他合约的存储吗?
默认情况下,一个智能只能在执行环境中读取自己的存储(通过SLOAD
)。但是,如果一个智能合约在其公共接口(ABI)中公开了能够从特定的状态变量或存储槽中读取数据的函数,那么该智能合约也可以读取其他智能合约的存储。
正如OpenZeppelin在他们的深入 EVM 第二部分文章中所解释的那样,智能合约的存储是一个字长寻址空间。这与内存或调用数据相反,后者是线性数据位置(增长的字节数组),你通过偏移量(字节数组中的索引)访问数据。
相反,智能合约的存储是一个键值映射(=数据库),其中键对应于存储中的一个槽号,而值是存储在这个存储槽中的实际值。
智能合约的存储是由槽组成的,其中:
综上所述:
一个智能合约的存储由2²⁵⁶个槽组成,其中每个槽可以包含大小不超过32字节的值。
在底层,合约存储是一个键值存储,其中256位的键映射到256位的值。每个存储槽的所有值最初都被设置为零,但也可以在合约部署期间(即 "构造函数")初始化为非零或一些特定的值,。
在他的文章中,Steve Marx将智能合约的存储描述为 "一个天文数字的大数组,最初充满了零,数组中的条目(索引)就是合约的存储槽。" 。
这在现实世界中会是什么样子?如何用我们可能最熟悉的东西来表示一个智能合约的存储?
合约的存储布局与货架很相似。
从货架上把东西拿出来。这相当于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字节长的值。
在我们上面的例子中,a
和b
是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)
}
}}
我们已经写了两个基本的函数来读取低级别的合约存储槽。看一下输出,我们得到以下结果:
Solidity文档中指出:
"如果可能的话,少于32字节的多个连续项目会被打包到一个存储槽中...。
存储槽中的第一个项目被低阶对齐存储
因此,当变量小于32字节时,Solidity尝试将一个以上的变量打包到一个存储槽中,如果它们能被容纳的话。因此,一个存储槽可以容纳一个以上的状态变量。
如果一个基本类型不适合存储槽的剩余空间,它将被移到下一个存储槽。对于以下Solidity合约。
pragma solidity ^0.8.0;contract StorageContract {
uint256 a = 10;
uint64 b = 20;
uint128 c = 30;
uint128 d = 40;
}
它的存储布局会是这样的:
在存储槽0处读取1个值
读取存储槽1的数值.
读取存储槽2的值
让我们看一个更具体的例子,一个流行的Defi协议: Aave。
例子: Aave Pool.sol
合约
AAVE协议使用Pools作为管理流动性的主要智能合约。这些是主要的 "面向用户的合约"。用户直接与 Aave pool合约交互,以提供或借用流动性(通过 Solidity 的其他合约,或使用 web3/ethers 库)。
定义在 Pool.sol
中的主要 Aave Pool
合约继承了一个名字很有趣的合约,与本文的主题有关:PoolStorage
。
正如协议的Aave v3的Natspec注释中所描述的,PoolStorage
合约有一个目的:定义了Pool合约的存储布局
。
如果我们看一下PoolStorage
合约的Solidity代码,我们可以看到一些状态变量由于其类型而被包装在同一个存储槽中。
_flashLoanPremiumTotal
和_flashLoanPremiumToProtocol
)都是uint128
。它们打包在一起占据了一整个存储槽(槽号6)。如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!