深入理解 EVM(三)

今天我们来聊聊调用合约方法在字节码层面是怎么实现的。同样地,我们以一个简单的合约作为例子:

编译合约

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

contract Demo {
    constructor() {}

    function func1() public {}

    function func2() public {}
}

该合约中一共有两个方法,分别是 func1func2。我们这里着重于理解方法调用的过程,因此简单起见就将方法内容置为空。

使用 solc 进行编译:

solc Demo.sol --bin

得到的字节码为:

6080604052348015600f57600080fd5b5060818061001e6000396000f3fe6080604052348015600f57600080fd5b506004361060325760003560e01c806374135154146037578063b1ade4db14603f575b600080fd5b603d6047565b005b60456049565b005b565b56fea264697066735822122069532a763e3f61abcfbb422ae7dc4587126c8f8b2264e73bb837a5924ebb1d4964736f6c634300080f0033

利用 0xfe 操作符分割后,得到的各部分分别为:

6080604052348015600f57600080fd5b5060818061001e6000396000f3(init bytecode)

6080604052348015600f57600080fd5b506004361060325760003560e01c806374135154146037578063b1ade4db14603f575b600080fd5b603d6047565b005b60456049565b005b565b56(runtime bytecode)

a264697066735822122069532a763e3f61abcfbb422ae7dc4587126c8f8b2264e73bb837a5924ebb1d4964736f6c634300080f0033(metadata hash)

init bytecode 是合约部署部分,我们在上篇文章中已经介绍过了。调用合约方法是与 runtime bytecode 这部分进行交互,我们主要来研究这一块。

我们知道,调用合约方法交易的 data 域就是合约签名与参数的拼接内容。具体来说,就是合约签名的 keccak256 哈希值的前 4 个字节再加上参数内容就构成了 data 部分。

在上面例子中,func1func2 的哈希值前 4 个字节分别是 0x741351540xb1ade4db,同时由于这两个方法都没有参数,因此不用在后面拼接参数。如果 fun1 的方法签名为:

function func1(uint _a) public {}

那么就需要添加参数,并且补齐 32 字节:

0x254e43db0000000000000000000000000000000000000000000000000000000000000001

其中 0x254e43dbfunc1(uint256) 的哈希值前 4 个字节。

接下来我们就来看看在调用合约方法的过程中,在 EVM 字节码层面会发生什么。我们以调用 func1 方法为例,那么此时交易的 data 域内容就为 0x74135154

我们先将 runtime bytecode 部分的字节码与其 opcodes 一一对应起来:

0 -> 60 PUSH1
1 -> 80 0x80
2 -> 60 PUSH1
3 -> 40 0x40
4 -> 52 MSTORE
5 -> 34 CALLVALUE
6 -> 80 DUP1
7 -> 15 ISZERO
8 -> 60 PUSH1
9 -> 0f 0xF
10 -> 57 JUMPI
11 -> 60 PUSH1
12 -> 00 0x0
13 -> 80 DUP1
14 -> fd REVERT
15 -> 5b JUMPDEST
16 -> 50 POP
17 -> 60 PUSH1
18 -> 04 0x4
19 -> 36 CALLDATASIZE
20 -> 10 LT
21 -> 60 PUSH1
22 -> 32 0x32
23 -> 57 JUMPI
24 -> 60 PUSH1
25 -> 00 0x0
26 -> 35 CALLDATALOAD
27 -> 60 PUSH1
28 -> e0 0xE0
29 -> 1c SHR
30 -> 80 DUP1
31 -> 63 PUSH4
(32 ~ 35) -> 74135154 func1 方法签名哈希前四字节
36 -> 14 EQ
37 -> 60 PUSH1
38 -> 37 0x37
39 -> 57 JUMPI
40 -> 80 DUP1
41 -> 63 PUSH4
(42 ~ 45) -> b1ade4db func2 方法签名哈希前四字节
46 -> 14 EQ
47 -> 60 PUSH1
48 -> 3f 0x3F
49 -> 57 JUMPI
50 -> 5b JUMPDEST
51 -> 60 PUSH1
52 -> 00 0x0
53 -> 80 DUP1
54 -> fd REVERT
55 -> 5b JUMPDEST
56 -> 60 PUSH1
57 -> 3d 0x3D
58 -> 60 PUSH1
59 -> 47 0x47
60 -> 56 JUMP
61 -> 5b JUMPDEST
62 -> 00 STOP
63 -> 5b JUMPDEST
64 -> 60 PUSH1
65 -> 45 0x45
66 -> 60 PUSH1
67 -> 49 0x49
68 -> 56 JUMP
69 -> 5b JUMPDEST
70 -> 00 STOP
71 -> 5b JUMPDEST
72 -> 56 JUMP
73 -> 5b JUMPDEST
74 -> 56 JUMP

来看看这部分字节码都干了些什么。

调用合约方法流程

0 - 4 行我们已经非常熟悉了,加载空闲内存指针。

5 - 14 行是校验 msg.value 必须为零,我们在上篇文章已经看到过这部分了。由于合约中没有 payable 方法,因此要求所有的合约调用的 callvalue 都要为零,否则会在 14 行REVERT 失败。

如果传入的 value 为零,则进入正常流程 15 行,在 16 行将栈中无用数据 pop 出。

17 - 18 行将 0x4 放入栈中,它代表正常的方法调用签名长度。此时栈为:

| 4

19 行获取到 data 域的长度并放入栈中,0x74135154 的长度为 4 个字节。此时栈为:

| 4 | 4

20 行从栈中获取两个数据,并判断第一个数字是否小于第二个数字,小于返回 1,否则返回 0。这里由于 4 = 4,因此返回 0。这部分是什么意思呢?上面我们说到第一次放入栈中的 4 代表正常的方法调用签名长度,也就是方法签名前四个字节的意思。无论我们调用了哪个方法,有没有参数,data 域的长度都至少应该是 4。如果某个交易的 data 域的长度小于 4,说明它并不是正常的方法调用,那么在接下来的流程中肯定就走不到正常的方法名匹配部分,要么交易失败,要么进入到 fallback 的调用流程中。我们这个例子中没有声明 fallback 方法,因此如果这时 LT 操作符返回 1(即 TRUE),交易就会失败。

在这里正常情况下,此时栈中为:

| 0

21 - 22 行将 0x32 放入栈中:

| 0 | 0x32

23 行 JUMPI(0x32, 0) 操作符根据栈中数据判断是否跳转。由于第二个参数是 0,因此不跳转,继续向下执行。这里假设第二个参数是 1,也就是上一步中的 data 域长度小于 4 字节,流程会跳转到 0x32,也就是 50 行,并最终在 54 行 REVERT 交易失败,验证了我们上面的说法。

24 - 25 行将 0x0 放入栈中:

| 0

26 行加载 32 字节长度的 data 域放入栈中,开始位置从栈中获取,这里即为 calldata[0],当前的 data 域内容为 0x74135154,不够 32 字节因此需要用 0 补齐。此时栈中为:

| 7413515400000000000000000000000000000000000000000000000000000000

27 - 28 行将 0xE0,即 224 放入栈中:

| 0x7413515400000000000000000000000000000000000000000000000000000000 | 0xE0

29 行 SHR 取出栈中数据,将 7413515400000000000000000000000000000000000000000000000000000000 右移 224 位,前者的长度是 256(即 64 * 4) 位,右移之后变成 0x74135154,刚好是四个字节,这四个字节恰好是方法签名哈希值的前四个字节,是用来匹配合约中的方法的。此时栈为:

| 0x74135154

30 行复制一份栈顶数据:

| 0x74135154 | 0x74135154

31 - 35 将 func1 的方法签名哈希前四个字节放入栈中:

| 0x74135154 | 0x74135154 | 0x74135154

36 行 EQ 判断栈顶两个元素是否相等,这里相等,因此返回 1:

| 0x74135154 | 1

37 - 38 将 0x37 放入栈中:

| 0x74135154 | 1 | 0x37

39 行 JUMPI(0x37, 1) 判断是否跳转,此时会跳转到 0x37,即 55 行。栈为:

| 0x74135154

这里如果第二个参数,也就是 EQ 的结果为 0,说明调用的方法与当前的方法不匹配,则不跳转,继续向下运行,寻找下一个方法签名进行匹配。

此时,我们已经找到了相匹配的合约方法,也就是说已经完成了匹配方法名的过程。接下来就是执行方法体内容了。

跳转到 55 行,56 - 59 将 0x3D0x47 放入栈中:

| 0x74135154 | 0x3D | 0x47

其中,0x47,即 71 是 func1 方法体所在的字节码开始位置。

60 行 JUMP 跳转到 71 行执行 func1 方法体,此时栈为:

| 0x74135154 | 0x3D

由于 func1 内容为空,因此不需要执行什么操作。

72 行获取栈顶元素 0x3D,即 61,并跳转。

61 - 62 行这里会进行方法调用的结尾工作,通过 STOP 结束。

小结

这篇文章我们学习了合约方法调用在字节码层面的实现。我们通过一个简单的合约,了解了合约方法调用过程中,用户的请求是如何匹配到具体的方法的。其实内容也比较简单,就是在栈中与所有的方法签名哈希值一个一个进行比对,如果相同,则跳转到相应的部分执行对应方法内容。这里还是建议大家都亲手去 evm.codes 这个网站去实际操作一下,感受整个流程中内存与栈内容的变化,会加深对整个流程的理解。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.