EVM 是如何确认该调用哪个智能合约函数的?

本文深入探讨了Solidity函数分发器的工作原理,包括EVM的结构、存储、内存、瞬态存储、栈、calldata和程序计数器等关键组件。详细解释了智能合约如何从calldata中检索函数选择器,并将其与合约字节码中的函数选择器进行比较,最终跳转到相应的代码位置执行函数,如果未找到匹配项则执行revert操作。

Solidity 的函数分发器如何工作?

TrashPirate 的日志:汇编 & 形式化验证

即使是 Web3 的新手,你也可能已经转移了一个简单的 ERC20 代币,比如从你的钱包向你朋友的钱包转移了 10 个 USDC。你按了一个名为“发送”之类的按钮。但是当你执行这样的动作时,底层到底发生了什么?在本文中,我们将研究当你执行智能合约交互时发生的底层过程 —— 例如检索余额或转移 ERC20 代币。

要真正理解智能合约交互,我们需要对以太坊虚拟机 (EVM) 是什么以及它的结构有一个基本的了解。EVM 是使智能合约执行成为可能的软件。它是一个虚拟机器,可以执行一组指令。这些指令由以太坊客户端解释,由以太坊节点运行,这些节点也存储区块链的状态。参与以太坊网络的每个节点或计算机都会安装软件,即所谓的客户端,它可以将 EVM 指令转换为在本地计算机上执行的操作。

EVM 是一个栈式机器,由以下组件组成:

  • EVM 指令集 & 预编译合约(持久)
  • (账户)存储(持久)
  • 内存(在调用级别是易失的)
  • (在交易级别是易失的)
  • 程序计数器 & 可用 Gas(在交易级别是易失的)
  • 瞬时存储(在交易级别是易失的)
  • Calldata(交易级别数据)

存储和内存

数据可以存储在存储或内存中,但存储访问比内存贵得多(使用更多的 gas)。例如,用于在存储中存储数据的操作码 SSTORE 消耗 20000 gas(二级写入 —— “热访问” —— 消耗 2900 gas),而用于在内存中存储数据的 MSTORE 仅消耗 3 gas。

存储类似于键值存储,内存类似于 32 字节字的动态数组,在交易期间数据会被连接起来。空闲内存指针跟踪内存的长度,并标记新数据的位置。

瞬时存储 (EIP-1153)

瞬时存储是 EIP-1153 中引入的一种新的存储空间。它的功能类似于常规存储,但仅在交易期间存在。在交易结束时,它会被丢弃 —— 类似于计算机的 RAM。为了从瞬时存储中存储和检索数据,引入了操作码 TSTORETLOAD。这两个操作码每个仅消耗 100 gas,与 SSTORE 的至少 20000(冷)/ 2900(热)gas 和 SLOAD 的 2100(冷)/ 100(热)gas 相比,便宜得多。

栈是一种后进先出的数据结构,用于执行许多 EVM 操作。这意味着数据从内存或存储加载栈上,用于对其执行操作(如加法或减法),然后从栈加载(到内存或存储)。栈最多可以容纳 1024 个槽位(32 字节字)的数据。如果需要超过 1024 个槽位来执行操作 —— 那么,你就会从编译器收到可爱的 stack too deep 错误。

这些操作或 操作码 定义了从栈中使用的槽位,从加载到栈上的最新值(第一个槽位)开始。例如,操作码 ADD 将栈上的前两个槽位相加,并将结果写入第一个槽位。

Calldata

Calldata 是一种特殊的、只读的、临时的位置,用于存储外部函数调用的输入参数。它是随交易发送到智能合约的数据,包括以字节编码的函数选择器和函数参数。它是不可变的(只读的),并且仅在函数调用期间存在,并在函数调用完成后被丢弃。Calldata 对于理解智能合约函数是如何执行的至关重要,因为它包含了合约决定调用哪个函数的相关信息。

程序计数器

程序计数器是一个虚拟寄存器,用于存储要执行的下一条指令的地址。在执行操作码后,它会自动递增。唯一可以操作程序计数器并将其设置为不同地址的操作码是 JUMPJUMPI。这允许 EVM 跳过部分或创建循环(例如 if/else 或循环语句)。

操作码

可以在 这里 找到一个非常有用的操作码列表,包括它们的 gas 消耗。

PUSH

PUSH 操作码从一些数据存储(存储、内存、瞬时存储或 calldata)加载(推送)数据到栈上。有不同的 PUSH 操作码来指定加载到栈上的数据的大小。例如,PUSH1 将一个字节推送到栈上。PUSH2 将 2 个字节的数据推送到栈上。你可以使用 PUSH32 将最多 32 个字节(1 个完整字)推送到栈上。在 EIP-3855 中引入的 PUSH0 操作码用于将值 0 加载到栈上,与 PUSH1-32 相比,可以节省一些 gas。

PUSH 操作码的使用方法如下:

代码: 0x60FF6000
文本: PUSH1 F0 PUSH1 01

栈:
[0x01] // 项目 1
[0xF0] // 项目 2

PUSH1 F0 将值 0xF0 加载到栈上,而 PUSH1 01 将值 0x01 加载到栈上。因此,0x01 是栈上的第一个项目。

ADD

操作码 ADD 用于加法,并作为操作码如何在栈上操作的示例。

ADD 在栈上的前两个槽位上操作(记住添加到栈上的最后两个项目)。因此,如果我们在之前的两个 PUSH1 操作之后执行 ADD 操作,我们将把 0xF0 (16) 和 0x01 (1) 相加。因此,栈上的第一个槽位现在将包含 0xF1 (17)。

代码: 0x60FF6000
文本: PUSH1 F0 PUSH1 01 ADD

栈:
[0xF1] // 项目 1

分发函数调用

加载 Calldata

智能合约需要做的第一件事是加载函数调用的 calldata。这是通过操作码 CALLDATALOAD 完成的,该操作码接受一个参数,表示从中读取 calldata 的字节索引。如果索引为 0,则读取 calldata 的前 32 个字节。如果索引为 1,则从第二个字节开始读取 calldata,依此类推。因此,要读取 calldata 的第二个 32 字节字,可以使用索引 32。

操作码 CALLDATALOAD 的参数从栈中读取,因此需要先将索引值推送到栈上。下面是用 Huff 编写的示例:

##define macro MAIN() = takes(0) returns (0) {
  0x00 // 将值 0 推送到栈上
  CALLDATALOAD // 从字节 0 加载 calldata
}

CALLDATALOAD 之前的栈:
[0x00]

CALLDATALOAD 之后的栈:
[32 字节的 calldata]

注意:Solidity 还会执行检查以确保 calldata 长度超过 4 个字节,否则函数调用将被还原。显然,这会为每个函数调用消耗更多的 gas。

检索函数选择器

下一步是从 calldata 中检索函数选择器。最简单的方法是使用操作码 SHR 进行右移。由于函数选择器是 4 个字节,我们需要删除 calldata 中剩余的 28 个字节。我们可以通过右移 28 个字节(= 224 位)来实现这一点。SHR 操作码接受两个参数:(1)要移动的位数,以及(2)要操作的 32 字节字。操作完成后,代表函数选择器的 4 个字节将写入栈。

查看 Huff 代码,其中实现了检索函数选择器所需的步骤:

##define macro MAIN() = takes(0) returns(0){
  ...   // calldata 已加载并在栈的第一个槽位中
  0xe0  // 移动值: 32-4 = 28 字节 = 224 位 = 0xe0
  shr   // 右移:
}

SHR 之前的栈:
[0xe0]
[32 字节的 calldata]

SHR 之后的栈:
[0xcdfead2e] // 函数选择器

函数分发

检索到函数选择器后,需要将其与合约字节码中存在的函数选择器进行比较。如果函数选择器存在,则必须相应地更新程序计数器(从中读取字节码的下一个操作的计数器)。

如果合约中有多个函数,则需要检查每个函数选择器。因此,最好复制栈中检索到的函数选择器以供以后使用。这可以使用 DUP1 操作码来完成,该操作码只是复制栈上的第一个项目。

##define macro MAIN() = takes(0) returns(0){
  ...   // 函数选择器已检索
  dup1  // 复制栈上的第一个项目
}

DUP1 之前的栈:
[0xcdfead2e] // 函数选择器

DUP1 之后的栈:
[0xcdfead2e] // 函数选择器
[0xcdfead2e] // 函数选择器

作为分发函数的第一步,我们需要将检索到的函数选择器与字节码中的函数选择器进行比较,如果存在匹配,则跳转到代码中的关联位置。这需要 3 个操作:(1)将要比较的函数选择器推送到栈上,(2)将其与从 calldata 中检索到的函数选择器进行比较,以及(3)跳转到正确的位置。我们可以使用 EQ 操作码来比较函数选择器,该操作码将结果写入栈。然后,我们将跳转目标推送到栈上,如果 EQ 返回 true,则使用 JUMPI 操作码跳转到该目标。

此 Huff 代码将所有这些操作放在一起:

##define macro MAIN() = takes(0) returns(0){
  ...         // 函数选择器已检索并复制
  0x70a08231  // 推送 balanceOf(address) 的函数选择器
  eq          // 比较两个顶部栈值是否相等
  jumpDest    // 将跳转目标推送到栈上
  jumpi       // 跳转
}

EQ 之前的栈:
[0x70a08231] // 要比较的函数选择器
[0xcdfead2e] // 函数选择器
[0xcdfead2e] // 函数选择器

EQ 之后的栈:
[0x00]       // EQ 的结果 => false
[0xcdfead2e] // 函数选择器

JUMPI 之后的栈:
[0xcdfead2e] // 函数选择器

我们可以对字节码中存在的每个函数选择器重复此模式,以测试每个函数选择器。

在未成功分发函数时还原

如果智能合约使用字节码中不存在的函数选择器调用,我们想要还原。这可以使用 REVERT 操作码轻松完成。此操作码接受两个与存储在内存中的返回数据相关的参数;偏移量和大小。偏移量告诉 EVM 在内存中查找的位置,而大小是数据的长度。两者都以字节为单位进行测量。

如果没有返回数据,则可以将这两个值都设置为零,如这个 Huff 代码片段所示:

##define macro MAIN() = takes(0) returns(0){
  ...         // 没有函数选择器与 calldata 匹配
  0x00
  0x00
  revert
}

REVERT 之前的栈:
[0x00] // 内存中返回数据的偏移量
[0x00] // 内存中返回数据的大小

REVERT 之后的栈:
[0x00] // 如果没有给出返回数据,则返回 0

总结

这正是当你调用 EVM 智能合约时,在执行任何其他操作之前发生的事情。它从 calldata 中检索函数选择器,检查函数选择器是否存在于字节码中,如果存在匹配,则跳转到关联的字节码位置。如果未找到匹配项,则还原。

来源

TrashPirate 的日志总结了来自以下内容:

Cyfrin

Updraft 课程,并按特定主题组织它们。希望在课程结束后使这些课程中的有价值信息更容易检索和回顾。

关联课程的链接:汇编 & 形式化验证

其他来源:

EVM 代码

关于瞬时存储的文章

Solidity 中的 Calldata

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

0 条评论

请先 登录 后评论
trashpirate
trashpirate
江湖只有他的大名,没有他的介绍。