译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]
本文是关于调试 EVM 智能合约系列的第 2 篇,本系列包含 7 篇文章:
在第二部分(本文)中,我们将分析当你在区块链中部署一个智能合约时发生了什么,例如,在点击 remix 中的 "部署 "按钮时。
下面是我们要部署的合约示例:
pragma solidity ^0.8.0;
contract Test {
uint balance;
constructor() {
balance = 9;
}
}
在深入学习本教程之前,不要忘记:
简要提示,要调试一个交易,必须在部署智能合约后按下面的 "调试 "按钮。
所有的调试信息都位于屏幕的左边,你可以看到堆栈、局部变量、状态、内存、存储、反汇编等等。
但是在开始调试之前,你能不能回答这个问题:
问:智能合约部署后,我们要调试的代码在哪里?
答:代码位于数据字段。代码位于交易的数据字段中,它就是在部署智能合约时要执行的代码。他的作用是在区块链上部署智能合约并执行构造函数。
现在,我们可以继续了。
默认情况下,调试器显示的是第 17 字节的构造函数代码,为了了解第 17 字节之前的代码,让我们点击下图中的左箭头。
因此,我们回到第 0 字节。
000 PUSH 80 | 0x80 |
002 PUSH 40 | 0x40 | 0x80 |
004 MSTORE ||
我们已经知道了前 3 条指令。
它将0x80存储在 EVM 内存的地址0x40,相当于内联汇编:
mstore(0x40,0x80)
这是空闲内存指针,别担心,我们稍后会讲到这部分:)
接下来的操作码也已经知道了(参考本系列的第一篇[11])。
005 CALLVALUE |msg.value|
006 DUP1 |msg.value|msg.value|
007 ISZERO |0x01|msg.value|
008 PUSH1 0f |0x0f|0x01|msg.value|
010 JUMPI |msg.value|
011 PUSH1 00 |0x00|msg.value| (if jumpi don't jump to 0f)
013 DUP1 |0x00|0x00|msg.value|
014 REVERT
015 JUMPDEST
基本上,它通过CALLVALUE操作码获得msg.value的值(发送到合约的以太币),如果返回值严格地大于0,则回退。
我们在本系列的第一篇中详细解释了发生了什么。
它等同于:
if (msg.value > 0) { revert() }
由于我们的构造函数是不支付的,所以我们不能发送正常的资金! 事情与第一部分相同,至少到第 14 个字节为止。
现在发生了什么?
函数签名在哪里?我们的函数中心在哪里?当然不见了,在部署的时候,除了构造函数之外,没有任何可用的函数!
15 JUMPDEST |0x00|
16 POP ||
17 PUSH1 09 |0x09|
19 PUSH1 00 |0x00|0x09|
21 SSTORE ||
在第 16 个指令,EVM 弹出堆栈中的剩余值(0)。
之后我们把 9 和 0 推到堆栈中,并调用SSTORE,堆栈现在是:|0x00|0x09|
。
SSTORE操作码将 Stack(1)存储到 Stack(0)槽中(顾名思义)。(所以它使用了 2 个参数,因此在执行SSTORE的第 21 指令后,它们被从堆栈中移除了)
此时,EVM 在第一个槽(槽号为 0)中存储数值 9,这相当于内联汇编:
sstore(0x00,0x09)
这正是我们构造函数中的代码:
balance = 9
它在变量 "balance"中存储了 9,但是 balance 位于存储中。
由于 balance 是智能合约中第一个声明的变量,这意味着分配给 "balance"是第一个存储槽(slot 0)。你可以在 "存储(Storage)"部分看到这一点:
当合约部署时,在 EVM 中执行的代码是非常短的,我们已经到达了终点,也就是第 32 字节! (使用类似于 STOP 的 REUTRN 操作码)
22 PUSH1 3f |0x3f|
24 DUP1 |0x3f|0x3f|
25 PUSH1 22 |0x22|0x3f|0x3f|
27 PUSH1 00 |0x00|0x22|0x3f|0x3f|
29 CODECOPY |0x3f|
30 PUSH1 00 |0x00|0x3f|
32 RETURN ||
堆栈现在在第 21 字节是空的(因为SSTORE不在堆栈中保留两个参数)
3f 被推入堆栈并重复,| 0x3f |
之后推入 22 和 00,在第 27 个指令堆栈现在是:| 0x00 |0x22 |0x3f |0x3f |
。
如果我们看一下文档,我们会发现CODECOPY是一个特殊的操作码,它可以 EVM 内存中复制当前智能合约代码。
它需要 3 个参数:
事实上,在执行这条指令后,如果我们在调试器中查看 EVM 的内存状态,会发现内存从0x00 到 0x3f被填满。
这是我们存储在 EVM 内存中的智能合约的代码。因此,交易数据的第 0x22 字节(十进制 34)之后的每一整块字节都是智能合约的代码!
第 32 个指令,RETURN被调用,参数为 Stack(0) = 0x00 和 Stack(1) = 0x3f。
RETURN停止代码的执行,并返回内存[Stack(0):Stack(0)+Stack(1)],这是 [0x00:0x40] 。
返回的这个值是存储在区块链中的。在我们的例子中,这就是智能合约的代码!。
总结一下这第一部分,这是为了在区块链中部署智能合约而执行的交易数据:
The code which deploy the smart contract (byte 0 to 33)
6080604052348015600f57600080fd5b506009600055603f8060226000396000f3fe-----------
The deployed smart contract (byte 34 to 97)
6080604052600080fdfea264697066735822122018fba077a8095159cac22a23ec0b3172b5ab77a14a3cf44bc3107e4049b7dcf264736f6c63430008070033
--------------
现在你知道当你在区块链上部署一个智能合约时,EVM 中到底发生了什么,这真是太棒了!
如果把构造函数写成可支付的(payable)呢?有什么不同吗?让我们来看看!
这是我们的新智能合约,与之前的智能合约差别不大,我们只是在构造函数中加入了 "payable"的修饰(不要改变设置, solidity: 0.8.7, optimizer: 1)
pragma solidity ^0.8.0;
contract Test {
uint balance;
constructor() payable {
balance = 9;
}
}
不要忘记在部署时向智能合约发送 1 个以太币,在”value“域中选择以太币(如下图)。
下面是交易的完整拆解(只有 20 个字节):
00 PUSH1 80
02 PUSH1 40
04 MSTORE
05 PUSH1 09
07 PUSH1 00
09 SSTORE
10 PUSH1 3f
12 DUP1
13 PUSH1 16
15 PUSH1 00
17 CODECOPY
18 PUSH1 00
20 RETURN
我想你已经认出了这段代码,没有必要显示堆栈。
我们的空闲内存指针仍然被设置,但之后并没有对msg.value进行任何验证,EVM 直接进入构造器代码,随后复制/返回智能合约代码,这些代码将被部署到区块链上。
唯一的区别是,在第 13 行写的是PUSH 16,而不是 push 22。
这是因为该交易是 13 个字节的大小。(0x20-0x13 的十六进制= 32-19=13 的十进制)然后 EVM 不是从 35 字节开始复制代码,而是从 22 字节开始,因为要部署的智能合约代码就在构造器的执行之后。
还要注意的是,汇编代码要短得多,但 Gas 成本是差不多的:89228 (没有 payable 时) 对比 89036(少 188 个 Gas)。
由于可支付和 "不可支付"的构造函数之间没有很大的区别,让我们继续前进!为什么不在构造函数中添加新参数呢?
让我们部署这个智能合约,参数 a=1,b=2,msg.value=1 ether,设置与之前一样(启用优化器, runs 设置为 1,solidity 0.8.7)。
pragma solidity ^0.8.0;
contract Test {
uint balance;
constructor(uint a,uint b) payable {
balance = 9;
}
}
在观察了调试标签后,字节 0 到 4 显然与期望的一样的相同。
提示:每个 solidity 智能合约都由 mstore(0x40,0x80)开始,也就是十六进制的 0x6080604052。
现在的输出略有不同:
005 PUSH1 40 |0x40|
007 MLOAD |0x80|
008 PUSH1 98 |0x98|0x80|
010 CODESIZE |0xd8|0x98|0x80|
011 SUB |0x40|0x80|
MLOAD从内存中加载 Stack(0)位置的值到堆栈,在内联汇编中是 MLOAD(0x40) 。因此 80 被推送到堆栈。(因为 80 在之前被存储在内存 0x40 处)
之后 EVM 推送 98,堆栈现在是| 0x98 | 0x80 |
。
CODESIZE不从堆栈中获取任何参数,但在堆栈中保存代码的大小,如果我们检查堆栈,我们应该看到堆栈中的新值是 0xd8。(如果你编译了完全相同的代码,设置与我相同,因此代码的长度将是相等的)
它是执行的代码的大小(因此这也是交易数据的大小,因为如前所述,要执行的代码位于交易数据中),现在堆栈是:| 0xd8 | 0x98 | 0x80 |
。
调用 SUB 操作码,执行 Stack(0)-Stack(1),现在的堆栈是| 0x40 | 0x80 |
。事实上,d8-98=40(十六进制)。
012 DUP1 |0x40|0x40|0x80|
013 PUSH1 98 |0x98|0x40|0x40|0x80|
015 DUP4 |0x80|0x98|0x40|0x40|0x80|
016 CODECOPY |0x40|0x80|
在一系列的PUSH和DUP 之后,CODECOPY在字节 16 处复制智能合约中执行的代码,下面是CODECOPY指令的参数(destOffset,offset,size)。- Stack(0) 复制代码到哪个内存位置。- Stack(1) 开始复制代码时,在已执行的代码中的偏移量(从哪个位置开始复制)。- Stack(2) 要复制多少字节的代码?
所有在 0x98 和 0x98+0x40 之间的代码被复制到内存的 0x80 槽中。
你看到内存中的差异了吗?
我们看到,32 字节的插槽 memory[0x80:0x99] 现在包含了第一个参数。(为 1)
同样,memory[0xa0:0xbf] 也包含了第二个参数。(为 2)
因此,这段代码(从第 5 个指令到第 16 个指令)的目的是将构造函数的参数复制到内存中!
当你部署一个智能合约时,交易中只有两个字段是必须的(除了签名),那就是字段 from 和 data。"from"包含你的地址,data 包含智能合约代码(以及部署智能合约的代码,我们在这里分析)和参数。
下面是一个例子:
{
from: "0x1234....."
data: "[code to execute when deploying smart contract] [smart contract code to deploy] [constructor parameters]
}
参数位于所有智能合约代码的数据之后(并以 32 字节编码,即 16 进制的 0x20)。
注意,0x40 的空闲内存指针不应该是 0x80,因为 0x80 的内存不再是空闲的。
现在由于 0x80 是使用的,它包含了 2 个参数。当内存被释放时,0x40 应该指向 0xc0。
这就是 17 和 23 之间的代码的目的(它把 40 加到前面的
017 DUP2 |0x80|0x40|0x80|
018 ADD |0xc0|0x80| add 0x40 to 0x80 (previous free memory pointer loaded at byte7)
019 PUSH1 40 |0x40|0xc0|0x80| push 40 in the stack
021 DUP2 |0xc0|0x40|0xc0|0x80|
022 SWAP1 |0x40|0xc0|0xc0|0x80|
023 MSTORE |0xc0|0x80| store the result of the addition in 0x40 in memory
空闲内存指针的目的只是指向内存中的一个空闲槽,每当内存使用这个槽时,指针就会改变到另一个空闲地址。让我们继续往下看。
024 PUSH1 1e |0x1e|0xc0|0x80|
026 SWAP2 |0x80|0xc0|0x1e|
027 PUSH1 29 |0x29|0x80|0xc0|0x1e|
029 JUMP |0x80|0xc0|0x1e| jump to 0x29 (41 in hex)
代码无条件跳转到 0x29(Dec 中的 41)
让我们把注意力集中在代码的第三部分,因为参数 1 和 2 已经加载到内存中。
041 JUMPDEST |0x80|0xc0|0x1e|
042 PUSH1 00 |0x00|0x80|0xc0|0x1e|
044 DUP1 |0x00|0x00|0x80|0xc0|0x1e|
045 PUSH1 40 |0x40|0x00|0x00|0x80|0xc0|0x1e|
047 DUP4 |0x80|0x40|0x00|0x00|0x80|0xc0|0x1e|
048 DUP6 |0xc0|0x80|0x40|0x00|0x00|0x80|0xc0|0x1e|
049 SUB |0x40|0x40|0x00|0x00|0x80|0xc0|0x1e|
050 SLT |0x00|0x00|0x00|0x80|0xc0|0x1e|
051 ISZERO |0x01|0x00|0x00|0x80|0xc0|0x1e|
052 PUSH1 3b |0x3b|0x01|0x00|0x00|0x80|0xc0|0x1e|
054 JUMPI |0x00|0x00|0x80|0xc0|0x1e|
055 PUSH1 00
057 DUP1
058 REVERT
在用调试器分析了这个汇编后(在字节 41 和 54 之间),智能合约计算 c0-80 并验证它是否等于 40。
如果减去的数字不相等,EVM 就会回退,否则执行流程继续,EVM 跳转到 3b(十进制的 59)。
80 和 c0 是存储在内存中的 2 个参数的开始偏移和结束偏移。(即 1 和 2)
由于 EVM 是按 32 个字节为一组工作的(十六进制为 20)。我们的目的只是为了验证在构造函数中确实有 2 个参数被加载到内存中(总长度为 40,十六进制)。通过将参数的结束偏移量减去内存中参数的开始偏移量。
如果不是这样,那么这意味着交易中的参数少于 2 个。所以 EVM 通过不跳转到 59 来还原指令 55 和 58 之间的情况
接下来在指令(我们的最后一段代码):
059 JUMPDEST |0x00|0x00|0x80|0xc0|0x1e|
060 POP |0x00|0x80|0xc0|0x1e|
061 POP |0x80|0xc0|0x1e|
062 DUP1 |0x80|0x80|0xc0|0x1e|
063 MLOAD |0x01|0x80|0xc0|0x1e| the first argument was loaded.
064 PUSH1 20 |0x20|0x01|0x80|0xc0|0x1e|
066 SWAP1 |0x01|0x20|0x80|0xc0|0x1e|
067 SWAP2 |0x80|0x20|0x01|0xc0|0x1e|
068 ADD |0xa0|0x01|0xc0|0x1e| add 20 to the offset in memory to load.
069 MLOAD |0x02|0x01|0xc0|0x1e| Our 2 arguments were loaded.
070 SWAP1 |0x01|0x02|0xc0|0x1e|
071 SWAP3 |0x1e|0x02|0xc0|0x01|
072 SWAP1 |0x02|0x1e|0xc0|0x01|
073 SWAP2 |0xc0|0x1e|0x02|0x01|
074 POP |0x1e|0x02|0x01|
075 JUMP |0x02|0x01| Swap stack to jump top 0x1e (30 in dec)
在堆栈中弹出 2 个 0x0 后,我们在第 61 指令的堆栈中留下了| 0x80 | 0xc0 | 0x1e |
。
EVM 复制了 80,并使用 MLOAD 在 Stack(0)处加载,加载在 80 处内存数据,这是我们之前复制到内存的构造函数中的第一个参数。(即 1)
现在由于我们加载的每一个值,都在堆栈中。
接下来,在指令 64 处,我们需要加载第二个参数,因为 EVM 是按 32 个字节(20)的十六进制分组工作的,EVM 必须在 80+20=a0 处加载内存,以获得第二个参数。
这就是为什么 EVM 在堆栈中推入 20,并在堆栈中交换一些数值,以使 80 成为第一个。
EVM 在指令 68 处加上这两个数字,等于 a0,并将它们加载到堆栈中。
总结一下,这段代码加载了存储在内存中的 2 个参数。
mload(0x80)
mload(0xa0)
之后,EVM 跳转到指令 30,堆栈中有 1 和 2,EVM 继续执行,将余额设置为 9(在第 30 和 40 指令之间执行构造函数)并将合约代码复制到区块链中。(在第 76 和 89 指令之间)
请注意,2 个参数在第 31 和 32 指令之间被构造函数弹出了,因为我们在代码中没有使用它们 :)
030 JUMPDEST |0x02|0x01|
031 POP |0x01|
032 POP ||
033 PUSH1 09 |0x09|
035 PUSH1 00 |0x00|0x09|
037 SSTORE ||
038 PUSH1 4c (76 in dec)
040 JUMP
076 JUMPDEST
077 PUSH1 3f
079 DUP1
080 PUSH1 59
082 PUSH1 00
084 CODECOPY
085 PUSH1 00
087 RETURN
.... 我们完成了! 智能合约结束了它的执行。
最后总结一下合约部署情况:
今天的工作已经很多了,但别担心,这个系列还没有结束。
本翻译由 Duet Protocol[12] 赞助支持。
原文链接:https://trustchain.medium.com/reversing-and-debugging-evm-smart-contracts-part-2-e6106b9983a
[1]
登链翻译计划: https://github.com/lbc-team/Pioneer
[2]
翻译小组: https://learnblockchain.cn/people/412
[3]
Tiny 熊: https://learnblockchain.cn/people/15
[4]
第1篇:汇编表示: https://learnblockchain.cn/article/4913
[5]
第2篇:部署智能合约: https://learnblockchain.cn/article/4927
[6]
第3篇:存储布局是如何工作的?: https://medium.com/@TrustChain/reversing-and-debugging-ethereum-evm-smart-contracts-part-3-ebe032a08f97
[7]
第4篇:结束/中止执行的5个指令: https://medium.com/@TrustChain/reversing-and-debugging-evm-the-end-of-time-part-4-3eafe5b0511a
[8]
第5篇:执行流 if/else/for/函数: https://medium.com/@TrustChain/reversing-and-debugging-evm-the-execution-flow-part-5-2ffc97ef0b77
[9]
第6篇:完整的智能合约布局: https://medium.com/@TrustChain/reversing-and-debugging-part-6-full-smart-contract-layout-f236c3121bd1
[10]
第7篇:外部调用和合约部署: https://medium.com/@TrustChain/reversing-and-debugging-theevm-part-7-2a20a44a555e
[11]
本系列的第一篇: https://learnblockchain.cn/article/4913
[12]
Duet Protocol: https://duet.finance/?utm_souce=learnblockchain