通过调试理解EVM 3 :存储布局如何工作?
本文是关于通过逆向工程与调试理解EVM第 3 篇,本系列包含 7 篇文章:
本篇我们将看看不同类型的变量是如何在EVM内存和存储中存储和处理的。
每次,当我们在分析一段代码时,我建议你同时用remix来调试它。你会对正在发生的事情有一个更好的理解。如果你不知道怎么做,请查看本系列的第1篇:理解汇编
我们将首先使用一个非常简单的例子。
不要忘记编译下面的合约,我们的设置是: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指令 表示第48个字节数上的指令(从 0 开始),下面都使用这种简写方式。
在指令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。
但是,不要...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!