对Solidity 存储、内存和calldata的深入研究
图片来源:Ant Rozetski on Unsplash
文章较长,内容很详细、很深入。但是不要吓到,坐下来,喝杯咖啡或你最喜欢的饮料,慢慢体会。
我们来探索Solidity的一个新的和必不可少的部分:数据存储位置。具有挑战性的话题。非常底层,因为它与以太坊虚拟机(EVM)的架构有关。
但通过类比,我们总是可以更好地理解复杂的编程概念、架构、智能合约和一般的区块链。
在这篇文章中,我们将通过类比EVM如果是一个 "巨大的工业工厂 "来学习每个数据位置(我希望能帮助你理解)。
然后我们将进入Solidity代码,学习每个数据位置的参考类型的规则和行为。所有这些都有图画、架构图、代码片段以及你可能知道的流行项目的源代码例子来学习。
这个系列还有另外二篇文章分别介绍存储(Storage)及 内存(memory).
作为一个对自己事业充满热情的人,一个工业炉的建造者和翻新者,我的父亲决定把我送到工业工厂去工作。
非常大的工厂! 熔化和制造钢铁和铝的工厂(Arcelor Mittal和Constellium)。
你需要一辆车在工业工厂的不同部门之间来回穿梭,比如钢铁厂和轧钢厂。
和EVM架构图一样,安赛乐米塔尔工业厂房的道路规划图一开始也是很难理解的!
我还记得的是我第一次进入包含轧机的机库(看视频链接!)工业炉是如此巨大,从这头走到那头需要几分钟时间!
机库里挤满了其他建筑工人、砌砖工人、电工,叉车在四周移动和行驶,还有人控制着机器用巨大的机械臂把东西搬进和搬出炉子。
就像我父亲一样,我是一个不同类型的建设者,也就是人们在web3中所说的 "建设者"。但当我回头看这个工业工厂时,我发现它的内部与EVM的内部有很多共同之处。
材料被储存在许多不同的地方,从架子上拿出来,放在炉子和其他随机的机器里。它们会被熔化、加工或处理。
图片来源:Patrick Henry on Unsplash
在我之前的文章中"关于ABI的一切"我提到。"智能合约是非常放松的小生命"。
但是,一旦智能合约被调用,它们就会变得非常活跃! 他们的底层逻辑作为字节码运行并被执行。而这多亏了EVM。
EVM从未放松过。它不断地在运行软件客户端的每台机器上重复运行。这是为了更新区块链的状态,让每个人都保持同步。
一个大的工业工厂也是如此。它一直在运行,24/7。门口总是有人,夜班工人在机库里整理东西,卡车进进出出,机器和熔炉在熔化、燃烧和加工更多材料。
当你调用一个智能合约时,EVM会运行并执行其字节码中的一组指令(=操作码)。其中一些操作码指示EVM从/向不同的位置读写数据。EVM需要这些多个数据位置来正确完成其工作。
在一个工厂里,操作工作和材料可以在多个地方找到。
你明白了,就像工厂一样,EVM使用很多不同的区域来计算和做处理工作。
EVM就像一个工业工厂。
学习每个数据位置是如何工作的,需要学习很多东西,比如 "存储"、"内存 "和 "calldata"的结构和布局,或者 "什么内容可以存储在哪里"。
但最重要的是,它教会了你与它们每一个相关的(Gas)成本,以及我所说的可变性/安全性的权衡。
作为一个Solidity开发者,对EVM中的数据位置以及如何充分使用它们的良好理解将使你能够:
本文旨在对这些不同的数据位置做一个很好的概述,数据可以被写入和读出。我们将看到,有些位置是只读的,不能写入,而其他位置是可变的,里面存储的值可以被编辑。
EVM有五个主要的数据位置:
EVM中可用的数据位置概览,来源:精通以太坊 。
主要EVM数据位置的基础知识也在以太坊黄皮书 第9章节中有说明
智能合约的存储相当于工业机库上的存储架单元,Petrebels on Unsplash
在以太坊中,每个特定地址的智能合约都有自己的 "存储",由一个键值存储组成,将256位映射到256位。存储中的数据在函数调用和交易之间持续存在。
存储是所有合约状态变量所在的地方。每个合约都有自己的存储。存储中的变量在函数调用之间持续存在。然而,存储空间的使用是相当昂贵的。
由于存储指的是合约存储,它指的是永久存储在区块链上的数据。
你可以从/向合约存储中读取和写入。在低层,用于这样做的EVM操作码是SSTORE
和SLOAD
。
(图来源: [Simon Kadula](http://simon kadula/) on Unsplash)
EVM 内存
是用来保存临时值的,在外部函数调用之间被擦除。然而,它的使用成本比较低。
在EVM中,内存是易失性的,是特定合约(环境)的上下文。这意味着,当执行环境从一个合约变为另一个合约时,“白板/写字板”被清除。在每一个新的消息调用中,都会获得一个新的被清除的内存实例。
因此,内存变量是暂时的。它们在对其他合约的外部函数调用之间被擦除。
你可以从/到EVM内存中读取和写入。在低层,用于从/向内存读写的EVM操作码是MLOAD
, MSTORE
, 和MSTORE8
。
某些EVM操作码,如 "CALL"、"DELEGATECALL "或 "STATICCALL" 从EVM内存中消耗其参数。
calldata相当于从船上或卡车上取出的一个集装箱。这些集装箱包含送到工厂进行加工的材料。Calldata是只读的。
calldata
是交易的数据或外部函数调用的参数所在的位置。它是一个只读的数据位置。你不能写到它。
Calldata的行为主要类似于内存,是一个可由字节编址的空间。你必须为你想读取的字节数指定一个准确的字节偏移。
在低层,可用于从calldata读取的EVM操作码是CALLDATALOAD
, CALLDATASIZE
和CALLDATACOPY
。
(来源:Arno Senoner on Unsplash)
堆栈是用来存放小型局部变量的。它的使用几乎是免费的(用Gas很低),但大小有限,能容纳的项目数量也有限。
堆栈是大多数在函数内部创建的局部变量所在的地方。它是EVM的一个重要部分。
在低层,可以用来对堆栈进行操作的EVM操作码,包括PUSH
、POP
、SWAP
和DUP
指令。大多数其他的EVM操作码从堆栈中消耗数据(通过从堆栈中取出),并将结果推回堆栈中。
(资料来源: Waldemar Brandt on Unsplash)
代码指的是合约的字节码。你只能从合约字节码中读取,而不能写到它。通常是你在Solidity中定义为 constant
的变量。大多数的EVM操作码从堆栈中消耗它们的参数。
字节码包含了很多关于合约的信息和逻辑,包括调度器,以及合约元数据。
在低层,从智能合约的代码中读取的EVM操作码是CODESIZE
和CODECOPY
及操作码EXTERNALCODESIZE
和EXTERNALCODECOPY
。
来源:https://github.com/CJ42/All-About-Solidity/blob/data-locations/articles/Data-Locations.md
Solidity语言定义了一些默认的规则,围绕着一些变量的默认位置,取决于它们被定义的位置。
constant
的变量 = 合约代码(=bytecode)。这些变量是不可改变的,一旦合约被部署就不能改变。它们是只读的,可以被内联使用。
这些被称为状态变量,因为它们是合约状态的一部分,反过来也是区块链全局状态的一部分(=以太坊中所有智能合约的状态)。这些变量被永久地写入区块链。
值类型的变量(例如,uint256
, bytes8
, address
)驻留在堆栈中。
大多数时候,你不需要使用数据位置关键字(storage
,memory
,或calldata
),因为Solidity通过上面解释的默认规则处理数据的位置。
然而,有时你确实需要使用这些关键字并指定数据位置,即在处理复杂类型的变量时,如函数内的结构体和数组。
对于数组(固定或动态大小的数组, 如uint256[]
), bytes
, string
, 结构和映射, 你必须明确提供存储值的数据区域. 这可以是storage
,memory
或calldata
。
通过使用这些关键字,你可以创建一个 "引用" 类型的变量。这种类型必须比值类型更仔细地处理。
下一个问题自然就出现了:
什么时候使用关键字存储(storage),内存(memory),和calldata?
你只能在函数中的3个地方指定引用一个变量的数据位置。
备注: 在Solidity 0.5.0之前,当一个复杂类型的变量(例如,动态大小的数组)被作为一个函数参数传递时,可以不指定这个变量的数据位置。
当storage
被用作一个函数参数的引用时,它是一个指向合约存储的指针。
对于memory
和calldata
也是如此。这样的关键字指向EVM内存中的某个位置或从交易中进来的输入数据(=calldata)的指针。
在函数内部,无论函数的可见性如何,都可以指定所有三个数据位置:
然而,引用类型之间的赋值是受特定规则约束的。(这里是变得复杂和 "略微扭曲舌头的地方!")。
storage
引用:总是可以直接从合约存储中(=状态变量)或通过另一个 "存储" 引用 给一些变量赋值,但它们不能赋值一个 "内存 "或 "calldata "引用。memory
引用:可以被分赋值任何东西(直接的状态变量,或storage
、memory
或calldata
引用)。它总是创建一个副本。calldata
引用:总是可以直接从calldata(= tx/message调用的输入),或通过另一个calldata
引用赋值,但它们不能从storage
或memory
引用赋值。为了更简单地描述它。
对于memory =总是可以复制内存中的任何数据(无论它来自合约的存储还是calldata)。
对于存储和calldata = 我们只能分配来自指定数据位置的值(无论是直接类型还是通过相同类型的引用)。
让我们看看一些真实的、实用的Solidity例子:
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;
contract StorageReferences {
bytes someData;
function storageReferences() public {
bytes storage a = someData;
bytes memory b;
bytes calldata c;
// storage variables can reference storage variables as long as the storage
reference they refer to is initialized.
bytes storage d = a;
// if the storage reference it refers to was not initiliazed, it will lead
to an error
// "This variable (refering to a) is of storage pointer type and can be
accessed without prior assignment,
// which would lead to undefined behaviour."
// basically you cannot create a storage reference that points to another
storage reference that points to nothing
// f -> e -> (nothing) ???
/// bytes storage e;
/// bytes storage f = e;
// storage pointers cannot point to memory pointers (whether the memory
pointer was initialized or not
/// bytes storage x = b;
/// bytes memory r = new bytes(3);
/// bytes storage s = r;
// storage pointer cannot point to a calldata pointer (whether the calldata
pointer was initialized or not).
/// bytes storage y = c;
/// bytes calldata m = msg.data;
/// bytes storage n = m;
}
}
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;
contract DataLocationsReferences {
bytes someData;
function memoryReferences() public {
bytes storage a = someData;
bytes memory b;
bytes calldata c;
// this is valid. It will copy from storage to memory
bytes memory d = a;
// this is invalid since the storage pointer x is not initialized and does
not point to anywhere.
/// bytes storage x;
/// bytes memory y = x;
// this is valid too. `e` now points to same location in memory than `b`;
// if the variable `b` is edited, so will be `e`, as they point to the same
location
// same the other way around. If the variable `e` is edited, so will be `b`
bytes memory e = b;
// this is invalid, as here c is a calldata pointer but is uninitialized,
so pointing to nothing.
/// bytes memory f = c;
// a memory reference can point to a calldata reference as long as the
calldata reference
// was initialized and is pointing to somewhere in the calldata.
// This simply result in copying the offset in the calldata pointed by the
variable reference
// inside the memory
bytes calldata g = msg.data[10: ];
bytes memory h = g;
// this is valid. It can copy the whole calldata (or a slice of the
calldata) in memory
bytes memory i = msg.data;
bytes memory j = msg.data[4: 16];
}
}
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;
contract DataLocationsReferences {
bytes someData;
function calldataReferences() public {
bytes storage a = someData;
bytes memory b;
bytes calldata c;
// for calldata, the same rule than for storage applies.
// calldata pointers can only reference to the actual calldata or other
calldata pointers.
}
}
contract MemoryCopy {
bytes someData;
constructor() {
someData = bytes("All About Solidity");
}
function copyStorageToMemory() public {
// assigning memory <\-- storage
// this load the value from storage and copy in memory
bytes memory value = someData;
// changes are not propagated down in the contract storage
value = bytes("abcd");
}
}
当我们将一个状态变量赋值给一个内存
引用的变量时,基本上是将数据从存储空间→复制到内存。
= 我们正在向内存写入 =新的内存被分配。
这意味着对变量的任何修改都不会影响到合约存储(=合约状态)。
= 合约存储将不会被重写。
在上面的例子中,运行函数后,状态变量someData
没有被改变。
这是前一个例子 "内存←状态变量 "的反例。同样会进行整体拷贝,这个例子可以在Solidity文档部分找到。
contract StoragePointer {
uint256[] someData;
uint256[] moreData;
function createStoragePointer() public {
// pointer to storage
uint256[] storage value = someData;
// pointer to somewhere else in storage
value = moreData;
}
}
当一个storage
变量在一个函数中被创建时,这基本上是作为一个存储指针。存储指针简单地引用已经分配到存储空间的数据。
你可以重新分配存储指针,将其指向存储中的其他地方。
= 引用存储中的一些现有值 = 不创建新的存储
然而,我们可以通过直接给查找变量分配一个新的值来覆盖合约存储。看一下这个例子。
contract StoragePointer {
uint256[] public someData;
function pushToArrayViaPointer() public {
uint256[] storage value = someData;
value.push(1);
}
}
如果你接着查询someData
的第一个索引,你会得到1
。
当我们将一个storage
引用的数据分配给一个memory
引用的变量时, 我们是在从storage → memory中复制数据。
= 我们是在向内存写数据 = 新的内存被分配。
这等同于我们之前涉及的第一种情况 "内存←状态变量",并通过存储引用增加了 "中间的额外路径"。存储引用将解析到状态变量,然后将其复制到内存。下面是一个建立在前面的代码片段上的基本例子。
contract MemoryCopy {
uint256[] public someData;
function copyStorageToMemoryViaRef() public {
uint256[] storage storageRef = someData;
uint256[] memory copyFromRef = storageRef;
}
}
这里copyFromRef
是整个数组someData
的拷贝。这个数组是通过存储引用storageRef
在内存中复制的。
同样在此案例中,由于我们从存储空间复制到内存,我们是在操作数据的副本,而不是在存储空间中的实际数据上。因此,对copyFromRef
所做的任何修改都不会影响合约存储,也不会修改合约状态。
为了说明这一点,请在Remix中复制以下合约,然后。
运行函数test()
。
在索引1
处读取someData
数组。
contract MemoryCopy {
uint256[] public someData;
constructor() public {
someData.push(1);
someData.push(2);
someData.push(3);
}
function doesNotPropagateToStorage() public {
uint256[] storage storageRef = someData;
uint256[] memory copyFromRef = storageRef;
copyFromRef[1] = 12345;
}
}
你会看到在运行函数后,someData[1]
的值仍然是2
,而且12345
并没有传播回合约存储。
Calldata引用的行为与storage
引用相同。它只能作为交易数据的引用,或者作为用calldata
关键字提供的复杂类型的函数参数的引用。
简而言之,一个calldata类型的变量总是创建一个引用。
唯一的主要区别是,作为calldata
引用的变量不能被修改,因为calldata是只读的。
这与storage
引用恰恰相反。当你给storage
引用分配一个新的值时,这个变化会传播到合约状态。
我强烈推荐你阅读它! 我曾用它来分析更多的底层EVM操作代码,以了解在背后发生了什么。
Solidity文档中提到了以下内容:
"数据位置不仅与数据的持久性有关,而且还与赋值的语义有关"。
在指定函数体内部的数据位置时,必须考虑两个主要问题:效果和Gas消耗。
让我们用一个简单的合约作为例子来更好地理解。这个合约在存储中持有一个结构体的映射。为了比较每个数据位置的行为,我们将使用不同的函数,使用不同的数据位置关键字。
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^ 0.8 .0;
contract Garage {
struct Item {
uint256 units;
}
mapping(uint256 => Item) items;
// gas (view): 24,025
function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
Item storage item = items[_itemIndex];
return item.units;
}
// gas (view): 24,055
function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
Item memory item = items[_itemIndex];
return item.units;
}
// gas: 50,755 (1st storage write)
function setItemUnitsStorage(uint256 _id, uint256 _units) public {
Item storage item = items[_id];
item.units = _units;
}
// gas: 27,871
function setItemUnitsMemory(uint256 _id, uint256 _units) public {
Item memory item = items[_id];
item.units = _units;
}
}
我们要问自己的第一个问题是:使用内存的getter还是使用存储的getter更便宜?它们各自是如何工作的?
// gas (view): 24,025
function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
Item storage item = items[_itemIndex];
return item.units;
}
// gas (view): 24,055
function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
Item memory item = items[_itemIndex];
return item.units;
}
使用 "storage "的getter比使用 "memory "的getter稍微便宜一些,因为它充当了一个存储指针。
使用memory
的getter更贵,花费更多的Gas,因为要创建一个新的变量。在上面的例子中,除了读取存储映射items[_itemIndex]
里面的值之外,它还会复制内存中的值。
让我们确保理解这里到底发生了什么。根据关键字 "storage "或 "memory",EVM在幕后做了什么?
我在下面列出了两种获取器类型的操作码序列(为了清晰和简洁,左边没有写程序计数器)。你可以通过在Remix中调试代码来查看它们。
如果我们比较两个getter函数的操作码,我们会发现,与使用内存的getter(45条指令)相比,使用存储的getter包含更少的操作码(30条指令)。
建议:我强烈建议使用evm.codes网站来测试这两个函数,并分析操作码和存储/内存中一个又一个操作码的变化。
; getItemUnitsStorage = 30 instructions
PUSH1 00 ; 1) manipulate + prepare the stack
DUP1
PUSH1 00
DUP1
DUP5
DUP2
MSTORE ; 2.1) prepare the memory for hashing (1)
PUSH1 20
ADD
SWAP1
DUP2
MSTORE ; 2.2) prepare the memory for hashing (2)
PUSH1 20
ADD
PUSH1 00
SHA3 ; 3) compute the storage number to load via hashing
SWAP1
POP
DUP1
PUSH1 00
ADD
SLOAD ; 4) load mapping value from storage
SWAP2
POP
POP
SWAP2
SWAP1
POP
JUMP
JUMPDEST
; getItemUnitsMemory = 47 instructions
PUSH1 00
DUP1
PUSH1 00
DUP1
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40 ; <------ additional opcodes start here
MLOAD ; 1) load the free memory pointer
DUP1 ; 2) reserve the free memory pointer by duplicating it
PUSH1 20
ADD ; 3) compute the new free memory pointer
PUSH1 40
MSTORE ; 4) store the new free memory pointer
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD ; 5) load mapping value from storage
DUP2
MSTORE ; 6) store mapping value retrieved from storage in memory
POP
POP ; <------------ additional opcodes end here
SWAP1
POP
DUP1
PUSH1 00
ADD
MLOAD
SWAP2
POP
POP
SWAP2
SWAP1
POP
在底层,EVM对一个使用storage
的getter执行以下步骤。
操作 + 准备堆栈
为 hash准备内存((1)和(2))。
计算要通过hash和SHA3
加载的值的存储槽(=来自映射
的值在哪个存储槽。见我的文章 关于映射
,以更好地理解如何计算/计算映射的存储槽)。
通过SLOAD
从存储空间加载值。
你可以在这里看到存储版本的getter函数堆栈的所有细节说明 。
然后出现的下一个问题是: 为什么内存的getter函数底层字节码指令中多了17条?
答案就在上面的汇编代码的描述注释中。这17条额外的EVM指令执行以下内容:
mload
+ mstore
)SLOAD
从合约存储中加载,然后通过MSTORE
写入内存。你可以在这里看到内存版的getter堆栈的所有细节的说明。
我推荐来吃Rob Hitchens这篇文章"Solidity中的存储指针:'这里有龙'"
以更好地理解使用setter函数的行为:
// gas: 50,755 (1st storage write)
function setItemUnitsStorage(uint256 _id, uint256 _units) public {
Item storage item = items[_id];
item.units = _units;
}
// gas: 27,871
function setItemUnitsMemory(uint256 _id, uint256 _units) public {
Item memory item = items[_id];
item.units = _units;
}
当为一个变量指定storage
并设置其值时,它将修改状态变量并覆盖合约存储。这将使用SSTORE
操作码。
相反,如果使用关键字memory
,它将仅仅覆盖本地变量,而不覆盖合约存储。这将使用MSTORE
操作码。
当涉及到EVM指令集时,我们可以从下面的片段中看到,使用storage
产生的指令要少很多(storage
产生28条,而memory
产生48条)。然而,使用storage
将花费更多的Gas,因为存储读/写是EVM中最昂贵的操作。
; setItemUnitsStorage = 28 instructions
PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
SWAP1
SSTORE
POP
POP
POP
POP
; setItemUnitsMemory = 46 instructions
PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40
MLOAD
DUP1
PUSH1 20
ADD
PUSH1 40
MSTORE
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD
DUP2
MSTORE
POP
POP
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
DUP2
MSTORE
POP
POP
POP
POP
POP
映射
可以作为参数传递给函数或定义在函数体内。然而,它们是一个边缘案例,有两个特定的规则:
storage
数据位置,作为对已经存在于合约存储中的映射的引用。这是因为映射不能被动态地创建。它们只能被定义在函数体内,作为对已经作为状态变量存在的映射的引用。
如果你在Remix中这样写...
这就是你将得到的错误。
你应该使用storage
, memory
, 还是calldata
取决于你在合约中试图做什么。
对于某些数据类型,如果数据很大,把它们从存储空间复制到内存中可能会很昂贵。
然而,在某些情况下,如果你想在函数中覆盖变量,而不把结果传播到合约存储中,这可能是必要的。
另一方面,calldata
提供了比内存更多的好处:
内存
中(从而在之后不得不从内存中加载)。calldata
是一个只读的数据位置,它可以确保数据不能被修改。这可能会增加安全性,特别是当传递给函数的参数代表 敏感
数据(例如64字节的签名)时。calldata
在 Solidity 文档中确实被推荐:
来源:https://learnblockchain.cn/docs/solidity/types.html#data-location
然而,在某些情况下,使用memory
而不是calldata
可以提高可组合性
最后,请注意,在你的函数中不使用适当的数据位置会导致潜在的错误和漏洞。一个现实世界的例子是对Cover协议的无限铸币攻击。
看看这个来自Cover协议的Blacksmith.sol
合约的代码片断:
对池变量所做的任何改变都是在内存中进行的,而不是传回合约存储。
关于这个错误的更多细节,请看Mudti Gupta的博文:https://mudit.blog/cover-protocol-hack-analysis-tokens-minted-exploit/
以太坊 Solidity: 内存与存储以及在本地函数中使用哪一种?"存储 "和 "内存 "数据位置之间的区别
All-About-Solidity/Data-Locations.md
本翻译由 Duet Protocol 赞助支持。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!