深入了解Solidity数据位置 - Calldata

深入了解Solidity数据位置 - Calldata

理解Solidity中以太坊交易的 "data" 字段

img

这是深入Solidity数据存储位置系列的第三篇

今天,我们将学习 calldata的特殊性,以及为什么你应该优先使用它而不是其他数据位置,如 memory。我们将使用Gnosis Safe合约中的代码例子来理解与calldata相关的三个EVM操作码:

介绍

如果你熟悉web3.js或ethers.js,你可能看过使用.send({ ... }).sendTransaction({ ... })时作为参数传递的data字段。

这是calldata(简称),或 "随着消息调用发送的数据"(无论我们用的是 staticcall、合约调用,还是任何改变状态(区块链状态或合约状态)的实际交易,在这里都不重要)。

相比之下,一个消息调用交易包含:数据:一个无限大小的字节数组,指定消息调用的输入数据,正式名称为Td。

黄皮书上,对calldata的解释?(第21页,第4.2节 > 交易 > 数据)。

calldata是EVM中的一个特殊数据位置。它指的是在两个地址之间的任何消息调用交易中发送的原始十六进制字节。对于EVM来说,calldata中包含的任何数据都是由一个地址(无论是EOA还是智能合约)作为输入来执行调用(外部调用)。

当调用一个合约(无论是从EOA账号还是另一个合约)时,calldata 是保存被调用函数的初始输入参数(=参数)数据的位置。这是 "public"或 "external" 函数的参数存储的地方。

对于其他编程语言,EVM中的calldata 与之类似:

  • C++中的动态内存(添加引用)
  • C#中的堆内存(添加引用)

Calldata的布局

Calldata是由字节组成的,以与内存相同的方式连续布局。这与其他数据位置的布局相反,如存储或堆栈,它们是由字(32字节长)组成的。

在EVM中,Calldata是一个可由字节编址的空间,类似于EVM的内存。各种类型的变量在calldata中的布局方式与它们在内存中的布局方式非常相似。

对于读取,calldata的行为方式与内存相同:你可以一次加载32个字节(mloadcalldataload)。然而,它的行为与内存不同,因为你不能向它写入。

Calldata是一个对任何基于EVM的区块链都非常特殊的数据位置,有一些布局的特殊性:

  • 前4个字节对应于函数签名的选择器。
  • 其余的字节对应于函数的输入参数。每个输入参数总是32字节长。如果它的类型小于32字节,参数会被填充。

注意:输入参数根据其类型被填充在右边或左边(例如,uintNaddress被填充在左边,而bytesN被填充在右边)。

Calldata的基础知识

Calldata经常与memory混淆,或者被认为是 内存中的一个特定位置。Calldata与内存不同,因为它是一个独立的数据位置。为了理解它与 内存的区别,我们必须理解它的目的,但主要是它来自哪里。

要理解 "calldata" 与 "memory" 的区别,一个好的问题是问 "谁在calldata中创建数据?"与 "谁在内存中创建数据?" ("创建 "和 "分配 "这两个词在这里可以互换使用)。

这个来自以太坊 Stack Exchange的优秀答案有助于做出明确的区分。

思考(calldatamemory之间)的区别以及它们应该如何使用的一个好方法是,calldata是由调用者分配的,而memory是由被调用者分配。

这句话非常有力,总结得非常好。让我们把它放在背景中。当从EOA或一个源(Source)合约中调用一个目标(Target)合约时。

  • 调用者(=无论是EOA还是合约)是创建要发送给目标合约的数据的人。这个数据被分配在calldata中,并通过消息调用发送给目标。
  • 被调用者(= Target合约)消耗calldata并使用内存做进一步处理。被处理的数据可以从calldata中加载,也可以从自己的存储中加载。

现在让我们来看看calldata的主要特征。calldata是一个存放交易或调用的数据参数的数据位置。

  • 不可修改(只读)= 你不能修改或改变calldata中的数据。它不能被改写。
  • 大小几乎无限 = 大小几乎无限,没有固定的边界。
  • 非常便宜和节省Gas = 读取+分配calldata中的字节是非常便宜和节省Gas的。
  • 非持久性(在交易完成后不再有效)
  • 用于具体到交易和合约调用。

Calldata是不可修改的

让我们先了解Solidity中calldata的一个最重要的特性

"存储在calldata中的数据是不可更改的。"

当涉及到Solidity中的calldata时,这是需要理解的最重要的概念之一。

Harry Altman在他一篇非常深入的文章"Solidity中的数据表示,在一个复杂的句子后面陈述了一个关于calldata的重要事实:

[......]因此我们会说 "calldata不能直接包含值类型"这样的话,只是因为Solidity不允许人们声明一个值类型的calldata变量(calldata中的原始值在使用前总是被复制到堆栈中)。显然,这个值仍然存在于calldata中,但是由于没有变量指向那里,所以这不是我们关心的问题。

对我们来说,重要的部分在括号之间。"calldata中的原始值在使用前总是会被复制到堆栈中"。

从这句话中,我们可以推断出三件事:

  1. calldata是不可改变的:我们不能修改里面的数据。

也因此:

  1. calldata是只读的

  2. 当从calldata中读取数值时,这些数值被复制到堆栈中。

calldata是不可变的这一事实也导致了我们在Solidity中只能通过引用来访问calldata,使用calldata关键字。

任何以calldata作为数据位置指定的复杂类型的变量都是只读的。该变量不能被修改。这适用于变量作为函数参数传递或在函数体中定义。

让我们通过下面的Solidity代码片断来看看它的实际情况。如果你在Remix中粘贴这段代码,Solidity 编译器会对指定为calldatainput变量给出错误提示,不允许你编辑它们:

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客户端源代码中的类型推断出来。

相比之下,一个消息调用交易包含:data:一个无限大的字节数组,指定消息调用的输入数据,形式为Td。

黄皮书对calldata有什么说法?(第21页,第4.2节 > 交易 > 数据)。

img

来源:geth客户端源代码(Github)--core/types/transaction.go 第50-59行

img

来源: geth 源代码 (Github) - core/types/transaction.go, line 77

这意味着,在某种程度上,"calldata可以根据需要容纳多少字节"。然而,从技术上讲,calldata,像内存一样,也将被约束在区块 Gas limit 的限制下。

然而,在 calldata 中分配更多字节的成本总是线性的,相比之下,内存的成本随着内存大小的增长而呈平方增长。我们将在下一小节中看到这一区别。

Calldata非常便宜而且节省Gas

尽管calldata是只读的,你不能对它进行写入,但它仍然有一个成本。但是,与其他数据位置相比,这种成本在 Gas方面是相对便宜的。

calldata的每个字节都有一个成本:

  • 零字节0x00 需要 4 Gas
  • 非零字节 需要 16 Gas

img

来源: 以太坊黄皮书,附录G,费用表(柏林版,第27页)

注意:非零字节的Gas成本随着EIP 2028 - 交易数据Gas成本降低而改变。降低calldata的Gas成本的目的是为了增加链上的可扩展性。由于calldata更便宜,每个交易中可以容纳更多的calldata字节,一般来说,一个区块中可以容纳更多的数据(即EIP的作者提到的 "更高的calldata带宽")。

EIP 2028 鼓励第二层可扩展性解决方案。就像洋葱的层,耗gas的操作(存储读/写+计算)被移到外层(链外),并引入数据提交。其形式是证明系统/欺诈证明(在一个证明tx中批处理多个交易),或者通过calldata将数据放在主链上。

请继续关注! 我们将在本文后面的第2层的背景下研究calldata :)

同样,操作码CALLDATALOAD从calldata中读取一个32字节的字,从calldata加载到堆栈,只需要3个Gas。

相比之下,使用MLOAD操作码从内存中读取的成本取决于内存的当前大小和内存扩展成本

Calldata是针对外部调用的 -- 以Gnosis为例

img

来源: cryptoouf.com

如前所述,calldata最常指的是来自外部交易(EOAs)或合约调用的数据。它只针对消息调用,而不是合约创建交易(我们将在下面的 构造函数中的calldata一节中看到这一区别。

让我们用一个流行项目的Solidity代码中的例子:以来自 Gnosis 的Gnosis-Safe 为例

Gnosis-Safe有一个setUp(...)函数来初始化保险箱的存储。你可以看到第一个参数是一个所有者数组address [] ,谁拥有这个保险箱。这些都是用数据位置calldata指定的,因为这个函数被定义为external

img

代码来源:GnosisSafe.sol(Github.com)

在 Solidity 中访问 calldata

让我们先看一下黄皮书。被定义为执行环境的calldata包含以下字段。

![...

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

点赞 2
收藏 3
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO