原文:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b?s=r
译文出自:Shenstone。
译者:Shenstone。
校对:Shenstone。
本文永久链接:https://learnblockchain.cn/article/3684
在第一部分,我们分析了remix的第一个合约示例1_Storage.sol。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/**
* @title Storage
* @dev Store & retrieve value in a variable
*/
contract Storage {
uint256 number;
/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}
/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}
然后我们编译生成了字节码,并关注了与函数选择有关的部分。在这篇文章中,我们将重点讨论合约运行时字节码的前5个字节。
6080604052
<!---->
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这5个字节代表了 "自由内存指针(free memory pointer)"的初始化。为了充分了解这意味着什么,以及这些字节的作用,我们必须首先建立你对管理合约内存(memory)的数据结构的认知。
合约内存是一个简单的字节数组,数据可以以32字节(256位)或1字节(8位)为单位存储,以32字节(256位)为单位读取。下面的图片说明了这种结构以及合约内存的读/写功能。
这种功能是由3个操作码决定的,这些操作码对内存进行操作。
这个EVM操练场(EVM Playground)将有助于巩固你对这3个操作码的理解以及内存位置的工作原理。点击 "运行 "和右上方的卷曲箭头,步进操作码,看看堆栈和内存是如何被改变的。(在操作码的上方有注释,描述了每一部分的作用)
当执行完上面的操作码,你可能会注意到一些奇怪的现象。首先,当我们写入一个单字节数据0x22到内存中,然后用MLOAD8到内存位置0x20(十进制:32)取数据时,得到的不是
而是

你也许会问了,我们只写入了一个字节,怎么会有这么多的零?
当你的合约写到内存时,你必须为所写的字节数付费。如果你写到一个以前没有被写过的内存区域,那么第一次使用该区域会有一个额外的内存扩展费用。 当写到以前未使用的内存空间时,内存以32字节(256位)的增量进行扩展。
内存拓展成本在最初的724字节成线性比例增加,之后会以二次方比例增加 上面例子中我们在写入1个字节前已经使用了32字节内存。再继续写入这一字节,我们开始向之前未使用的内存写入,结果,内存又被扩大了32字节的增量,达到64字节。 请注意,内存中的所有位置最初都被很好地定义为零,这就是为什么我们看到2200000000000000000000000000000000000000000000000000000000000000000000添加到我们的内存。
第二个关键现象你可能已经注意到了,当我们运行MLOAD操作码从内存位置0x21(十进制:33)读取数据时。我们获得一个返回值,并被压入了栈区(stack)。 这意味着我们可以从非32字节对齐的内存位置读取数据。 记住内存是一个字节数组,这意味着我们可以从任何内存位置开始读(和写)。我们不受限制于32的倍数。内存是线性的,可以在字节级别上寻址。
内存只能在一个函数中新创建。它可以是新实例化的复杂类型,如数组/结构(例如通过new int[...])或从存储引用(storage)的变量中复制。 现在我们对数据结构有了一丢丢的了解,让我们回到自由内存指针(free memory pointer)的问题上。
Free Memory Pointer只是一个指向自由内存开始位置的指针。它确保智能合约知道已写入和未写入的内存位置。 这可以防止合约覆盖一些已经分配给另一个变量的内存。 当一个变量被写入内存时,合约将首先参考Free Memory Pointer,以确定数据应该被存储在哪里。 然后,它更新Free Memory Pointer,指出有多少数据将被写入新的位置。这两个值的简单相加将产生新的自由内存的起始位置。
freeMemoryPointer + dataSizeBytes(数据大小) = newFreeMemoryPointer
如前面所写,freeMemoryPointer是通过这5个字节码相对应的操作码定义的
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这些实际上说明了freeMemoryPointer在内存中位于memory中的0x40(十进制:64)位置,其值为0x80(十进制128). 你可能立马想问咯,为啥会使用0x40和0x80呢?这个问题下面会解释。
Solidity的内存布局保留了4个32字节的插槽:
- 0x00 - 0x3f (64 bytes): scratch space
- 0x40 - 0x5f (32 bytes): free memory pointer
- 0x60 - 0x7f (32 bytes): zero slot 我们可以看到,0x40是solidity为freeMemoryPointer预留的位置。值0x80只是在4个保留的32字节的插槽之后第一个可写的位置。 我们快速浏览下每个保留部分的作用。
为了巩固我们到目前为止所学到的知识,我们要看一下内存和空闲内存指针是如何在真实的solidity代码中更新的。 我创建了一个MemoryLane合约,并有意让它变得非常简单。它有一个函数,只是定义了两个长度为5和2的数组,然后给b[0]赋值为1。尽管很简单,但当这3行代码被执行时,会发生很多事情。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3
contract MemoryLane {
function memoryLane() public pure {
bytes32[5] memory a;
bytes32[2] memory b;
b[0] = bytes32(uint256(1));
}
}
为了查看这个solidity代码在EVM中的执行细节,可以把它复制到一个remix IDE中。复制后,你可以编译代码,部署它,运行memoryLane()函数,然后进入调试模式,逐步浏览操作代码(关于如何做的说明,见这里)。我已经将一个简化版本提取到EVM Playground中,并将在下面运行它。 简化版按顺序组织操作码,去掉了JUMP和任何与内存操作无关的操作码。代码中加入了注释,以解释现在在做啥。这段代码被分成6个不同的部分,我们将深入研究。 我不能不强调,使用EVM Playground和自己按步执行操作码是多么重要。这将大大促进你的学习。现在让我们来看看这6个部分。
首先,咱已经在上面初步讨论过FreeMemoryPointer的初始化,一个0x80的值被放入stack中。这是FreeMemoryPointer的值,由solidity的内存布局决定。在这个阶段,我们的内存没有任何东西.

接下来,把FreeMemoryPointer的内存位置0x40放到stack中。

最后,我们调用MSTORE,从stack中弹出0x40,以确定写入内存的位置,第二个值0x80作为写入内容。
这样stack就空了,但现在内存中存在一些值了。这个内存表示是十六进制的,每个字符代表4位。
我们在内存中有192个十六进制字符,这意味着我们有96个字节(1字节=8比特=2个十六进制字符)。
如果我们参考Solidity的内存布局,我们知道前64个字节将被分配为scratch空间,接下来的32个字节将是Free Memory Pointer。

对于剩下的部分,为了简洁起见,我们将跳到每一部分的结束状态,并对所发生的事情做一个高层次的概述。各个操作码的步骤可以通过EVM操场看到。 接下来为变量 "a"(bytes32[5])分配内存,并更新Free Memory Pointer。 编译器将通过数组大小和默认的数组元素大小确定需要多少空间。
请记住Solidity中内存数组中的元素总是占据32字节的倍数(这甚至对bytes1[]来说也是如此,但对bytes和字符串来说不是如此) 数组的大小乘以32个字节,告诉我们需要分配多少内存。 在这种情况下,5*32的计算结果是160,即十六进制的0xa0。我们可以看到这个值被推入stack,并与当前的Free Memory Pointer位置0x80(十进制为128)相加,得到新的Free Memory Pointer。 这将返回0x120(十进制288),我们可以看到它已被写入Free Memory Pointer位置。 调用堆栈将变量 "a "的内存位置0x80保留在stack中,这样它就可以在以后需要时引用它。0xffff代表一个JUMP位置,可以被忽略,因为它与内存操作无关。

现在,内存已经分配完毕,Free Memory Pointer也已更新,我们需要为变量 "a "初始化内存空间。由于该变量只是被声明而没有被分配,它将被初始化为零值。 为了做到这一点,EVM使用CALLDATACOPY,它接收了3个变量。

这与变量 "a "的内存分配和Free Memory Pointer更新相同,只是这次是针对 "byte32[2]内存b"。 Free Memory Pointer被更新为0x160(十进制352),这等于之前的Free Memory Pointer位置288加上新变量的大小(字节64)。
请注意,Free Memory Pointer在内存中已经更新到了0x160,我们现在在stack上有变量 "b "的内存位置(0x120)。
与变量 "a "的内存初始化相同。
请注意,内存已经增加到352字节。stack仍然保存着2个变量的内存位置。
最后,我们要给数组 "b "的索引0赋值。代码指出b[0]应该有一个1的值。 这个值0x01被push到stack的。接下来会发生一个位的左移,但是位移的输入是0,意味着我们的值不会改变。 接下来,要写到0x00的数组索引位置被推到stack中,并进行检查以确认这个值小于数组的长度0x02。如果不是,执行就会跳到其他字节码,以处理这个错误状态。 MUL(乘法)和ADD操作码用于确定在内存中需要写入的数值,以使其对应于正确的数组索引。
0x20 (32 in decimal) * 0x00 (0 in decimal) = 0x00
记住内存数组是32字节的元素,所以这个值代表数组索引的起始位置。鉴于我们正在向索引0写入,所以偏移值为0。
0x00 + 0x120 = 0x120 (288 in decimal)
ADD是用来将这个偏移值添加到变量 "b "的内存位置。鉴于我们的偏移量是0,我们将直接把数据写到指定的内存位置。
最后,一个MLOAD将数值0x01加载到这个内存位置0x120。
下面的图片显示了函数执行结束时的系统状态。stack里所有的变量都已经被弹出了。
注意在实际的remix中,有几个变量留在stack上了,一个JUMP位置和函数签名,但是它们与内存操作无关,因此在EVM playground中被省略了。
我们的内存已经被更新,包括b[0]=1的赋值,在我们内存的倒数第三行,一个0值已经变成了1。
你可以验证该值是否在正确的内存位置,b[0]应该占据位置0x120 - 0x13f(字节289 - 320)。

我们终于搞定啦,吸收这么多知识,帮助我们对合约内存的工作有了坚实的理解。下次我们需要写一些solidity代码时,这将对我们有好处。 当你执行一些合约操作码,看到某些内存位置不断pop出(0x40)时,你现在就会知道它们的确切含义。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码