通过逆向和调试理解EVM #3 :存储布局如何工作?

通过调试理解EVM 3 :存储布局如何工作?

本文是关于通过逆向工程与调试理解EVM第 3 篇,本系列包含 7 篇文章:

本篇我们将看看不同类型的变量是如何在EVM内存和存储中存储和处理的。

每次,当我们在分析一段代码时,我建议你同时用remix来调试它。你会对正在发生的事情有一个更好的理解。如果你不知道怎么做,请查看本系列的第1篇:理解汇编

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)

img> 修改函数的Gas成本

但是,如果你第二次调用该函数,由于存储中的值是非零的,Gas成本会便宜很多。(每SSTORE 使用2200gas)

img

提示:每条在EVM上指令都要花费Gas,一个交易的Gas成本是所有指令的Gas总和(+21000Gas的基本成本),你可以在调试器标签中的 "步骤详情(step details)"部分看到Gas的使用。

img

这里,SWAP1指令使用了3个Gas

如果你不理解这第一部分,请随时阅读本系列的第一篇或第二篇文章,在那里更详细地解释汇编代码:https://learnblockchain.cn/article/4913

2. 使用uint8 而不是uint256

到现在为止,我们还没学到什么,但是如果我们把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=1balance2=2balance3=3的值。

在第60指令,因为Stack(1)是0,OR(或)操作码在这里没有任何作用,因为 0 OR x = x (对于所有的x),Stack保持不变,只有Stack(1)的0x00被删除。

之后,SSTORE用来将030201存储在0槽中。这就是我们所要做的。

你可以注意到,03 02 01在存储空间和堆栈中都占用了1个槽,就像我们所期望的那样。 因此,我们可以证明,这3个变量占用了相同的存储槽,因此使用的Gas更少了

img

只有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       ||
  1. 首先(指令78),EVM 加载存储的槽 0,即0x030201
  2. 其次(指令79-82),EVM对 ff00 取反(NOT),在32个字节的结果是 0xfffffffffffffffffffffffffffffffffffffffffffffffffff00ff
  3. 在指令83,2个结果进行与运算,即0x00000000000000000000000000000000000000000000000000030001(或0x030001)。 这和存储槽0是一样的,但是没有02(合约中的 "balance2"的部分),这是正常的!为什么? 这是因为在modify2()中,EVM修改了balance2。首先它需要擦除之前的结果,而不擦除balancebalance3(因为它们在同一个槽中),所以它通过使用0xfff...ff00ff掩码来 "清洗" 结果。
  4. 之后,0500被推入堆栈(指令84),最后的2个结果进行OR操作(指令85),最后的结果是:0x030501,"OR"的目的是在03和01的边上加上05。因此余额2被成功地修改,而没有改变余额和余额3。

这个0xfffffffffffffffffffffffffffffffffffffffffffffffffff00ff被称为 "掩码"。

如果我们想代替balance2修改balance3为5,我们应该使用掩码0xfffffffffffffffffffffffffffffffffffffffffffffffff00ffff(擦除balance3在0x00槽中的字节),在第四步,我们应该PUSH 050000.(05应该在这里,因为这里放置了balance3的存储。)

这就是为什么你在需要的时候应该使用较小的类型:它需要更少的Gas。

但是,不要...

剩余50%的内容订阅专栏后可查看

0 条评论

请先 登录 后评论
翻译小组
翻译小组

首席翻译官

167 篇文章, 29224 学分