深入了解Solidity数据位置 - Calldata
理解Solidity中以太坊交易的 "data" 字段
这是深入Solidity数据存储位置系列的第三篇
今天,我们将学习 calldata
的特殊性,以及为什么你应该优先使用它而不是其他数据位置,如 memory
。我们将使用Gnosis Safe合约中的代码例子来理解与calldata
相关的三个EVM操作码:
如果你熟悉web3.js或ethers.js,你可能看过使用.send({ ... })
或.sendTransaction({ ... })
时作为参数传递的data
字段。
这是calldata(简称),或 "随着消息调用发送的数据"(无论我们用的是 staticcall
、合约调用,还是任何改变状态(区块链状态或合约状态)的实际交易,在这里都不重要)。
黄皮书上,对calldata的解释?(第21页,第4.2节 > 交易 > 数据)。
calldata是EVM中的一个特殊数据位置。它指的是在两个地址之间的任何消息调用交易中发送的原始十六进制字节。对于EVM来说,calldata中包含的任何数据都是由一个地址(无论是EOA还是智能合约)作为输入来执行调用(外部调用)。
当调用一个合约(无论是从EOA账号还是另一个合约)时,calldata 是保存被调用函数的初始输入参数(=参数)数据的位置。这是 "public"或 "external" 函数的参数存储的地方。
对于其他编程语言,EVM中的calldata 与之类似:
Calldata是由字节组成的,以与内存相同的方式连续布局。这与其他数据位置的布局相反,如存储或堆栈,它们是由字(32字节长)组成的。
在EVM中,Calldata是一个可由字节编址的空间,类似于EVM的内存。各种类型的变量在calldata中的布局方式与它们在内存中的布局方式非常相似。
对于读取,calldata的行为方式与内存相同:你可以一次加载32个字节(mload
与calldataload
)。然而,它的行为与内存不同,因为你不能向它写入。
Calldata是一个对任何基于EVM的区块链都非常特殊的数据位置,有一些布局的特殊性:
注意:输入参数根据其类型被填充在右边或左边(例如,uintN
或address
被填充在左边,而bytesN
被填充在右边)。
Calldata经常与memory
混淆,或者被认为是 内存中的一个特定位置
。Calldata与内存不同,因为它是一个独立的数据位置。为了理解它与 内存
的区别,我们必须理解它的目的,但主要是它来自哪里。
要理解 "calldata" 与 "memory" 的区别,一个好的问题是问 "谁在calldata中创建数据?"与 "谁在内存中创建数据?" ("创建 "和 "分配 "这两个词在这里可以互换使用)。
这个来自以太坊 Stack Exchange的优秀答案有助于做出明确的区分。
思考(
calldata
和memory
之间)的区别以及它们应该如何使用的一个好方法是,calldata
是由调用者分配的,而memory
是由被调用者分配。
这句话非常有力,总结得非常好。让我们把它放在背景中。当从EOA或一个源(Source)
合约中调用一个目标(Target)
合约时。
源
合约)是创建要发送给目标
合约的数据的人。这个数据被分配在calldata中,并通过消息调用发送给目标。Target
合约)消耗calldata并使用内存做进一步处理。被处理的数据可以从calldata中加载,也可以从自己的存储中加载。现在让我们来看看calldata的主要特征。calldata是一个存放交易或调用的数据参数的数据位置。
让我们先了解Solidity中calldata
的一个最重要的特性
"存储在calldata中的数据是不可更改的。"
当涉及到Solidity中的calldata时,这是需要理解的最重要的概念之一。
Harry Altman在他一篇非常深入的文章"Solidity中的数据表示,在一个复杂的句子后面陈述了一个关于calldata的重要事实:
[......]因此我们会说 "calldata不能直接包含值类型"这样的话,只是因为Solidity不允许人们声明一个值类型的calldata变量(calldata中的原始值在使用前总是被复制到堆栈中)。显然,这个值仍然存在于calldata中,但是由于没有变量指向那里,所以这不是我们关心的问题。
对我们来说,重要的部分在括号之间。"calldata中的原始值在使用前总是会被复制到堆栈中"。
从这句话中,我们可以推断出三件事:
也因此:
calldata是只读的
当从calldata中读取数值时,这些数值被复制到堆栈中。
calldata是不可变的这一事实也导致了我们在Solidity中只能通过引用来访问calldata,使用calldata
关键字。
任何以calldata
作为数据位置指定的复杂类型的变量都是只读的。该变量不能被修改。这适用于变量作为函数参数传递或在函数体中定义。
让我们通过下面的Solidity代码片断来看看它的实际情况。如果你在Remix中粘贴这段代码,Solidity 编译器会对指定为calldata
的input
变量给出错误提示,不允许你编辑它们:
pragma solidity ^ 0.8 .0;
contract AllAboutCalldata {
function manipulateMemory(string memory input) public pure returns(string memory) {
// 可以修改用 'memory' 位置的参数
// you can add data in the string
input = string.concat(input, " - All About Solidity");
// you can change the whole string
input = "Changed to -> All About Memory!";
return input;
}
function manipulateCalldata(string calldata input) external pure returns
(string calldata) {
//不可以修改用 'calldata' 位置的参数
// you cannot add or edit data in the string
// TypeError: Type string memory is not implicitly convertible to expected
type string calldata.
input = string.concat(input, " - All About Solidity");
// you CANNOT change the whole string
// Type literal_string "..." is not implicitly convertible to expected type
string calldata.
input = "Cannot change to -> All About Calldata!";
return input;
}
}
Calldata的大小几乎没有限制。
Calldata比内存有一个额外的好处:它的大小。
内存有一个最大的尺寸边界。它最多可以容纳2 ** 64字节(= uint64的最大值)。
相比之下,calldata的大小几乎是无限的。这在黄皮书中都有描述,也可以从geth客户端源代码中的类型推断出来。
黄皮书对calldata有什么说法?(第21页,第4.2节 > 交易 > 数据)。
这意味着,在某种程度上,"calldata可以根据需要容纳多少字节"。然而,从技术上讲,calldata,像内存一样,也将被约束在区块 Gas limit 的限制下。
然而,在 calldata 中分配更多字节的成本总是线性的,相比之下,内存的成本随着内存大小的增长而呈平方增长。我们将在下一小节中看到这一区别。
尽管calldata是只读的,你不能对它进行写入,但它仍然有一个成本。但是,与其他数据位置相比,这种成本在 Gas方面是相对便宜的。
calldata的每个字节都有一个成本:
0x00
需要 4 Gas 注意:非零字节的Gas成本随着EIP 2028 - 交易数据Gas成本降低而改变。降低calldata的Gas成本的目的是为了增加链上的可扩展性。由于calldata更便宜,每个交易中可以容纳更多的calldata字节,一般来说,一个区块中可以容纳更多的数据(即EIP的作者提到的 "更高的calldata带宽")。
EIP 2028 鼓励第二层可扩展性解决方案。就像洋葱的层,耗gas的操作(存储读/写+计算)被移到外层(链外),并引入数据提交。其形式是证明系统/欺诈证明(在一个证明tx中批处理多个交易),或者通过calldata将数据放在主链上。
请继续关注! 我们将在本文后面的第2层的背景下研究calldata :)
同样,操作码CALLDATALOAD
从calldata中读取一个32字节的字,从calldata加载到堆栈,只需要3个Gas。
相比之下,使用MLOAD
操作码从内存中读取的成本取决于内存的当前大小和内存扩展成本。
来源: cryptoouf.com
如前所述,calldata
最常指的是来自外部交易(EOAs)或合约调用的数据。它只针对消息调用,而不是合约创建交易(我们将在下面的 构造函数中的calldata
一节中看到这一区别。
让我们用一个流行项目的Solidity代码中的例子:以来自 Gnosis 的Gnosis-Safe 为例
Gnosis-Safe有一个setUp(...)
函数来初始化保险箱的存储。你可以看到第一个参数是一个所有者数组: address []
,谁拥有这个保险箱。这些都是用数据位置calldata
指定的,因为这个函数被定义为external
:
代码来源:GnosisSafe.sol(Github.com)
让我们先看一下黄皮书。被定义为执行环境的calldata包含以下字段。
![...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!