探索 EVM 能做什么?
你可以在 EVM 中读取和写入数据的位置 (2024 年 6 月)
本文最初发表于 Cyfrin 博客。
EVM 代表“以太坊虚拟机”。每当你在以太坊(或其他 EVM 链)上“做”任何事情时,运行 EVM 软件的不同节点会在其中启动一台虚拟机器(称为虚拟机)并运行机器级代码。
机器级代码有时被称为“汇编”、“操作码”或在其最低级别称为“十六进制”。
这串十六进制:
0x6080604052
是一组机器指令,转换为:
PUSH1 0x80
PUSH1 0x40
MSTORE
这些都是 EVM 操作码。正是这组操作码控制每个 EVM / 以太坊节点可以“做”什么。在我们的 Solidity 智能合约中,它们都被编译为这些操作码。
例如,以下 Solidity 代码:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract Hi {
function hi() public pure returns(uint256){
return 7;
}
}
编译为:
0x608060405234801561000f575f80fd5b5060af8061001c5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c8063a99dca3f14602a575b5f80fd5b60306044565b604051603b91906062565b60405180910390f35b5f6007905090565b5f819050919050565b605c81604c565b82525050565b5f60208201905060735f8301846055565b9291505056fea264697066735822122020880945bedabcb839ddd572248fb2e38887216fb2a960d7f7f07c0bd9071fe864736f6c63430008180033
正是这些字节码决定了我们如何与代码库交互。
所以,让我们看看 EVM 能做什么,它可以读取和写入到哪里,以及我们如何利用这一点作为开发人员。
要真正理解本文,我们建议你首先了解什么是位和字节 。
你可能在 Solidity 中见过这个错误:
function doStuff(string stuff) public {
// 上述代码将无法编译,抛出错误:
// TypeError: Data location must be "memory" or "calldata" for parameter in
// function, but none was given
并且它将无法编译,错误为:
TypeError: Data location must be memory or calldata for parameter in function,
but none was given
为什么会出现这个问题?
什么是 memory 或 calldata?
最后,为什么这张图片代表 EVM?
原始图片来自 @pcaversaccio
如果你想全面了解底层发生的事情,请务必查看 Cyfrin Updraft Assembly and Formal Verification 课程,该课程比我们在此处涵盖的内容更深入。
evm.codes 网站在跟踪 EVM 操作码及其功能方面做得很好。
请注意,此信息截至 2024 年 6 月 3 日是准确的 , EVM 正在不断改进。
让我们深入探讨。
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 {
// 当有人调用 oStuff 时,此变量被添加到栈上
// 由于它在栈上,函数调用结束后,或
// 交易结束后,栈被删除,因此
// 变量 7 也被删除
uint256 myNumber = 7;
}
栈是存储和检索数据的最便宜的地方(就 gas 而言),并且是 EVM 中唯一可以对数据进行操作的地方,例如加法、减法、乘法、左移等。然而,它并不总是存储数据的最佳位置。
注意:栈在调用上下文结束时技术上被删除,但你可以在 evm.codes 阅读更多相关信息。目前,只需假设交易结束时栈被销毁。我们将在本文的瞬态存储部分解释调用上下文。
现在,下一个临时数据位置将是内存。内存与栈一样,在交易结束后被删除。有时栈不足以放置数据,因此我们使用内存。
uint8[3] memory myArray = [1,2,3]
例如,数组无法放入栈中。对于数组,我们需要存储每个元素和数组长度。因此,在底层,我们调用 MSTORE 操作码将数据存储在 EVM 的内存数据结构中。你可以稍后通过调用 MLOAD 操作码从内存中读取。
你会注意到,为了在内存数组中存储任何内容,我们需要首先将对象放到栈上。这是将数据存储在内存中比存储在栈上 gas-wise 更昂贵的原因之一。还有其他原因,包括内存扩展 gas 成本 ,你可以通过此链接了解更多信息。
PUSH1 0x1
PUSH0 // 将 0 推入堆栈
MSTORE // 这会导致 0x1 被存储在内存位置 0x0
内存和堆栈一样,在调用上下文结束后会被删除(如果这让你困惑,可以假设“调用上下文”就是交易。这是个小谎言,但为了学习,这没关系)。
记住这些,当我们谈论calldata时,因为在那时我们将讨论为什么我们在开始时看到那个错误:数据位置必须是“memory”或“calldata”。
函数内部的变量,比如 uint256 myNumber = 7,总是首先设置为堆栈变量,并且根据编译器的不同,它们也可能存储在内存中。函数外部的变量,也就是“状态变量”,存储在storage中。
现在与内存和堆栈不同,存储是永久存储的。当你将数据存储为状态变量时,它将被永久存储。这就是为什么当你在 Solidity 中创建一个公共变量时,你可以通过调用函数来“获取”该值。然而,在函数中创建变量会将其设置为临时变量(在内存中或仅在堆栈上)。
contract MyContract{
uint256 myStorageVar = 7; // 这是在存储中
function doStuff() public {
uint256 myStackVar = 7; // 这是在堆栈上
}
}
将对象存储到存储中使用与内存相同的操作码设置,只是我们使用 SSTORE 和 SLOAD 而不是 MSTORE 或 MLOAD。
上面的代码与 myStorageVar 可能会编译成一串看起来像这样的操作码:
PUSH1 0x7
PUSH0
SSTORE // 这将数字 7 存储在存储槽 0
将数据存储到存储中是 EVM 中存储数据的最昂贵方式(就 gas 而言)。由于我们是永久存储数据,所有 EVM 节点必须在交易结束后仍然保留数据。由于所有节点都需要做这种“额外的工作”来永久存储数据,因此它们增加了运行所需的 gas 量。
大多数情况下,存储比内存、堆栈、瞬态存储和 calldata 简单得多。所以让我们进入一些更有趣的地方。
现在 calldata 有点难以定义,因为它是一个有点过载的术语。当我们提到 calldata 时,我们指的是以下两种方式之一:
根据 evm.codes,calldata(作为 EVM 概念)是:
calldata 区域是作为智能合约交易的一部分发送到交易的数据。例如,在创建合约时,calldata 将是新合约的构造函数代码。calldata 是不可变的,可以通过指令
CALLDATALOAD
、CALLDATASIZE
和CALLDATACOPY
读取。
每当我们调用一个函数时,我们以 calldata 的形式向合约发送数据。因此,当 EVM 需要读取我们发送给合约的数据时,它从 calldata 中读取。例如,在 foundry / cast 中,我可以通过定义我的 calldata 来发送交易。或者如果我从 Metamask 发送交易,我可以通过检查十六进制选项卡查看正在发送的 calldata。
这与 Solidity 关键字 calldata 基本相同,但当提到 Solidity calldata 关键字时,我们可以使定义更简单。在 Solidity 中,只有函数参数可以被视为 calldata,因为只有函数可以用 calldata 调用。
一旦在交易中发送,calldata 就不能更改。它必须存储在另一个数据结构中(如堆栈、内存、存储等)以进行操作。
现在我们已经了解了 calldata 和内存,让我们回到我们在开始这篇文章时遇到的错误。
function doStuff(string stuff) public {
// 上面的代码将无法编译,抛出一个错误:
// TypeError: 数据位置必须是"memory"或"calldata"对于函数中的参数,但没有给出
在我们的函数 doStuff 中,我们需要告诉 solidity 编译器我们应该如何处理字符串 stuff 对象。字符串 stuff 对象是 solidity 中的一个特殊对象,一个字符串。字符串实际上是字节数组对象。 由于它们是数组,它们可能大于 32 字节,因此它们不能放在堆栈上。因此我们需要告诉 solidity 编译器传入的数据将存储在内存中还是 calldata 中。
如果是内存:
如果是 calldata:
每当我们从区块链外部调用一个函数(例如,调用 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 转换为内存
// calledFromFunctionCalldata(myArray);
}
这种区别很重要,因为它涉及到许多 gas 的权衡,并告诉编译器在哪里查找数据。
calldata 在交易或调用上下文结束后被删除,可以被视为像堆栈和内存一样的临时数据位置。
根据 EIP-1153,现在有一个额外的位置,像存储一样,但在交易结束后被删除,使其成为另一个临时存储位置。然而,与堆栈、内存和 calldata 不同,它们在调用上下文结束后被删除,瞬态存储在交易结束后被删除。让我们了解“调用上下文”或“调用上下文”是什么,以理解这一点。
每当在交易中调用一个函数(外部函数调用或内部调用)时,就会创建一个新的“调用上下文”。在上图中,你可以看到我们已经高亮显示了被视为“调用上下文”的区域,其中包括:
译者注:原文作者的表述可能是不对的, 根据文档 内部调用是在同一个 EVM环境下执行的,内部调用是可以传递内存变量的应用的。
本质上,这些是为函数存储和操作数据而隔离的环境。这也是为什么两个函数不能访问彼此的变量。
在下面的例子中,这就是为什么这两个函数可以有完全相同的变量名,但它们永远不会重叠。每当你调用 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;
不可变和常量变量直接存储在合约代码中,永远无法更改。* 根据 solidity 文档 :
编译器生成的合约创建代码将在返回之前通过用分配给它们的值替换所有对不可变变量的引用来修改合约的运行时代码。
这就是为什么这些值无法更改,它们存储在合约字节码本身中。
* 合约只能通过 SELFDESTRUCT 操作码删除,然后该合约可以在以后替换。然而,该操作码存在争议,并计划在某个时候移除。自 EVM cancun 升级以来,这仅在同一交易中可能,根据_ EIP-6780。
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”的编译器错误!
要学习智能合约安全性和开发,请访问 Cyfrin Updraft。
要为你的智能合约项目请求安全支持/安全审查,请访问 Cyfrin.io 或 CodeHawks.com。
要了解更多关于智能合约中报告的顶级攻击,请务必学习 Solodit。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!