深入理解 EVM(二)

上篇文章我们简要介绍了一下合约的字节码构造以及内存布局,今天我们来从字节码层面聊聊合约的部署过程。

编译合约

我们来看一个例子:

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

contract Demo {
    constructor() {}
}

这个合约比上篇文章的还要简单,它什么内容都没有,仅仅有一个合约的框架而已,这样我们就可以不用关心一些额外内容比如变量赋值等,只需关心合约部署的最核心步骤。

先使用 solc 进行编译:

solc Demo.sol --bin

编译后的字节码为:

6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220c6a9021ede10c2befd51f04c8bcd9294ea47f79868b9ad6d81af3e41d3b2c2bf64736f6c634300080f0033

按照 0xfe 操作符分割后的结果为:

6080604052348015600f57600080fd5b50603f80601d6000396000f3(init bytecode)

6080604052600080fd(runtime bytecode)

a2646970667358221220c6a9021ede10c2befd51f04c8bcd9294ea47f79868b9ad6d81af3e41d3b2c2bf64736f6c634300080f0033(metadata hash)

其中 init bytecode 部分就是合约的部署流程,我们主要着重于这部分。

我们前面说过,这些字节码对应的其实都是 opcodes,也就是操作符和操作数,我们可以通过

solc Demo.sol --opcodes

来得到对应的 opcodes:

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x3F DUP1 PUSH1 0x1D PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 0xC6 0xA9 MUL 0x1E 0xDE LT 0xC2 0xBE REVERT MLOAD CREATE 0x4C DUP12 0xCD SWAP3 SWAP5 0xEA SELFBALANCE 0xF7 SWAP9 PUSH9 0xB9AD6D81AF3E41D3B2 0xC2 0xBF PUSH5 0x736F6C6343 STOP ADDMOD 0xF STOP CALLER

我们将 init bytecode 部分的 16 进制字节码与其 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 -> 3f 0x3F
19 -> 80 DUP1
20 -> 60 PUSH1
21 -> 1d 0x1D
22 -> 60 PUSH1
23 -> 00 0x0
24 -> 39 CODECOPY
25 -> 60 PUSH1
26 -> 00 0x0
27 -> f3 RETURN

我们今天的目标就是把这些字节码搞懂,看看它都干了些什么。

合约部署流程

开头的 0 → 4 是我们前面讲过的加载空闲内存指针的过程,即 MSTORE(0x40, 0x80),此时内存中的数据为(第三行的最后为 80):

0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000080
0000000000000000000000000000000000000000000000000000000000000000

每一行的长度是 64,即 32 个字节。我们前面说过 0x40 - 0x5f 内存区域是用来存储空闲内存指针的,对照上面结果,确实如此。

接下来,第 5 行的 CALLVALUE,获取创建合约时发送的 ETH 数量,并存入栈中。我们创建该合约并不需要同时发送 ETH,因此值为 0。此时的栈中数据为(左边是栈底,右边是栈顶,下同):

| 0

第 6 行的 DUP1,会取栈顶第一个元素,也就是 0,并且复制一份,再放入栈中。此时,栈中数据为:

| 0 | 0

第 7 行 ISZERO,取出栈顶元素,判断是否为 0,若为 0,返回 1,否则返回 0,最后将结果放入栈中。此时,栈为:

| 0 | 1

第 8、9 行,将 0xF 放入栈中。此时,栈为:

| 0 | 1 | F

第 10 行,JUMPI 会取出栈顶的两个元素,并将其作为参数,即 JUMPI(F, 1),如果第二个参数是 1,则跳转到第一个参数,也就是 F 位置,如果不是 1,则不跳转。这里需要跳转,F 是 16 进制中的 15,因此需要跳转到 15 行。这个 15 行,也就是从 0 开始第 15 个字节处。每个操作符都消耗一个字节,操作数也同样消耗字节。此时,栈为:

| 0

第 15 行是 JUMPDEST,是跳转的目标位,也就是说,凡是 JUMPI 指令进行跳转,跳转的目标必须是它,它本身并没有什么实际作用,仅仅用做标记位。如果跳转至此,没有该操作符,则流程失败。

第 16 行,POP 会弹出栈顶的元素,即弹出 0。此时栈为空:

|

第 17、18 行,PUSH1 3F,即将 0x3F 放入栈中。此时,栈为:

| 3F

第 19 行,DUP1 复制栈顶元素。此时,栈为:

| 3F | 3F

第 20 到 23 行,连续将两个元素 0x1D0x0 放入栈中。此时,栈为:

| 3F | 3F | 1D | 0

第 24 行,CODECOPY,将当前运行环境中的字节码复制到内存中,接收 3 个参数,分别是目标内存位置、要复制数据的起始位置、复制长度。这里是 CODECOPY(0, 1D, 3F),即从字节码中的 1D 起始,复制长度为 3F 的字节码到内存位置 0 处。

我们来看看这几个参数是什么意思,首先第一个参数是说将结果都放在 0 开始的内存处,这个好理解。第二个参数 1D,换成 10 进制是 29,我们前面看的 init bytecode 部分一共有 27 行(算上 0 行有 28 行),由于我们是按照 0xfe 来将其分割的,因此按照全部字节码来说,0xfe 是位于第 28 行的,而第 29 行开始的部分就是 runtime bytecode 了。也就是说,我们需要从 runtime bytecode 处开始复制代码,长度是 0x3F,即 10 进制的 63。我们再来看整体的字节码,从 runtime bytecode 处的 6080604052… 开始的字节码,一直到最后,长度是 126,恰好是 63 个字节。

也就是说,我们将除了 init bytecode 部分的 runtime bytecodemetadata hash 复制到了内存中。此时,内存中数据为:

6080604052600080fdfea2646970667358221220c6a9021ede10c2befd51f04c
8bcd9294ea47f79868b9ad6d81af3e41d3b2c2bf64736f6c634300080f003300
0000000000000000000000000000000000000000000000000000000000000080
0000000000000000000000000000000000000000000000000000000000000000

同时,栈中数据为:

| 3F

接下来,第 25、26 行,PUSH1 0x00 放入了栈中。此时,栈为:

| 3F | 0

最后,第 27 行,RETURN 从内存中读取数据并返回给 EVM,并结束流程。它接收两个参数,分别是起始位置以及长度,即 RETURN(0, 3F)。前面我们说了 0x3Fruntime bytecodemetadata hash 的长度,也就说从内存中读取了这两部分的数据,并返回。即返回值为:

6080604052600080fdfea2646970667358221220c6a9021ede10c2befd51f04c
8bcd9294ea47f79868b9ad6d81af3e41d3b2c2bf64736f6c634300080f0033

此时,栈中数据已经清空。

现在,我们已经走完了部署的整个流程,可以看到,整个部署的流程主要就是简单的两步:

  1. 运行构造函数的逻辑
  2. 获取 runtime bytecodemetadata hash 的内容并返回给 EVM

接下来我们回到第 5 行,这里我们获取了 CALLVALUE,前面的流程中值为 0。现在我们假设在创建合约的同时也发送了 ETH,那么这里得到的就是非零值,这里我们假设是在创建合约时同时发送了 1 wei(只要是非零值结果都一样),那么栈中为:

| 1

第 6 行 DUP1,复制后栈为:

| 1 | 1

第 7 行 ISZERO,由于栈顶非零,因此结果为 0,此时栈中为:

| 1 | 0

第 8、9 行,将 0xF 放入栈中。此时,栈为:

| 1 | 0 | F

第 10 行,JUMPI(F, 0),由于第二个参数是 0,因此不跳转,继续向下执行。

第 11、12 行,PUSH1 0x00 放入栈中:

| 1 | 0

第 13 行 DUP1,此时栈为:

| 1 | 0 | 0

第 14 行 REVERT,抛出错误,流程以失败结束。

那么我们说的这个 CALLVALUE 0 或者非 0,到底是什么意思呢?

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

contract Demo {
    constructor() {}
}

看看代码,其实就是因为合约中的构造函数没有 payable 关键字,禁止向合约中发送 ETH。因此在字节码中需要对此进行判断。那么我们就会想到,如果加上了 payable 之后,字节码会怎样呢?

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

contract Demo {
    constructor() payable {}
}

编译后的字节码:

6080604052603f8060116000396000f3(init bytecode)

6080604052600080fd(runtime bytecode)

a26469706673582212208c516264e3e6785d014f2db856266d96d155fdd72ef5e53ecc896b5837f13cc664736f6c634300080f0033(metadata hash)

这时我们看到 init bytecode 部分短了很多,其实就是去掉了判断 CALLVALUE 的部分。我们再将其与 opcodes 一一对应:

0  -> 60 PUSH1
1  -> 80 0x80
2  -> 60 PUSH1
3  -> 40 0x40
4  -> 52 MSTORE
5  -> 60 PUSH1
6  -> 3f 0x3F
7  -> 80 DUP1
8  -> 60 PUSH1
9  -> 11 0x11
10 -> 60 PUSH1
11 -> 00 0x0
12 -> 39 CODECOPY
13 -> 60 PUSH1
14 -> 00 0x0
15 -> f3 RETURN

我们再简单过一下流程。

0 → 4: MSTORE(0x40, 0x80),内存为:

0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000080
0000000000000000000000000000000000000000000000000000000000000000

5 → 6: PUSH1 0x3F

| 3F

7: DUP1

| 3F | 3F

8 → 9: PUSH1 0x11

| 3F | 3F | 11

10 → 11: PUSH1 0x0

| 3F | 3F | 11 | 0

12: CODECOPY(0, 11, 3F),此时内存为:

6080604052600080fdfea26469706673582212208c516264e3e6785d014f2db8
56266d96d155fdd72ef5e53ecc896b5837f13cc664736f6c634300080f003300
0000000000000000000000000000000000000000000000000000000000000080
0000000000000000000000000000000000000000000000000000000000000000

13 → 14: PUSH1 0x0

| 3F | 0

15: RETURN(0, 3F),内存中前 3F 个字节,即

6080604052600080fdfea26469706673582212208c516264e3e6785d014f2db8
56266d96d155fdd72ef5e53ecc896b5837f13cc664736f6c634300080f0033

我们看到,由于构造函数中有 payable 关键字,也就是发不发送 ETH 都可以,对应于字节码的整个流程中确实没有了对于 CALLVALUE 的判断。

实际部署合约

我们从字节码方面走完了整个部署流程,那么我们来实际部署一个合约,来看看具体交易的细节。仍然使用最开始的合约:

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

contract Demo {
    constructor() {}
}

通过 Remix 或者其它工具进行部署。我在 Rinkeby 上部署了合约,部署的交易哈希是:

0x039c306e0ac3ad5f8a972185d2094a977b1bef97694e2872f975f70e6db4a241

利用 Ethersjs 读取交易详情:

部署交易详情
部署交易详情

从图中注意到两点,一个是 to 字段为空,一个是 data 字段恰好就是我们前面编译合约之后得到的字节码,这也正是部署合约交易的两个特点。

小结

这篇文章我们学习了字节码层面的合约部署流程,我们以最简单的合约为例,逐一走过了整个流程。对于有更复杂操作的合约,原理都一样,只不过是多了一些变量赋值等操作。这里强烈推荐 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.