本文深入探讨了Solidity函数分发器的工作原理,包括EVM的结构、存储、内存、瞬态存储、栈、calldata和程序计数器等关键组件。详细解释了智能合约如何从calldata中检索函数选择器,并将其与合约字节码中的函数选择器进行比较,最终跳转到相应的代码位置执行函数,如果未找到匹配项则执行revert操作。
TrashPirate 的日志:汇编 & 形式化验证
即使是 Web3 的新手,你也可能已经转移了一个简单的 ERC20 代币,比如从你的钱包向你朋友的钱包转移了 10 个 USDC。你按了一个名为“发送”之类的按钮。但是当你执行这样的动作时,底层到底发生了什么?在本文中,我们将研究当你执行智能合约交互时发生的底层过程 —— 例如检索余额或转移 ERC20 代币。
要真正理解智能合约交互,我们需要对以太坊虚拟机 (EVM) 是什么以及它的结构有一个基本的了解。EVM 是使智能合约执行成为可能的软件。它是一个虚拟机器,可以执行一组指令。这些指令由以太坊客户端解释,由以太坊节点运行,这些节点也存储区块链的状态。参与以太坊网络的每个节点或计算机都会安装软件,即所谓的客户端,它可以将 EVM 指令转换为在本地计算机上执行的操作。
EVM 是一个栈式机器,由以下组件组成:
数据可以存储在存储或内存中,但存储访问比内存贵得多(使用更多的 gas)。例如,用于在存储中存储数据的操作码 SSTORE
消耗 20000 gas(二级写入 —— “热访问” —— 消耗 2900 gas),而用于在内存中存储数据的 MSTORE
仅消耗 3 gas。
存储类似于键值存储,内存类似于 32 字节字的动态数组,在交易期间数据会被连接起来。空闲内存指针跟踪内存的长度,并标记新数据的位置。
瞬时存储是 EIP-1153 中引入的一种新的存储空间。它的功能类似于常规存储,但仅在交易期间存在。在交易结束时,它会被丢弃 —— 类似于计算机的 RAM。为了从瞬时存储中存储和检索数据,引入了操作码 TSTORE
和 TLOAD
。这两个操作码每个仅消耗 100 gas,与 SSTORE
的至少 20000(冷)/ 2900(热)gas 和 SLOAD
的 2100(冷)/ 100(热)gas 相比,便宜得多。
栈是一种后进先出的数据结构,用于执行许多 EVM 操作。这意味着数据从内存或存储加载到栈上,用于对其执行操作(如加法或减法),然后从栈上加载(到内存或存储)。栈最多可以容纳 1024 个槽位(32 字节字)的数据。如果需要超过 1024 个槽位来执行操作 —— 那么,你就会从编译器收到可爱的 stack too deep
错误。
这些操作或 操作码 定义了从栈中使用的槽位,从加载到栈上的最新值(第一个槽位)开始。例如,操作码 ADD
将栈上的前两个槽位相加,并将结果写入第一个槽位。
Calldata 是一种特殊的、只读的、临时的位置,用于存储外部函数调用的输入参数。它是随交易发送到智能合约的数据,包括以字节编码的函数选择器和函数参数。它是不可变的(只读的),并且仅在函数调用期间存在,并在函数调用完成后被丢弃。Calldata 对于理解智能合约函数是如何执行的至关重要,因为它包含了合约决定调用哪个函数的相关信息。
程序计数器是一个虚拟寄存器,用于存储要执行的下一条指令的地址。在执行操作码后,它会自动递增。唯一可以操作程序计数器并将其设置为不同地址的操作码是 JUMP
和 JUMPI
。这允许 EVM 跳过部分或创建循环(例如 if/else
或循环语句)。
可以在 这里 找到一个非常有用的操作码列表,包括它们的 gas 消耗。
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
在栈上的前两个槽位上操作(记住添加到栈上的最后两个项目)。因此,如果我们在之前的两个 PUSH1
操作之后执行 ADD
操作,我们将把 0xF0
(16) 和 0x01
(1) 相加。因此,栈上的第一个槽位现在将包含 0xF1
(17)。
代码: 0x60FF6000
文本: PUSH1 F0 PUSH1 01 ADD
栈:
[0xF1] // 项目 1
智能合约需要做的第一件事是加载函数调用的 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 的日志总结了来自以下内容:
Updraft 课程,并按特定主题组织它们。希望在课程结束后使这些课程中的有价值信息更容易检索和回顾。
关联课程的链接:汇编 & 形式化验证
- 原文链接: medium.com/the-pirate-st...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!