深入理解EVM系统(2)
作者:noxx
译者:Kurt Pan
在深入理解EVM系统(1)中,我们探讨了 EVM 如何根据调用的合约函数知道要运行哪个字节码,这有助于我们了解调用栈、calldata、函数签名和 EVM 操作码指令。
在第 2 部分中,我们将沿着“内存”之路走一趟,全面回顾合约内存是什么以及它在 EVM 下的工作原理。
1进入内存的旅程
在深入理解EVM系统(1)中,我们查看了来自remix的默认 1_Storage.sol
合约。
然后我们生成了字节码并聚焦到函数选择相关部分。在本文中,我们将重点关注合约运行时字节码的前 5 个字节。
6080604052
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这 5 个字节代表空闲内存指针的初始化。要充分了解这意味着什么以及这些字节的作用,必须首先建立对管理合约内存的数据结构的理解。
内存数据结构
合约内存是一个简单的字节数组,其中数据可以以 32 字节(256 位)或 1 字节(8 位)的块存储,并以 32 字节(256 位)的块读取。下图说明了这种结构以及合约内存的读/写功能。
图源:https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf
此功能由对内存进行操作的 3 个操作码决定。
MSTORE (x, y)
- 存储从内存位置“x”开始的 32 字节(256 位)值“y”MLOAD (x)
- 从内存位置“x”开始加载 32 字节(256 位)到调用栈MSTORE8 (x, y)
- 在内存位置“x”(32 字节栈值的最低有效字节)存储一个 1 字节(8 位)值“y”。
你可以将内存位置简单地视为开始写入/读取数据的数组索引。如果你想写入/读取超过 1 个字节的数据,你只需继续从下一个数组索引写入或读取。
EVM 游乐场
这个EVM 游乐场将有助于巩固你对这 3 个操作码的作用以及内存位置如何工作的理解。单击 Run 和右上角的↻以逐步运行操作码并查看栈和内存是如何更改的。(操作码上方有注释来描述每个部分的作用)。
https://tinyurl.com/2p93fz6p
在运行上面的 EVM 游乐场时,你可能已经注意到一些奇怪的事件。首先,当我们使用 MLOAD8
将单个字节 0x22
写入内存位置 32 (0x20
) 时,内存从
变成了
你可能会问,我们只添加了 1 个字节,所有的这些附加零是怎么回事?
内存扩展
当你的合约写入内存时,你必须为写入的字节数付费。如果你正在写入之前未写入的内存区域,则第一次使用它会产生额外的内存扩展成本。
写入以前未触及的内存空间时,内存以 32 字节(256 位)为增量扩展。
前 724 个字节的内存扩展成本呈线性增长,之后呈二次方增长。
上述例子中,在我们在位置 32 处写入 1 个字节之前,内存是 32 个字节。此时我们开始写入未触及的内存,结果,内存又增加了 32 个字节,增加到 64 个字节。
请注意,内存中的所有位置最初都明确定义为零,这就是为什么我们看到 22000000000000000000000000000000000000000000000000000000000000000
被添加到我们的内存中的原因。
内存是一个字节数组
你可能注意到的第二件事发生在我们从内存位置 33 (0x21
) 运行 MLOAD
时。我们将以下值返回到调用栈。
3300000000000000000000000000000000000000000000000000000000000000
我们能够从一个非 32的因子开始读取。
请记住,内存是一个字节数组,这意味着我们可以从任何内存位置开始读取(和写入),而不限于 32 的倍数。内存是线性的,可以在字节级别进行寻址。
内存只能在函数中新建。它可以是新实例化的复合类型,如数组/结构体(例如通过
new int[...]
),也可以从storage
引用变量中复制。
现在我们已经了解了数据结构,让我们回到空闲内存指针。
空闲内存指针
空闲内存指针只是一个指向空闲内存开始位置的指针。它确保智能合约跟踪哪些内存位置已写入,哪些未写入。这可以防止合约覆盖掉已分配给另一个变量的一些内存。
当一个变量被写入内存时,合约将首先引用空闲内存指针来确定数据应该存储在哪里。然后,它通过记录要写入新位置的数据量来更新空闲内存指针。这两个值的简单相加将产生新的空闲内存将开始的位置。
freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer
字节码
如前所述,空闲内存指针是通过这 5 个操作码在运行时字节码的开头定义的。
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这几句操作码有效地陈述了空闲内存指针位于内存中字节 0x40
(十进制中的 64)处,并且具有值 0x80
(十进制中的 128)。
而为什么使用 0x40
和 0x80
这两个值呢?这个问题的答案见下面陈述。
Solidity 的内存布局保留了四个 32 字节的槽:
0x00
-0x3f
(64 字节):暂存空间
0x40
-0x5f
(32字节):空闲内存指针
0x60
-0x7f
(32 字节):零槽
我们可以看到,0x40
是solidity空闲内存指针的预定义位置。值 0x80
只是在 4 个保留的 32 字节槽之后可写入的第一个内存字节。
简要说一下每个保留槽的功能:
暂存空间,可以跨语句使用,即在内联汇编之中和用于哈希算法。
空闲内存指针,当前分配的内存大小,空闲内存的起始位置,初始化为
0x80
。零槽,用作动态内存数组的初始值,永远不应写入。
真实合约中的内存
为了巩固到目前为止所学的知识,我们将看看内存和空闲内存指针如何在真正的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()
函数,然后进入调试模式以逐步执行操作码。
https://remix.ethereum.org/ https://remix-ide.readthedocs.io/en/latest/tutorial_debug.html
我已将一个简化版本取出来放到了EVM游乐场中,并会在下面运行。
https://tinyurl.com/424a4xd4
简化版本按顺序组织操作码,删除任何 JUMP
和任何与内存操作无关的代码。注释已添加到代码中,为正在执行的操作提供上下文。代码分为 6 个不同的部分,我们将深入研究。
强调一下使用EVM游乐场并自己动手单步执行操作码的重要性,这将大大提高你的学习效果。现在让我们深入研究这 6 个部分。
空闲内存指针初始化(第 1-15 行)
首先,我们有上面已经讨论过的“空闲内存指针初始化”部分。将值0x80
(十进制的 128)压入栈。这是空闲内存指针的值,由 Solidity 的内存布局决定。在这个阶段,内存中没有任何东西。
接下来,我们再次推入由 Solidity 的内存布局确定的空闲内存指针位置 0x40
(十进制为 64)。
最后,我们调用 MSTORE
,它将栈的第一项 0x40
弹出以确定在内存中写入的位置,第二个值 0x80
作为写入的内容。
这给我们留下了一个空栈,但我们现在已经填充了一些内存。此内存表示为十六进制,其中每个字符代表 4 位。
我们在内存中有 192 个十六进制字符,这意味着我们有 96 个字节(1 个字节 = 8 位 = 2 个十六进制字符)。
回顾一下 Solidity 的内存布局,前 64 个字节将被分配为暂存空间,接下来的 32 个字节将用于空闲内存指针。
这正是下面的内容。
内存分配变量“a”和空闲内存指针更新(第 16-34 行)
对于其余部分,我们将跳到每个部分的结束状态,并简要概述发生的事情。可以通过EVM 游乐场查看各个操作码步骤。
为变量“a”(bytes32[5]
)分配下一个内存,并更新空闲内存指针。
编译器将通过数组大小和默认数组元素大小确定需要多少空间。
Solidity 中的内存数组中的元素总是占据 32 字节的倍数(这甚至适用于
bytes1[]
,但不适用于bytes
andstring
)
数组大小乘以 32 字节后可以告诉我们需要分配多少内存。
本例中,计算 5 * 32 会产生十六进制的 160 或 0xa0
。我们可以看到它被压入栈并添加到当前空闲内存指针 0x80
(十进制中的 128)以获取新的空闲内存指针值。
这将返回 0x120
(十进制的 288),我们可以看到它已被写入空闲内存指针位置。
调用栈将变量“a”的内存位置保存在栈 0x80
上,以便以后可以在需要时引用它。0xffff
代表一个 JUMP
位置,可以忽略,因为它与内存操作无关。
内存初始化变量“a”(第35-95行)
现在已经分配了内存并且更新了空闲内存指针,我们需要为变量“a”初始化内存空间。由于该变量只是被声明并且没有被赋值,它将被初始化为零。
为此,EVM 使用了 CALLDATACOPY
,它接受 3 个变量。
memoryOffset
(将数据复制到哪个内存位置)calldataOffset
(要复制的calldata中的字节偏移量)size
(要复制的字节大小)
本例中,memoryOffset
是变量“a”的内存位置(0x80
)。calldataOffset
是calldata的实际大小,因为我们不想复制任何calldata,我们想用零值初始化内存。最后,size
是 0xa0
或 160 字节,因为这是变量的大小。
我们可以看到我们的内存已经扩展到 288 字节(包括零槽),并且栈再次保存了变量的内存位置和调用栈上的 JUMP
位置。
内存分配变量“b”和空闲内存指针更新(第 96-112行)
这与变量“a”的内存分配和空闲内存指针更新相同, 但这次是针对bytes32[2] memory b
.
内存指针更新为 0x160
(十进制为 352),等于先前的空闲内存指针 288 加上新变量的大小(以字节 64 为单位)。
注意到空闲内存指针已在内存中更新为 0x160
,我们现在在栈上拥有变量“b”(0x120
)的内存位置。
内存初始化变量“b”(第113-162行)
与变量“a”的内存初始化相同。
注意到内存已增加到 352 字节。栈仍然保存 2 个变量的内存位置。
为 b[0] 赋值(第163-207行)
最后,我们开始为数组“b”索引 0 赋值。代码指出 b[0]
的值应该为 1。
该值被压入栈 0x01
。接下来会有一个左位移,但是位移的输入为 0,这意味着值不改变。
接下来,要写入 0x00
的数组索引位置被压入栈,并检查该值是否小于数组的长度0x02
。否,则执行跳转到处理此错误状态的不同部分的字节码。
MUL
(乘法)和 ADD
操作码用于确定需要将值写入内存中的哪个位置以使其对应于正确的数组索引。
0x20 (32 in decimal) * 0x00 (0 in decimal) = 0x00
内存数组是 32 字节的元素,因此该值表示数组索引的起始位置。鉴于我们正在写入索引 0,我们没有偏移量。
0x00 + 0x120 = 0x120 (288 in decimal)
ADD
用于将此偏移值添加到变量“b”的内存位置。鉴于我们的偏移量为 0,我们将直接将数据写入分配的内存位置。
最后一个 MLOAD
将值 0x01
加载到这个内存位置 0x120
。
下图显示了函数执行结束时的系统状态。所有栈项都已弹出。
实际上在 remix 中还有一些项留在栈上,一个 JUMP
位置和函数签名,但是它们与内存操作无关,因此在 EVM 游乐场中被省略了。
我们的内存已更新为包含 b[0] = 1
赋值,在我们内存的倒数第三行,0 值变成了 1。
你可以验证该值位于正确的内存位置,b[0]
应占用位置 0x120 - 0x13f
(字节 289 - 320)。
OK这部分可以完结撒花啦🎉。虽然有大量信息你可能还要再消化一下,但我们现在对合约内存的工作原理有了深入的了解。下次我们需要编写一些solidity代码时,这将帮我们大忙。当你运行一些合约操作码并看到某些内存位置(0x40
)不断弹出时,你现在可以确切地知道它们的含义了。
下一部分见!