通过逆向和调试理解EVM #3 :存储布局如何工作?
通过调试理解EVM 3 :存储布局如何工作?
本文是关于**通过调试理解EVM第 3 篇**,本系列包含 7 篇文章:
- [第1篇:理解汇编](https://learnblockchain.cn/article/4913)
- [第2篇:部署智能合约](https://learnblockchain.cn/article/4927)
- [第3篇:存储布局是如何工作的?](https://learnblockchain.cn/article/4943)
- [第4篇:结束/中止执行的5个指令](https://learnblockchain.cn/article/4965)
- [第5篇:执行流 if/else/for/函数](https://learnblockchain.cn/article/4987)
- [第6篇:完整的智能合约布局](https://medium.com/@TrustChain/reversing-and-debugging-part-6-full-smart-contract-layout-f236c3121bd1)
- [第7篇:外部调用和合约部署](https://medium.com/@TrustChain/reversing-and-debugging-theevm-part-7-2a20a44a555e)
本篇我们将看看不同类型的变量是如何在EVM内存和存储中存储和处理的。
每次,当我们在分析一段代码时,我建议你同时用**remix**来调试它。你会对正在发生的事情有一个更好的理解。如果你不知道怎么做,请查看本系列的[第1篇:理解汇编](https://learnblockchain.cn/article/4913)
## 1. 简单的例子
我们将首先使用一个非常简单的例子。
不要忘记编译下面的合约,我们的设置是:solidity **0.8.7**版本编译器、启用优化器,run 为 200 。
部署它可以并调用函数 "modify()":
```solidity
// 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](https://img.learnblockchain.cn/2022/09/30/1*_9m0BNrePEGhNOiqngKwzw.png)> 修改函数的Gas成本
但是,如果你第二次调用该函数,由于存储中的值是非零的,Gas成本会便宜很多。(每SSTORE 使用2200gas)
![img](https://img.learnblockchain.cn/2022/09/30/1*aGCWJxGhwN8wIbQywUo7yQ.png)
提示:每条在EVM上指令都要花费Gas,一个交易的Gas成本是所有指令的Gas总和(+21000Gas的基本成本),你可以在调试器标签中的 "步骤详情(step details)"部分看到Gas的使用。
![img](https://img.learnblockchain.cn/2022/09/30/1*8rkvQRCaKy31GBZaw2n_BQ.png)
> 这里,SWAP1指令使用了3个Gas
如果你不理解这第一部分,请随时阅读本系列的第一篇或第二篇文章,在那里更详细地解释汇编代码:https://learnblockchain.cn/article/4913
## 2. 使用uint8 而不是uint256
到现在为止,我们还没学到什么,但是如果我们把**uint**代替为**uint8**呢?有什么区别吗?让我们看看结果吧!
```solidity
// 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更少了
![img](https://img.learnblockchain.cn/2022/09/30/1*XA0NGnmd-Paqj3aBr_op6A.png)
> 只有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**。首先它需要擦除之前的结果,而不擦除**balance**和**balance3**(因为它们在同一个槽中),所以它通过使用**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。
但是,不要滥用较小的类型,因为它增加了EVM执行的操作的数量(通过使用带有掩码的操作),所以它使用更多的Gas。
## 3. 使用不同类型的数据
让我们看看节省Gas的技巧是否只适用于**uint**类型或其他solidity内置类型。
下面是新的智能合约,用相同的设置(编译器0.8.7和优化器为200)编译它:
```solidity
// 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槽中。
![img](https://img.learnblockchain.cn/2022/09/30/1*5MEjUepzOaxMcIw0lKD2tg.png)
使用了43298个Gas(只执行了1个SSTORE)。
**除此之外,Gas成本与存储uint8的成本差不多低。(43286 vs 43298)**
## 4. 变量的位置是否有关系?
要了解EVM,最好的方法是通过修改不同的参数,进行尽可能多的测试。这正是我们在这里所做的。
在这个例子中,我们将互换**addr**和**data**变量。
它们应该在存储空间中占据相同的位置,但位置应该是颠倒的,对吗?
```solidity
// 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)架构(第一个在最后)。
## 5. 如何存储结构体?
如果我们用结构体做同样的事情,会发生什么?
```
// 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费也和以前一样:
![img](https://img.learnblockchain.cn/2022/09/30/1*26mYV9OP2GG_ma7dCkQVKA.png)
好了,这对我们来说太容易了,直到现在,操作码之间没有什么区别。但我们还没有结束! 我们需要更多的挑战。
## 6. 那么数组呢?
数组是如何在EVM中存储的?像结构体或变量一样?
```solidity
// 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成本,我们会发现,这比非数组的情况下要高。
![img](https://img.learnblockchain.cn/2022/09/30/1*iK2TH6949B03SIY4auCVzg.png)
> 87746 Gas
有1个**SSTORE**来存储一个数组的长度。(+ 20000 gas)
有2个**SSTORE**来存储7和8的值。(+40000 gas)
有1个**SSTORE**到一个非零值槽,将数组的长度从1更新到2。
我们就快完成了......
## 7. 如何存储映射?
现在我们来谈谈映射,像数组一样,如何存储映射的所有值并不明显。(ERC20代币中的变量**balances**就是一个好例子)
我们知道,映射是一组**键值对**。
对比数组,在映射中存储值的方式非常相似,存储槽等于**SHA3(mapping_slot.key)**
(其中**.**是连接运算符)在这最后一部分,我们将验证这个公式。
让我们来编译这个最后的合约,**编译选项 没有优化(但仍然是solidity 0.8.7)**,并调用**modify()**函数!
```solidity
// 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 被推送到堆栈。
- 0f是**modify()**函数中第一个地址的余额。
- bc5d...f2 就像是 balance=15" 的地址。
在第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)**
- 余额变量的**mapping_slot**当然等于0(存储在0x20到0x40的内存中,而不是在开头位置,因为EVM使用小端结构)
- **key**等于**地址0xbc5d291d2165f130375b94c62211f594db48fef2**(存储在0x00到0x20的内存中)。
对于那些想知道**memory[0x00:0x40]**的目的是什么的人来说,**的答案是:目标是(仅仅)存储使用**keccak256进行Hash的操作数。
由于EVM对0x00和0x40之间的所有东西进行散列,我们的说法是正确的。
下面是这样一个操作的Gas成本:
![img](https://img.learnblockchain.cn/pics/20221028175641.png)
> 使用 65594 Gas
成本比存储在数组中要低(至少在开始时),因为mapping_slot(槽0)不包含任何东西,也没有被修改,所以少了一个**SSTORE**操作。
## 8. 结论
如果你读完了这篇文章,恭喜你! 这并不容易,但它是值得的。你现在对EVM的了解比99.9%的开发者还要多。
---
本翻译由 [Duet Protocol](https://duet.finance/?utm_souce=learnblockchain) 赞助支持。
- 原文链接: https://trustchain.medium.com/reversing-and-debugging-ethereum-evm-smart-contracts-part-3-ebe032a08f97
- 译文出自:登链翻译计划
- 译者:翻译小组 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
本文是关于通过调试理解EVM第 3 篇,本系列包含 7 篇文章:
- 第1篇:理解汇编
- 第2篇:部署智能合约
- 第3篇:存储布局是如何工作的?
- 第4篇:结束/中止执行的5个指令
- 第5篇:执行流 if/else/for/函数
- 第6篇:完整的智能合约布局
- 第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)
> 修改函数的Gas成本
但是,如果你第二次调用该函数,由于存储中的值是非零的,Gas成本会便宜很多。(每SSTORE 使用2200gas)
提示:每条在EVM上指令都要花费Gas,一个交易的Gas成本是所有指令的Gas总和(+21000Gas的基本成本),你可以在调试器标签中的 "步骤详情(step details)"部分看到Gas的使用。
这里,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=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 ||
- 首先(指令78),EVM 加载存储的槽 0,即0x030201。
- 其次(指令79-82),EVM对 ff00 取反(NOT),在32个字节的结果是 0xfffffffffffffffffffffffffffffffffffffffffffffffffff00ff 。
- 在指令83,2个结果进行与运算,即0x00000000000000000000000000000000000000000000000000030001(或0x030001)。 这和存储槽0是一样的,但是没有02(合约中的 "balance2"的部分),这是正常的!为什么? 这是因为在modify2()中,EVM修改了balance2。首先它需要擦除之前的结果,而不擦除balance和balance3(因为它们在同一个槽中),所以它通过使用0xfff...ff00ff掩码来 "清洗" 结果。
- 之后,0500被推入堆栈(指令84),最后的2个结果进行OR操作(指令85),最后的结果是:0x030501,"OR"的目的是在03和01的边上加上05。因此余额2被成功地修改,而没有改变余额和余额3。
这个0xfffffffffffffffffffffffffffffffffffffffffffffffffff00ff被称为 "掩码"。
如果我们想代替balance2修改balance3为5,我们应该使用掩码0xfffffffffffffffffffffffffffffffffffffffffffffffff00ffff(擦除balance3在0x00槽中的字节),在第四步,我们应该PUSH 050000.(05应该在这里,因为这里放置了balance3的存储。)
这就是为什么你在需要的时候应该使用较小的类型:它需要更少的Gas。
但是,不要滥用较小的类型,因为它增加了EVM执行的操作的数量(通过使用带有掩码的操作),所以它使用更多的Gas。
3. 使用不同类型的数据
让我们看看节省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)
4. 变量的位置是否有关系?
要了解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)架构(第一个在最后)。
5. 如何存储结构体?
如果我们用结构体做同样的事情,会发生什么?
// 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费也和以前一样:
好了,这对我们来说太容易了,直到现在,操作码之间没有什么区别。但我们还没有结束! 我们需要更多的挑战。
6. 那么数组呢?
数组是如何在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。
我们就快完成了......
7. 如何存储映射?
现在我们来谈谈映射,像数组一样,如何存储映射的所有值并不明显。(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 被推送到堆栈。
- 0f是modify()函数中第一个地址的余额。
- bc5d...f2 就像是 balance=15" 的地址。
在第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)
- 余额变量的mapping_slot当然等于0(存储在0x20到0x40的内存中,而不是在开头位置,因为EVM使用小端结构)
- key等于地址0xbc5d291d2165f130375b94c62211f594db48fef2(存储在0x00到0x20的内存中)。
对于那些想知道memory[0x00:0x40]的目的是什么的人来说,的答案是:目标是(仅仅)存储使用keccak256进行Hash的操作数。
由于EVM对0x00和0x40之间的所有东西进行散列,我们的说法是正确的。
下面是这样一个操作的Gas成本:
使用 65594 Gas
成本比存储在数组中要低(至少在开始时),因为mapping_slot(槽0)不包含任何东西,也没有被修改,所以少了一个SSTORE操作。
8. 结论
如果你读完了这篇文章,恭喜你! 这并不容易,但它是值得的。你现在对EVM的了解比99.9%的开发者还要多。
本翻译由 Duet Protocol 赞助支持。
本文参与区块链技术网 ,好文好收益,欢迎正在阅读的你也加入。
- 发表于 2022-10-28 18:16
- 阅读 ( 228 )
- 学分 ( 12 )
- 分类:智能合约
评论