译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]
本文是关于通过调试理解 EVM 第 3 篇,本系列包含 7 篇文章:
笨笨我们将看看不同类型的变量是如何在 EVM 内存和存储中存储和处理的。
每次,当我们在分析一段代码时,我建议你同时用remix来调试它。你会对正在发生的事情有一个更好的理解。如果你不知道怎么做,请查看本系列的第 1 篇:理解汇编[11]
我们将首先使用一个非常简单的例子。
不要忘记编译下面的合约,我们的设置是:solidity 0.8.7版本编译器、启用优化器,run 为 200 。
部署它可以并调用函数 "modify()":
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
uint balance;
uint balance2;
uint balance3;
function modify() external {
balance = 1;
balance2 = 2;
balance3 = 3;
}
}
当我们用 remix 调试 "modify" 函数时,会被 remix 直接 "路由"到函数modify() ,因此在modify() 之前执行的代码(如函数选择器或 payable 验证)已经完成,对我们的分析没有帮助:
045 JUMPDEST |0x64cf33b8| (this is the function signature, we will discard it)
046 PUSH1 42 |0x42|
048 PUSH1 01 |0x01|0x42|
050 PUSH1 00 |0x00|0x01|0x42|
052 DUP2 |0x01|0x00|0x01|0x42|
053 SWAP1 |0x00|0x01|0x01|0x42|
054 SSTORE |0x01|0x42|
055 PUSH1 02 |0x02|0x01|0x42|
057 SWAP1 |0x01|0x02|0x42|
058 DUP2 |0x02|0x01|0x02|0x42|
059 SWAP1 |0x01|0x02|0x02|0x42|
060 SSTORE |0x02|0x42|
061 PUSH1 03 |0x03|0x02|0x42|
063 SWAP1 |0x02|0x03|0x42|
064 SSTORE |0x42|
065 JUMP ||
066 JUMPDEST ||
067 STOP ||
在我们调用函数 modify 后,结果是相当明显的。
在第 48 指令,EVM 在堆栈中PUSH 42(十进制的66),这就是合约末尾代码中的 "位置"。(066 jumpdest 067 stop)当 modify()的执行将结束时,EVM 将 JUMP 到这个字节。
在指令 48 和 54 之间,EVM 在存储槽 0保存 1 在指令 55 和 60 之间,EVM 在存储槽 1 保存2。在指令 61 和 64 之间,EVM 在存储槽 2 保存3。
在第 65 个指令,函数JUMP到 66(0x42),在函数 modify()的开头保存的字节,并通过使用STOP指令结束智能合约的执行。
你可以通过运行调试器和检查堆栈中的汇编来验证。这段代码相当于:
sstore(0x0,0x1)
sstore(0x1,0x2)
sstore(0x2,0x3)
这里,即使我们的值比 32 字节少很多,它们也被存储在单独的槽里,这可能会花费一些 Gas。(如果以前的值是 0,则每个槽要花费 20000 个 Gas)
> 修改函数的 Gas 成本
但是,如果你第二次调用该函数,由于存储中的值是非零的,Gas 成本会便宜很多。(每 SSTORE 使用 2200gas)
提示:每条在 EVM 上指令都要花费 Gas,一个交易的 Gas 成本是所有指令的 Gas 总和(+21000Gas 的基本成本),你可以在调试器标签中的 "步骤详情(step details)"部分看到 Gas 的使用。
这里,SWAP1 指令使用了 3 个 Gas
如果你不理解这第一部分,请随时阅读本系列的第一篇或第二篇文章,在那里更详细地解释汇编代码:https://learnblockchain.cn/article/4913
到现在为止,我们还没学到什么,但是如果我们把uint代替为uint8 呢?有什么区别吗?让我们看看结果吧!
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0; contract Test {
uint8 balance;
uint8 balance2;
uint8 balance3;
function modify() external {
balance = 1;
balance2 = 2;
balance3 = 3;
}
function modify2() external {
balance2 = 5;
}
}
你可能已经知道,uint8 只使用1 个字节。所以 3 个 uint8 应该只用3 个字节,这比一个槽要少得多。(32 字节)
因此,这三个变量加起来应该只使用一个槽,对吗?
是的,你是对的!只有一个存储被执行,而且代码要短得多。
045 JUMPDEST |function signature (discarded)|
046 PUSH1 00 |0x00|
048 DUP1 |0x00|0x00|
049 SLOAD |0x00|0x00| (the slot 0 in storage contains 0x030201)
050 PUSH3 ffffff |0xffffff|0x00|0x00|
054 NOT |0xffffff...fffff000000|0x00|0x00| (the NOT inverse all 32 bytes of Stack(0)
055 AND |0x00|0x00|
056 PUSH3 030201 |0x030201|0x00|0x00|
060 OR |0x030201|0x00|
061 SWAP1 |0x00|0x030201|
062 SSTORE ||
063 STOP ||
让我们看看在这个函数中到底发生了什么。像往常一样,不要忘记在阅读的同时使用调试器,你会对情况有更好的理解。
在第 49 指令,SLOAD在 Stack(0)槽中加载 Storage 值,由于 Stack(0)=0 (备注:存储槽没有写入过,默认都是 0),所以堆栈没有变化。
接下来的 3 个操作(指令 50-55)有点神秘。
EVM 推送 "ffffff" 和 NOT(取反)这个,结果是 0xfffffffffffffffffffffffffffffffffffffffffffffffff000000, NOT 指令反转了**Stack(0)**的所有字节。
在这之后,它与之前的SLOAD 进行 AND 运算(与运算),也就是 0x00。
我们知道,0 AND x = 0(对每一个 x),结果是 0x00,堆栈保持与指令 50 之前一样。
在这 6 条指令之后,没有任何变化,这非常奇怪......我们将在后面几行看到原因。
就在这之后的第 56 字节:0x030201 被推到了堆栈中,这显然是我们的balance=1
,balance2=2
,balance3=3
的值。
在第 60 指令,因为Stack(1) 是 0,OR(或)操作码在这里没有任何作用,因为 0 OR x = x (对于所有的 x),Stack 保持不变,只有 Stack(1)的 0x00 被删除。
之后,SSTORE用来将030201存储在0槽中。这就是我们所要做的。
你可以注意到,03 02 01 在存储空间和堆栈中都占用了 1 个槽,就像我们所期望的那样。因此,我们可以证明,这 3 个变量占用了相同的存储槽,因此使用的 Gas 更少了
只有 43286 个 Gas 被使用,而之前是 87504。
第二个智能合约函数 modify(),只使用了 43286 个 Gas 而不是 87504 个。如果你是一个智能合约的开发者,你已经证明了,使用更少的变量(当它是可能的)可以节省大量的 Gas......
现在让我们调用函数modify2(在modify() 之后 ),这里是整个函数的反汇编。
提示一下:modify2 只把 balance2(插槽 1)设置为 5。
075 PUSH1 00 |0x00|
077 DUP1 |0x00|0x00|
078 SLOAD |0x030201|0x00| (Slot 0 = balance which contains 0x030201 as set previously)
079 PUSH2 ff00 |0xff00|0x030201|0x00|
082 NOT |0xfff...fffff00ff|0x030201|0x00|
083 AND |0x000...000030001|0x01|0x00|
084 PUSH2 0500 |0x0500|0x000...000030001|0x01|0x00|
087 OR |0x000...000030501|0x01|0x00|
088 SWAP1 |0x01|0x000...000030501|0x00|
089 SSTORE |0x00|
090 STOP ||
这个0xfffffffffffffffffffffffffffffffffffffffffffffffffff00ff被称为 "掩码"。
如果我们想代替balance2修改balance3为 5,我们应该使用掩码0xfffffffffffffffffffffffffffffffffffffffffffffffff00ffff(擦除 balance3 在 0x00 槽中的字节),在第四步,我们应该PUSH 050000.(05 应该在这里,因为这里放置了balance3的存储。)
这就是为什么你在需要的时候应该使用较小的类型:它需要更少的 Gas。
但是,不要滥用较小的类型,因为它增加了 EVM 执行的操作的数量(通过使用带有掩码的操作),所以它使用更多的 Gas。
让我们看看节省 Gas 的技巧是否只适用于uint类型或其他 solidity 内置类型。
下面是新的智能合约,用相同的设置(编译器 0.8.7 和优化器为 200)编译它:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
uint8 balance;
bytes4 data;
address addr;
function modify() external {
balance = 17;
data = 0xaaaaaaaa;
addr = 0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3;
}
}
这个智能合约包含一个uint8,占1 个字节,一个byte4,占4 个字节,一个地址,占20 个字节。我们创建的 modify
函数总共 "修改"了25 个字节,这比一个 EVM 插槽(32 个字节)还要少。
理论上,这 3 个变量应该用一个存储槽就可以。
是这样吗?
045 JUMPDEST |function signature discarded|
046 PUSH1 00 |0x00|
048 DUP1 |0x00|0x00|
049 SLOAD |0x00|0x00| (slot 0 in storage contains 0)
050 PUSH1 01 |0x01|0x00|0x00|
052 PUSH1 01 |0x01|0x01|0x00|0x00|
054 PUSH1 c8 |0xc8|0x01|0x01|0x00|0x00|
056 SHL |0x00.50zeros.0100...00|0x01|0x00|0x00| move 0x01 to 0xc8 = 200times (50 hex numbers) to the left
057 SUB |0x00.15zeros0tttffff.49f.fffff|0x00|0x00|
058 NOT |0xfffffffffffffff00..49zeros..00|0x00|0x00| Our mask is created !
059 AND |0x00|0x00|
060 PUSH25 358aa13c52544eccef6b0add0f801012adad5ee3aaaaaaaa11
|0x00...00358aa13...|0x00|0x00|
086 OR |0x00...00358aa13...|0x00|
087 SWAP1 |0x00|0x00...00358aa13...|
088 SSTORE ||
089 STOP ||
你可能猜到了,答案是肯定的!
该函数的结构与上一个例子(使用 uint8)几乎相同。只是在开始创建掩码时有一些不同。
因为有 25 个字节,所以掩码应该是0xfffffffffff00000...49zeros00000在指令 58/59 的AND之前。
之后,在第 60 指令,包含智能合约中存在的值的 25 个字节被推送(addr = 0x358aa13c52544eccef6b0add0f801012adad5ee3,data = 0xaaaaaaaa,balance = 0x11),并与指令86的掩码进行 "OR"。
最后,它们被 "存储"在 0 槽中。
使用了 43298 个 Gas(只执行了 1 个 SSTORE)。
除此之外,Gas 成本与存储 uint8 的成本差不多低。(43286 vs 43298)
要了解 EVM,最好的方法是通过修改不同的参数,进行尽可能多的测试。这正是我们在这里所做的。
在这个例子中,我们将互换addr和data变量。
它们应该在存储空间中占据相同的位置,但位置应该是颠倒的,对吗?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
uint8 balance;
address addr;
bytes4 data;
function modify() external {
balance = 17;
data = 0xaaaaaaaa;
addr = 0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3;
}
}
让我们来编译和反汇编,在指令 60 处,我们有:(当然,代码的其他指令是一样的)
PUSH aaaaaaaa358aa13c52544eccef6b0add0f801012adad5ee311
这与上次的情况:“358aa13c52544eccef6b0add0f801012adad5ee3aaaaaaaa11” 不同。
我们可以看到,addr和data变量被互换了。
我们的假设是对的,两个变量的位置被颠倒了。balance = 17 (十六进制 11)仍然是在第一个位置,因为 EVM 使用的是小端(litter-endian)架构(第一个在最后)。
如果我们用结构体做同样的事情,会发生什么?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
struct Values {
uint8 balance;
address addr;
bytes4 data;
}
Values value;
function modify() external {
value.balance = 17;
value.addr = 0x358AA13c52544ECCEF6B0ADD0f801012ADAD5eE3;
value.data = 0xaaaaaaaa;
}
}
下面是函数的额完整汇编代码:
045 JUMPDEST
046 PUSH1 00 |0x00|
048 DUP1 |0x00|0x00|
049 SLOAD |0x00|0x00|
050 PUSH1 01 |0x01|0x00|0x00|
052 PUSH1 01 |0x01|0x01|0x00|0x00|
054 PUSH1 c8 |0xc8|0x01|0x01|0x00|0x00|
056 SHL |0x00.50zeros.0100...00|0x01|0x00|0x00|
057 SUB |0x00.15zeros0tttffff.49f.fffff|0x00|0x00|
058 NOT |0xfffffffffffffff00..49zeros..00|0x00|0x00|
059 AND |0x00|0x00|
060 PUSH25 aaaaaaaa358aa13c52544eccef6b0add0f801012adad5ee311 |0x00...00358aa13...|0x00|0x00|
086 OR |0x00...00358aa13...|0x00|
087 SWAP1 |0x00|0x00...00358aa13...|
088 SSTORE ||
089 STOP ||
使用结构体的代码是完全一样。
警告:这意味着有时要区分 3 个不同的变量还是一个结构体可能很困难。
Gas 费也和以前一样:
好了,这对我们来说太容易了,直到现在,操作码之间没有什么区别。但我们还没有结束! 我们需要更多的挑战。
数组是如何在 EVM 中存储的?像结构体或变量一样?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
uint[] values;
// uint value2 in comment
function modify() external {
values.push(7);
values.push(8);
}
}
不是的 !
这一次,不幸的是,它更长、更复杂。我们将需要更多关于 EVM 中存储的理论。
"values" 变量是一个动态数组,最多可以存储无限多的值。但是有一个问题,如果我们在数组之后创建一个value2变量(就像注释中的那样),怎么办?它应该被存储在哪个存储槽中?
value2需要在 "value"之后,并且在槽 1 中,但是由于 "value"数组是动态的,可以改变大小,所以很难指定一个插槽。嗯...
在现实中,这仍然是正确的,value2将被存储在槽 1 中(因此接下来的变量将被存储在槽 2,3 中,以此类推...)。
但是槽 0 呢?槽 0 包含什么?
事实上,槽 0 存储的是数组的长度,在此案例中,在智能合约执行函数**modify()**后,它将存储 2,因为有 2 个值被推入数组。
但是,这些值被存储在哪里?
由于数组的长度被存储在槽 0 中,这些值应该被存储在其他地方。
它是在槽Keccak256(0)+n中。
第一个值存储在槽 Keccak256(0)+0 中 下一个:Keccak256(0)+1第三个:Keccak256(0)+2第 n 个:Keccak256(0)+(n-1)
由于Keccak256(0) 是一个非常大的数字,EVM 不可能用完插槽。因此,我们解决了这个问题。
为什么在keccak256(0)中使用0?因为声明的数组是在 0 槽里。如果它是:
uint value2 //value 2 is in slot 0
uint[] values; //the array is in slot 1
因为数组在槽 1 中,这个数组中的第 i 个值被存储在Keccak256(1)+(n-1) ,数组的长度被存储在槽 1 中。
现在我们应该看看它在汇编中是如何工作的。首先,这是函数 modify()在汇编中的第一部分:
048 PUSH1 00 |0x00|
050 DUP1 |0x00|0x00|
051 SLOAD |0x00|0x00|
052 PUSH1 01 |0x01|0x00|0x00|
054 DUP2 |0x00|0x01|0x00|0x00|
055 DUP2 |0x01|0x00|0x01|0x00|0x00|
056 ADD |0x01|0x01|0x00|0x00|
057 DUP4 |0x00|0x01|0x01|0x00|0x00|
058 SSTORE |0x01|0x00|0x00|
059 DUP3 |0x00|0x01|0x00|0x00|
060 DUP1 |0x00|0x00|0x01|0x00|0x00|
061 MSTORE |0x01|0x00|0x00|
在指令51,EVM 从存储槽 0 加载,结果是 0,因为数组的长度是 0。
在第56指令,EVM 在 0 号存储槽加载的值上加 1,并在第 58 指令将其SSTORE到同一存储槽。因此,现在数组的长度是 1。
在第 61指令,EVM 在地址 0 处 MSTORE 0,我们将在后面看到原因。
062 PUSH1 07 |0x07|0x01|0x00|0x00|
064 PUSH32 290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 |hash|0x07|0x01|0x00|0x00|
097 SWAP3 |0x00|0x07|0x01|hash|0x00|
098 DUP4 |hash|0x00|0x07|0x01|hash|0x00|
099 ADD |hash|0x07|0x01|hash|0x00|
100 SSTORE |0x01|hash|0x00|
0x07 被推到堆栈中,0 的哈希值也是如此,它是keccak256(0x00)(它等于 290decd...e563)
由于数组的当前索引是 0,EVM 将 0 添加到哈希值上(在指令 99),即堆栈中的第四个值。(这一点很重要,因为当 EVM 将另一个值添加到数组中时,索引将与 0 不同,因此结果也将不同)。
然后存储(SSTORE) 0x07 到结果槽,(槽 keccak256(0x00)+0 = 7)
这就成功存储了数组的第一个元素。
在第 2 次 SSTORE 之后,重复同样的代码,存储值为 8,但有细微差别:
101 DUP3 |0x00|0x01|hash|0x00|
102 SLOAD |0x01|0x01|hash|0x00|
103 SWAP1 |0x01|0x01|hash|0x00|
104 DUP2 |0x01|0x01|0x01|hash|0x00|
105 ADD |0x02|0x01|hash|0x00|
106 SWAP1 |0x01|0x02|hash|0x00|
107 SWAP3 |0x00|0x02|hash|0x01|
108 SSTORE |hash|0x01|
109 PUSH1 08 |0x08|hash|0x01|
111 SWAP2 |0x01|hash|0x02|
112 ADD |hash+1|0x02|
113 SSTORE ||
在第 102 指令,SLOAD在槽 0 处返回 1,这是数组的长度。
在第 105 指令,ADD在结果上加 1,所以 1+1=2。
在第 108 指令,EVM SSTORE结果(是 2)在槽 0,这就是数组的新长度。
在第 112 指令,EVM 将 1 添加到 Hash 中,这是分配给values[1] 的槽。
在第 113 指令,EVM存储 值 8,在keccak256(0)+1 而不是上次的keccak256(0) 。
如果我们分析一下这种操作的 Gas 成本,我们会发现,这比非数组的情况下要高。
87746 Gas
有 1 个SSTORE来存储一个数组的长度。(+ 20000 gas) 有 2 个SSTORE来存储 7 和 8 的值。(+40000 gas) 有 1 个SSTORE到一个非零值槽,将数组的长度从 1 更新到 2。
我们就快完成了......
现在我们来谈谈映射,像数组一样,如何存储映射的所有值并不明显。(ERC20 代币中的变量balances就是一个好例子)
我们知道,映射是一组键值对。
对比数组,在映射中存储值的方式非常相似,存储槽等于SHA3(mapping_slot.key)
(其中 . 是连接运算符)在这最后一部分,我们将验证这个公式。
让我们来编译这个最后的合约,编译选项 没有优化(但仍然是 solidity 0.8.7) ,并调用modify() 函数!
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Test {
mapping(address => uint) balances;
function modify() external { balances[0xbc5D291D2165f130375B94c62211f594dB48fEF2] = 15; balances[0x9a8af21Ac492D5055eA7e1e49bD91BC9b5549334] = 55;
}
}
这是函数的第一部分的完整反汇编:
054 PUSH1 0f |0x0f|
056 PUSH1 00 |0x00|0x0f|
058 DUP1 |0x00|0x00|0x0f|
059 PUSH20 bc5d291d2165f130375b94c62211f594db48fef2
|address1|0x00|0x00|0x0f|
080 PUSH20 ffffffffffffffffffffffffffffffffffffffff
|0xff..ff|address1|0x00|0x00|0x0f|
101 AND |address1|0x00|0x00|0x0f|
102 PUSH20 ffffffffffffffffffffffffffffffffffffffff
|0xff..ff|address1|0x00|0x00|0x0f|
123 AND |address1|0x00|0x00|0x0f|
124 DUP2 |0x00|address1|0x00|0x00|0x0f|
125 MSTORE |0x00|0x00|0x0f|
126 PUSH1 20 |0x20|0x00|0x00|0x0f|
128 ADD |0x20|0x00|0x0f|
129 SWAP1 |0x00|0x20|0x0f|
130 DUP2 |0x20|0x00|0x20|0x0f|
131 MSTORE |0x20|0x0f|
132 PUSH1 20 |0x20|0x20|0x0f|
134 ADD |0x40|0x0f|
135 PUSH1 00 |0x00|0x40|0x0f|
137 SHA3 |keccak256(address1)|0x0f|
138 DUP2 |0x0f|keccak256(address1)|0x0f|
139 SWAP1 |keccak256(address1)|0x0f|0x0f|
140 SSTORE |0x0f|
在第 54-59 指令 0f(十进制的 15),0,0 和 bc5d...f2 被推送到堆栈。
在第 80 和 102 指令之间,这段代码没有任何作用,它只是通过使用掩码:0x00000000000000000000000ffffffffffffffffffffffffffffffffffffffff 保证堆栈中的地址 0xbc5d...之后只有 0000
例如,地址可能是0xd0000....bc5d...fd,EVM 和 0x000...000ffff的掩码进行与运算,从而去除开头的d。
在第 125 指令,它用MSTORE把结果保存到内存(保存在地址为 0x00 的 "清洁 "内存中)。
在第 128 指令,它将 20 加到0x00,并在第 131 指令将 0 存储到内存。(因此 0 被存储在内存的 0x20 处)
在第 137 指令,调用SHA3指令,使用堆栈中的参数:0(offset)和 40(size)。
这条指令返回keccak256(memory[offset:offset+length])。这里 offset = Stack(0) = 0, length = Stack(1) = 40 (因为堆栈里有最后两个值)
这是地址0xbc5d...f2与 0 相连接的SHA3(或KECCAK256)。
之后,通过使用SSTORE,15 被存储在SHA3操作的结果槽中。
如果我们把它与开头的公式进行比较: SHA3(mapping_slot.key)
对于那些想知道memory[0x00:0x40]的目的是什么的人来说,的答案是:目标是(仅仅)存储使用keccak256 进行 Hash 的操作数。
由于 EVM 对 0x00 和 0x40 之间的所有东西进行散列,我们的说法是正确的。
下面是这样一个操作的 Gas 成本:
使用 65594 Gas
成本比存储在数组中要低(至少在开始时),因为 mapping_slot(槽 0)不包含任何东西,也没有被修改,所以少了一个SSTORE操作。
如果你读完了这篇文章,恭喜你! 这并不容易,但它是值得的。你现在对 EVM 的了解比 99.9%的开发者还要多。
本翻译由 Duet Protocol[12] 赞助支持。
原文链接:https://trustchain.medium.com/reversing-and-debugging-ethereum-evm-smart-contracts-part-3-ebe032a08f97
[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://learnblockchain.cn/article/4943
[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]
第1篇:理解汇编: https://learnblockchain.cn/article/4913
[12]
Duet Protocol: https://duet.finance/?utm_souce=learnblockchain