如何修复“数据位置必须是内存或 calldata” - Cyfrin

  • cyfrin
  • 发布于 2025-03-17 10:22
  • 阅读 10

本文深入探讨了以太坊虚拟机(EVM)的数据存取机制,阐明了不同数据位置(如stack、memory、storage、calldata和transient storage)的性质与用途,及其与Solidity编程的相关性。文章不仅解释了Solidity中常见错误的原因,还提供了丰富的代码示例和图示,帮助开发者理解EVM内部工作原理。

如何修复 ‘数据位置必须是内存或 calldata’ | EVM 可以读写数据的地方?

了解 EVM 可以从哪些地方读写数据,什么是 calldata、内存和存储,以及编写 Solidity 或 Vyper 智能合约时需要知道的最佳实践

引言

你可能在 Solidity 中见过这个错误:

function doStuff(string stuff) public {
// 上述代码无法编译,抛出错误信息为:
// TypeError: 数据位置必须是 "memory" 或 "calldata",
// 参数未设置数据位置

为什么会得到这个错误?

什么是 "Solidity" 内存或 calldata?

最后,为什么这张图片代表 EVM?

来自 @pcaversaccio

如果你想全面了解底层发生了什么,务必查看 Cyfrin Updraft Assembly and Formal Verification 课程,它会深入探讨我们在这里要涵盖的内容。

evm codes 网站很好地保持了对 EVM 操作码及其作用的最新信息。

为了真正理解这篇文章,我们建议你首先了解什么是 Bits and Bytes

让我们深入探讨。

以太坊虚拟机 (EVM) 可以在哪些地方访问数据?

EVM 可以从以下位置 读取和写入 数据:

  • 堆栈
  • 内存
  • 存储
  • 临时存储
  • Calldata
  • 代码
  • 返回数据

EVM 可以 写入但不能读取 数据至以下位置:

  • 日志

EVM 可以 读取但不能写入 数据于以下位置:

  • 交易数据
  • 链数据
  • 燃油数据
  • 其他几个超具体的地方

EVM 堆栈

在 EVM 世界中,堆栈是一种数据结构,项目只能从顶部添加或移除。它有两个主要操作:

  • push:添加到堆栈顶部
  • pop:从堆栈顶部移除

在这方面,你可以将堆栈想象成一堆煎饼。

在 Solidity 或 Vyper 中,大多数情况下,每当你创建一个变量,实际上是在将一个对象放置到堆栈上:

uint256 myNumber = 7;

这将在堆栈上放置一个临时变量,并使用 PUSHX 操作码,其中数字 7 被“推入”堆栈中。

PUSH1 0x7 //0x7 在十六进制中是 7

当 EVM 看到这个,它会自动将 7 转换为 32 字节的形式,前面加上一些 0。

只有当对象小于 32 字节时,才能将其“推入”堆栈。 7 在 32 字节中表示为:

0x0000000000000000000000000000000000000000000000000000000000000007

堆栈目前的最大限制是 1024 个值,所以在我们的煎饼示例中,就是“1024 个煎饼”。这就是为什么许多 Solidity 开发者会遇到臭名昭著的“堆栈太深”错误,因为他们的 Solidity 代码导致堆栈中的变量过多。

堆栈是临时的,堆栈上的对象在交易完成后被销毁。因此,当你在 Solidity 或 Vyper 中创建一个变量时,它不会在交易结束后保持存在(*从技术上讲是调用上下文)。这就是为什么堆栈被删除的原因。

function doStuff() public {
    // 当有人调用 `doStuff` 时,这个变量被添加到堆栈
    // 由于它在堆栈上,因此在函数调用结束后,
    // 或者交易结束时,堆栈被删除,因此变量 7 也被删除
    uint256 myNumber = 7;
}

堆栈是存储和检索数据的最便宜地方(以油费计),也是 EVM 唯一可以对数据进行操作的地方,例如加法、减法、乘法、左移等。然而,它并不总是存储数据的最佳地方。

*注意:技术上来说,堆栈在调用上下文结束时被删除,但你可以在 evm.codes 中查看更多关于它的内容。现在只需假设,在交易结束时,堆栈会被销毁。在这篇文章的临时存储部分,我们会解释调用上下文。

EVM 内存

现在,下一个 临时数据位置 将是内存。内存就像堆栈一样,在交易结束后会被删除。当堆栈不足以放置数据时,我们就会使用内存。

让我们看看以下 Solidity 代码:

uint8[3] memory myArray = [1,2,3];

例如,数组将无法放入堆栈。对于数组,我们需要存储每个元素和数组长度。因此,在底层,我们调用 MSTORE 操作码以将数据存储在 EVM 的内存数据结构中。稍后,你可以通过调用 MLOAD 操作码从内存中读取。

你会注意到,要在内存数组中存储任何内容,首先需要将对象放在堆栈上。这是将数据存储在内存中比存储在堆栈中更耗气的原因之一。还有其他原因,包括 内存扩展 gas 费用,你可以通过 此链接 了解更多。

PUSH1 0x1
PUSH0       // 将 0 推入堆栈
MSTORE      // 这导致 0x1 被存储在内存的位置 0x0 上

内存在调用上下文结束后也会被删除(如果这让你感到困惑,只需假设“调用上下文”意味着交易。虽然有点不准确,但对于学习来说是可以的)。

请考虑这些事项,当我们谈论 calldata 时,因为在那里我们将讨论为什么我们最开始看到该错误:数据位置必须是 "memory" 或 "calldata"。

函数内部的变量,如 uint256 myNumber = 7,一开始总是作为堆栈变量设置的,具体取决于编译器,它们可能也会存储在内存中。在函数外部的变量,即“状态变量”,存储在 存储 中。

EVM 存储

与内存和堆栈不同,存储是 永久性 的。当你将数据存储为 状态变量 时,数据将永久存储。这就是为什么在 Solidity 中创建公共变量时,你能够通过调用函数来“获取”该值。然而,在函数中创建的变量被设置为临时变量(在内存或堆栈中)。

contract MyContract {
    uint256 myStorageVar = 7;     // 此变量存储在存储中

    function doStuff() public {
        uint256 myStackVar = 7; // 此变量在堆栈中
    }
}

将对象存储到存储中使用与内存相同的操作码设置,而不是 MSTORE 或 MLOAD,我们使用 SSTORE 和 SLOAD。

上面的代码中 myStorageVar 最终编译为一串类似于以下内容的操作码:

PUSH1 0x7
PUSH0
SSTORE        // 这在存储槽 0 中存储数字 7

将数据存储到存储中是存储数据的 最耗气 的方法。在将数据永久存储时,所有 EVM 节点必须 在交易结束后也保持数据持久性。由于所有节点都需要执行这项“额外工作”以永久存储数据,因此它们会增加运行的Gas费用。

在大多数情况下,存储比内存、堆栈、临时存储和 calldata 更简单。因此,让我们讨论一些更有趣的地方。

EVM Calldata

现在,calldata 的定义有点复杂,因为它是一个被过度使用的术语。当我们提到 calldata 时,我们是指两个意思之一:

  • Solidity 关键字 calldata
  • EVM 概念的 calldata

根据 evm.codes,calldata(作为 EVM 概念)是:

calldata 区域是作为智能合约交易的一部分发送到交易的数据。例如,创建合约时,calldata 将是新合约的构造函数代码。calldata 是不可变的,可以使用 CALLDATALOAD、CALLDATASIZE 和 CALLDATACOPY 指令进行读取。

每当我们调用一个函数时,我们以 calldata 的形式将数据发送给合约。因此,当 EVM 需要读取我们发送给合约的数据时,它会从 calldata 中读取。例如,在 foundry / cast 中,我可以定义我的 calldata 来发送交易。或者,如果我从 Metamask 发送交易,我可以通过查看十六进制选项卡看到正在发送的 calldata。

一个示例 calldata。

这实际上与 Solidity 关键字 calldata 相同,但在提到 Solidity calldata 关键字时,我们可以使定义更加简化。在 Solidity 中,只有函数参数可以被视为 calldata,因为只有函数可以用 calldata 被调用。

一旦在交易中发送,calldata 不能被更改。它必须存储在另一个数据结构中(如堆栈、内存、存储等)才能进行操作。

现在我们已经了解了 calldata 和内存,让我们回到开始时的错误。

function doStuff(string stuff) public {
// 上述代码无法编译,抛出错误信息为:
// TypeError: 数据位置必须是 "memory" 或 "calldata" 为参数
// 函数中未设置数据位置

在我们的 function doStuff 中,我们需要告诉 Solidity 编译器如何处理字符串 stuff 对象。字符串 stuff 对象在 Solidity 中是一个特殊对象。字符串实际上是 bytes 数组对象。 由于它们是数组,因此它们可能大于 32 字节,因此无法放入堆栈。因此,我们需要告诉 Solidity 编译器传入的数据是存储在内存中还是 calldata 中。

如果是内存:

  • 我们可以对 stuff 对象进行操作(添加到字符串,保存新的字符串等)
  • 我们可以使用存储在内存或 calldata 中的数据调用 doStuff 函数

如果是 calldata:

  • 我们无法对 stuff 对象进行操作
  • 只能使用存储为 calldata 的数据调用 doStuff 函数

每当我们从区块链外部调用函数时(例如,调用 ERC20 合约的 transfer 并使用 Metamask 或其他浏览器钱包进行签名),该数据总是作为 calldata 发送。然而,如果一个合约调用另一个函数参数,则可以将数据作为 calldata 或内存发送。

Solidity 足够聪明,会将 calldata 转换为内存,通过将 calldata 存储到内存中,但不能将内存中的数据移动到 calldata 中。calldata 是原始交易的一部分,我们无法编辑原始交易数据。

// 让我们首先从 Metamask / 浏览器钱包调用此函数
function calledFromMetamask(uint256[] calldata myArray) public {
    // calldata -> calldata
    calledFromFunctionCalldata(myArray);
    // calldata -> memory
    calledFromFunctionMemory(myArray);
}

function calledFromFunctionCalldata(uint256[] calldata myArray) public {
    // calldata -> calldata -> memory
    calledFromFunctionMemory(myArray);
}

function calledFromFunctionMemory(uint256[] memory myArray) public {
    // 取消注释以下行将无法编译,因为我们已
    // 将 myArray 从 calldata 转换为 memory

    // calledFromFunctionCalldata(myArray);
}

这个区别很重要,因为涉及许多气体的权衡,并告诉编译器从哪里查找数据。

calldata 在交易或调用上下文结束后被删除,可以被视为临时数据位置,类似于堆栈和内存。

EVM 临时存储

根据 EIP-1153,现在有了一个额外的位置,像存储一样,但在交易结束后删除,使其成为另一个临时存储位置。然而,临时存储与堆栈、内存和 calldata 不同,后者在 调用上下文 结束后被删除,临时存储在交易结束后被删除。让我们学习什么是“调用上下文”或“call context”,以便理解这一点。

什么是调用上下文?

每当在交易中调用一个函数(外部函数调用或内部调用)时,都会创建一个新的“调用上下文”。在上面的图像中,你可以看到我们高亮显示的区域被视为“调用上下文”,它包括:

  • 程序计数器
  • 可用 gas
  • 堆栈
  • 内存

本质上,这是函数存储和操作数据的独立环境。这也是为什么两个函数无法访问彼此的变量的原因。

在下面的示例中,这就是为什么这两个函数可以拥有绝对相同的变量名称,但永远不会重叠。每当你调用 doStuff 或 doMoreStuff 时,它们将各自获得自己堆栈、内存、calldata 等的调用上下文。

function doStuff() public {
    uint256 myNumber = 7;
}

function doMoreStuff() public {
    uint256 myNumber = 8;
}

当达到 RETURN、STOP、INVALID 或 REVERT 操作码或交易回滚时,调用上下文便结束。

理解这一点后,我们现在可以回到了解临时存储。自 Solidity 版本 0.8.24 起,我们可以在 yul 中使用 TSTORE 和 TLOAD 操作码。

modifier nonreentrant(bytes32 key) {
        assembly {
            if tload(key) { revert(0, 0) }
            tstore(key, 1)
        }
        _;
        assembly {
            tstore(key, 0)
        }
    }

TSTORE 和 TLOAD 操作码的工作原理与 SSTORE 和 SLOAD 存储操作码完全相同,但数据不是永久存储,而是在整个交易期间存储,并在交易结束后删除。

在这篇文章底部,我们将有一个速查表来帮助说明这些差异。

代码

我们可以存储数据的最终地方是作为合约,即 EVM 的“代码”位置。这非常简单,这就是为什么使用 Solidity 中标记为 constant 和 immutable 的变量无法更改。

uint256 constant MY_VAR = 7;
uint256 immutable i_myVar = 7;

通过 immutable 和 constant 变量,它们被直接存储在合约代码中,这些代码是永远无法更改的。* 根据 solidity 文档

> 编译器生成的合约创建代码将在合约的运行时代码中进行修改,替换掉所有对不可变变量的引用,替换为分配给它们的值。

这就是为什么这些值不能被更改,因为它们直接存储在合约字节码中。

*合约只能通过 SELFDESTRUCT 操作码被删除,然后该合约可以在之后被替换。然而,这个操作码一直伴随争议,并计划在某个时点被移除。

EVM 数据结构速查表

数据结构 临时? 可修改? 何时被删除? 数据大小?
存储 它不会被删除 每个 SSTORE 32 字节,几乎无限量
内存 在调用上下文或交易结束后 每个 MSTORE 32 字节,几乎无限量
堆栈 在调用上下文或交易结束后 每个槽 32 字节,最大 1024 个值
临时存储 在交易结束后 每个 TSTORE 32 字节,几乎无限量
Calldata 在调用上下文或交易结束后 大小几乎无限
代码 它不会被删除 取决于特定 EVM 实现的合约大小限制

*只有在调用 SELFDESTRUCT 操作码时

取决于特定 EVM 实现的合约大小限制

返回数据

EVM 可以读取和写入的最后一个地方是返回数据位置。根据 evm.codes:

> 返回数据是智能合约在调用后返回值的方式。它可以通过 RETURN 和 REVERT 指令的合约调用进行设置,并可以通过 RETURNDATASIZE 和 RETURNDATACOPY 被调用合约读取。

本质上,每当你看到 return 关键字,这将创建 RETURN 操作码以将数据存储在返回数据位置。

function doStuff() public returns(uint256) {
    return uint256(7);
}

其他调用此数据的函数可以通过 CALL、STATICCALL、CREATE、DELEGATECALL 和一些其他操作码进行读取。返回数据有点奇特,调用 RETURN 操作码会结束当前调用上下文,然后将结果数据作为返回数据传递给父调用上下文。然后可以使用 RETURNDATASIZE 和 RETURNDATACOPY 访问这些数据。返回数据只有一条,通过调用这些操作码将返回最近结束的调用上下文的返回数据。返回数据不会持久存在,并且可以被子上下文通过调用 RETURN 操作码轻易覆盖。

这意味着在调用上下文中,确实只能有一个数据在其中。然而,这些数据可以大于 32 字节,因此可以将整个数组和其他大变量放入返回数据中。

写入但不读取

日志

日志是 EVM 中的一个存储位置,其代码纯粹是被写入的。在 Solidity 中,这是通过 emit 关键字完成的。

event myEvent();
emit myEvent();

读取但不写入

在 EVM 中,有很多地方可以读取数据。你可以在 Solidity 中看到这些示例:

msg.sender;
block.chainid;
blobhash(0);
gasleft();

以及许多 全局可用单位

总结

希望通过这些信息,你能更好地理解 EVM 的内部工作,以便做出更明智的决策!

关键是,你现在知道为什么你在 Solidity 中会看到那些常见的“堆栈太深”和“必须使用 calldata 或 memory”编译错误!

请注意,EVM 不断在改进,这些信息截至 2024 年 3 月 6 日是准确的。如果有任何信息似乎过时,请在推特上@cyfrinupdraft 或 @patrickalphac 联系我们。

  • 原文链接: cyfrin.io/blog/fixing-da...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.