本文作者:Shenstone。[1]
在第一部分[2],我们分析了 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 字节的插槽:
为了巩固我们到目前为止所学到的知识,我们要看一下内存和空闲内存指针是如何在真实的 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()函数,然后进入调试模式,逐步浏览操作代码(关于如何做的说明,见这里[3])。我已经将一个简化版本提取到 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)时,你现在就会知道它们的确切含义。
原文链接:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b?s=r
[1]
Shenstone。: https://learnblockchain.cn/people/2033
[2]
第一部分: https://learnblockchain.cn/article/3647
[3]
这里: https://remix-ide.readthedocs.io/en/latest/tutorial_debug.html