最详细的解释EVM的函数选择原理

基础:solidity->bytecode(字节码)->opcode(操作码)

在我们开始前,这篇文章假定读者具备 solidity 的基础知识,以及了解它是如何部署在以太坊网络的。本文将简要地讨论这部分知识,如果你想对这些知识进行系统复习,请看这篇文章[2]众所周知,solidity 代码在部署到以太坊网络之前需要被编译成字节码。这个字节码对应的是 evm 所解析的一系列操作码指令。本系列文章主要分析编译后的字节码特定部分,并阐明它们的工作原理。在阅读完每篇文章后,你应该对每个组件的功能有一个更清晰的了解。在这一过程中,你会学到很多与 evm 相关的基础概念。我们先来看一个基本的 solidity 合约,以及它部分字节码/操作码,以展示 evm 是如何选择函数的。由 solidity 合约创建的运行态(runtime)字节码是整个合约的内容总结(reoresentation)。在合约中,你可能写有多个函数,一旦部署在链上,就可以被调用。学习 evm 和合约的一个常见问题是,EVM 是如何知道根据合同的哪个函数被调用来执行哪一块字节码?这个问题是我们用来帮助理解 evm 的底层机制以及如何处理这种特殊情况的第一个问题。

1_Storage.sol 解析

在我们的演示中,我们将使用 1_Storage.sol 合约,它是在线 solidity IDE Remix[3]的默认合约之一。

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

/**
* @title Storage
* @dev Store & retrieve value in a variable
*/
contract Storage {

uint256 number;

/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}

/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}

该合约有 2 个函数store(uint 256)retrieve(),当有函数调用时,evm 将需要在两个函数之间做出选择。下面是整个合同的编译后的运行态(runtime)字节码。

608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033

我们将专注于下面的字节码片段,这个片段说明了函数选择器的逻辑。标记该片段,并执行“ctrl f”来验证它是否在上述字节码中。

60003560e01c80632e64cec11461003b5780636057361d1461005957

上述字节码对应的是一组 evm 操作码以及其对应的入参。你可以在这里[4]查看 evm 操作码的列表。操作码(opcode)的长度为一个字节,理论上有 256(2^8)种不同的操作码,evm 目前只使用 140 个独特的操作码。下面显示了被分解成相应操作码命令的字节码片段。这些都是由 evm 在调用堆栈上按顺序运行的。你可以访问上面的链接来验证 No.60 操作码是否为 PUSH1 等。在文章的最后,你应该对这些操作码的作用有一个全面的了解。

60 00                       =   PUSH1 0x00
35                          =   CALLDATALOAD
60 e0                       =   PUSH1 0xe0
1c                          =   SHR
80                          =   DUP1
63 2e64cec1                 =   PUSH4 0x2e64cec1
14                          =   EQ
61 003b                     =   PUSH2 0x003b
57                          =   JUMPI
80                          =   DUP1
63 6057361d                 =   PUSH4 0x6057361d
14                          =   EQ
61 0059                     =   PUSH2 0x0059
57                          =   JUMPI

智能合约函数调用&调用数据(Smart Contract Function Calls & Calldata)

在深入研究操作码之前,我们需要快速了解下如何调用一个合约函数。当我们调用一个合约函数时,我们需要 calldata,这些 calldata 指定了我们要调用的函数签名和任何需要传递的参数(入参)。在 solidity 中,通过以下方式完成。

event FunctionCalldata(bytes);//事件,相当于定义特殊的日志函数

bytes memory functionCallfata = abi.encodeWithSignature("store(uint256)",10);//将函数签名和入参(10)进行编码

emit FunctionCalldata(functionCalldata);//打印上述编码结果

address(storageContract).call(functionCalldata);//指定storage合约地址,并调用该合约账户的store函数

在这里,我们用参数 10 对 store 函数进行合约调用。我们使用abi.encodeWithSignature() 来获取所需格式的 calldata。emit 记录了我们的 calldata,用于测试。

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

以上字节码就是abi.encodeWithSignature("store(uint256)",10) 的返回结果。之前本文提到了函数签名,现在我们来弄清啥时函数签名呢?

函数签名被定义为 Keccak 哈希的前 4 个字节,Keccak 哈希算法是一个经典函数签名算法

函数签名的标准表示法是函数名称和函数参数类型,比如这里的"store(uint256)"和"retrieve()"。自己尝试对"store(uint256)"进行函数签名计算,并在这个工具链接[5]进行验证。

keccak256("store(uint256)")-> 前4个字节 = 6057361d

keccak256("retrieve()") -> 前4个字节 = 2e64cec1

注意上面的 calldata,我们可以得知 calldata 为 36 字节,而其中前 4 个字节对应于我们刚刚为 store(uin256)函数通过 keccak256 函数签名算法计算得到的函数选择器。calldata 剩下 32 个字节则对应我们的入参(uint256)。我们有一个十六进制的值"a",在十进制中等于 10。

6057361d = function signature (4 bytes)

000000000000000000000000000000000000000000000000000000000000000a = uint256 input (32 bytes)

"ctrl f"搜索函数签名,看是否可以在运行态字节码中找到它。

操作码&调用栈

有了上述知识基础,我们可以开始深入研究函数选择过程在 EVM 层面发生了什么。我们将通过执行每个操作码命令来理解它们的作用以及它们是如何影响调用堆栈的。如果你对栈这个数据结构不熟悉,先看这个视频[6]作为入门。我们从 PUSH1 开始,它告诉 EVM 将下一个 1 字节的数据(0x00)推到调用栈里。接下来的操作码将解释我们为什么这样做。译者注:为方便解释 stack 和 storage 变化,这里将用[]表示栈,{}表示 storage。

PUSH1 0x00                        [0]

接下来,CALLDATALOAD 操作码会弹出栈上第一个值(0)作为输入。这个操作码使用"0"作为偏移量(msg.data[0:0+32]),将 calldata 加载到栈中。栈里每层空间为 32 字节,但我们的 calldata 为 36 字节。所以传入栈的值实际为 msg.data[i:i+32],这里的 i 就是输入(本例中 i 即为 0)。这确保了只有 32 个字节的数据被传入栈区,但使我们可以访问 calldata 的任何部分。在本例中,偏移量 i 为 0,所以我们把 calldata 的前 32 个字节传入了栈区。还记得之前用一个事件(event)来记录我么的 calldata(0x6057361d000000000000000000000000000000000000000000000000000a)。这意味这后面的 4 个字节数据("0000000a")会丢失。如果我们需要访问这个 uint256 的入参,可以使 i 为 4,则访问了完整的入参,但前 4 个字节即函数签名会被忽略。

CALLDATALOAD                      [0x6057361d0...00]

接着是下一个 PUSH1,入参为(0xe0,十进制为 224)。请记住函数签名是 4 个字节/32 位。我们加载的 calldata 是 32 字节长,即 256 位。256 - 32 = 224,你可以猜到这次 push 是为了什么。

PUSH1 0xe0                        [224, 0x6057361d0...00]

接下来的操作码为 SHR,它的作用是右移。它弹出 stack 区的第一个变量(224)作为输入,说明要右移多少。stack 区的第二个变量(0x6057361d0...00)代表需要右移的数据,右移之后会将数据传入 stack。我们可以看到在这个操作之后,我们在 stack 上有了 4 字节的函数选择器。如果你对比特移位不熟悉可以看这个视频介绍[7]

SHR                               [0x6057361d]

接下来是 DUP1 操作码,一个十分简单的操作码,作用为将 stack 区最前面的一个数据复制,并再次传入 stack。

DUP1                              [0x6057361d, 0x6057361d]

PUSH4 望文生义即是将 4 字节数据传入 stack,这里是将 retrieve()的 4 字节函数签名(0x2e64cec1)传入 stack 上。如果你好奇它是怎么知道这个值的,请记住这是从 solidity 代码中编译出的字节码。因此,编译器肯定会有关于代码中所有函数名称和参数类型的信息。

PUSH4 0x2e64cec1                  [0x2e64cec1, 0x6057361d, 0x6057361d]

EQ 操作码从 stack 区弹出 2 个变量,在本例中即为 0x2e64cec1 和 0x6057361d,并检查它们是否相等。如果相等则会传入 1 到 stack 区,否则传入 0。

EQ                                [0, 0x6057361d]

PUSH2 将 2 个字节的数据(这里为 0x003b,十进制为 59)传入 stack. stack 区有一个叫做程序计数器的东西,它规定了下一个执行命令在字节码中的位置。这里我们设置 59,因为那是 retrieve()字节码的起始位置。(注意下面 EVM Playground 部分会帮助理解这一点) 你可以把程序计数器指明的位置类比为你在 solidity 代码中找代码的行数。就好像函数如果被定义在 59 行,你可以用行号来告诉机器在哪里找到该函数的代码。

PUSH2 0x003b                      [59, 0, 0x6057361d]

JUMPI 代表"如果...,则跳转至...",它从 stack 中弹出 2 个值作为输入,第一个(59)是跳转位置,第二个(0)是是否应该执行这个跳转的 bool 值。其中 1 = 真,0 = 假。如果条件为真,程序计数器将被更新至 59,在我们的例子中,第 2 参数为 0,程序计数器不会被改变,执行继续正常执行。

JUMPI                             [0x6057361d]

再次调用 DUP1

DUP1                              [0x6057361d, 0x6057361d]

PUSH4 将 4 字节 store(uint256)函数签名传入到 stack。

PUSH4 0x6057361d                  [0x6057361d 0x6057361d, 0x6057361d]

再次调用 EQ 来做判断,这次为真,因为函数签名相等。

EQ                                [1, 0x6057361d]

PUSH2,把 store(uint256)函数位置(0x0059,十进制为 89)传入到 stack。

PUSH2 0x0059                      [89, 1, 0x6057361d]

JUMPI,这次 bool 检测通过,执行跳转动作。更新程序计数器到 89,将会改变下一步执行操作码的位置到 89。在这个位置,将有一个 JUMPDEST 操作码,如果在目的地没有这个操作码,JUMPI 会失败。

JUMPI                             [0x6057361d]

到这里,在这个操作码执行后,你将被带到 store(uint256)的位置,然后函数的执行将继续正常执行。虽然这个合约只有两个函数,但该原则同样也使用于有 20+函数的合约。你现在知道 EVM 是如何根据合约中的函数调用来确定它需要执行的函数字节码的位置了。实际上,这只是一组简单的“if 语句”,用于合约中的每个函数以及它们的跳转位置。

EVM Playground

我强烈推荐你访问这个链接[8],这是一个 evm 操练场,我在这里设置了我们刚刚运行的字节码,你可以互动看到 stack 的变化,我还包括了 JUMPDEST,所以你可以看到在最后的 JUMPI 之后发生了什么。EVM 操练场也将有助于你对程序计数器的理解,在代码中,你会看到每个命令旁边的注释,其偏移量代表其程序计数器的位置。你还会看到运行按钮左边的 calldata 输入,试着把它改成 retrieve()的调用数据 0x2e64cec1,看看执行情况如何变化。只要点击运行,然后点击右上方的“步进”按钮,就可以逐步调试每个操作码。

PUSH1 0x0 // Offset 0

CALLDATALOAD // Offset 2 (previous instruction occupies 2 bytes)

PUSH1 0xE0 // Offset 3

SHR // Offset 5 (previous instruction occupies 2 bytes)

DUP1 // Offset 6

PUSH4 0x2E64CEC1 // Offset 7

EQ // Offset 12 (previous instruction occupies 5 bytes)

PUSH2 0x3B // Offset 13

JUMPI // Offset 16 (previous instruction occupies 3 bytes)

DUP1 // Offset 17

PUSH4 0x6057361D // Offset 18

EQ // Offset 23 (previous instruction occupies 5 bytes)

PUSH2 0x59 // Offset 24

JUMPI // Offset 27 (previous instruction occupies 3 bytes)

// These are just padding to enable us to get to program counter 59 & 89
PUSH30 0x0 // Offset 28


// retrieve() bytecode
JUMPDEST // Offset 59 (previous instruction occupies 31 bytes)
PUSH1 0x1 // Offset 60 (retrieve() function execution...)


// These are just padding to enable us to get to program counter 59 & 89
PUSH26 0x0 // Offset 62 (previous instruction occupies 2 bytes)


// store(uint256) bytecode
JUMPDEST // Offset 89 (previous instruction occupies 27 bytes)
PUSH1 0x0 // Continue function execution....

原文链接:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy?utm_source=url&s=r

猜你喜欢

比特币提现会被银行查吗?

比特币提现会被银行查吗? 首先,根据《中华人民共和国反洗钱法》、《金融机构大额交易和可疑交易报告管理办法》、《金融机构报告涉嫌恐怖融资的可疑交易管理办法》等法律法规的相关规定,银行会对大额资金的流动做监控,主要是审查来源是否合法,是否涉嫌洗钱。

2022-05-21

比特币暴跌50%!30岁老公玩比特币输了好多钱

比特币暴跌50%!30岁老公玩比特币输了好多钱 过去的一周里,作为一个游曳在币圈边缘的键盘侠,见识了币圈度日如年的跌宕后,仍可以笑看潮起潮落。

2022-05-21

UST爆雷之后,USDT也要爆雷了?

这几天的行情,证明了良心哥的推测非常准确。 首先是5月10日分析luna背后是被人开了黑枪,并且持续看空luna。 次日消息实锤,luna再次跌了个99%。 昨天分析说,luna的死亡螺旋会带崩大盘。

2022-05-21

Luna币7天蒸发2000亿,但更怕的是熊市即将到来!

心哥昨天虽然不知道这里边的细节,但依然非常确定的告诉大家,这是一场狙击战,找的就是这个空档,打出来的子弹是要人命的。 另外排队枪毙这个场景,估计今天很多人也领教了。

2022-05-21

一天蒸发400亿人民币,Luna是如何被狙击的?

你们也都知道良心哥炒币是个渣渣,但良心哥的判断大体还是准确的。 可能这就是从业时间久了的盘感吧。 有人说luna的暴跌,ust抛锚,都他吗赖孙宇晨。 从5月5号孙宇晨宣布进军算法稳定币之后,大盘就崩了

2022-05-21

    上一篇

    ERC721A 算法分析与设计

    下一篇

    Polkadot Xcm -- 从基础到实践(实现跨链转账)

评论