深入EVM虚拟机-part2
前言:
part1主要写了关于 EVM 如何调用通过被调用的合约函数知道需要运行哪个字节码,其中,主要了解了调用栈、calldata、签名函数和 EVM 操作码指令。
接下来在 part2 中主要了解合约的内存,以及他在 EVM 上的工作形式
内存
依然使用在 part1 中在 Remix 上的示例代码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/**
* @title Storage
* @dev Storage & retrieve value in a variable
*/
contract Storage{
uint256 number;
/**
* @dev Storage 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 个字节表示初始化“空闲内存指针”操作。要完全理解这些字节码的作用,首先需要理解管理支配合约内存的数据结构。
内存数据结构
合约内存是一个简单的字节数组,其中数据存储可以使用 32 字节(256 位)或 1 字节(8 位)的数据块存储数据,但是读取时,每次只能读取固定大小的 32 字节(256 位)的数据块。下面的图片说明了此结构以及合约内存的读/写功能:
这个功能是由操作内存的 3 个操作码觉得的。
MSTORE(x,y)
:从内存位置“x”开始存储一个 32 字节(256 位)的”y”值MLOAD(x)
从内存位置”x”开始将 32 字节(256)位加载到调用栈上MSTORE8(x,y)
:从内存位置”x”存储一个 1 字节(8 位)的值”y”(32 字节栈值的最低有效字节)
内存位置可以简单地理解为开始写入/读取数据的数组索引。如果想写入/读取超过 1 个字节的数据,只需要继续从下一个数组索引写入或读取。
EVM Playground
EVM Playground 有助于巩固我们这 3 个操作码的运行原理、作用以及内存位置的理解。单机 Run 和右上角的的箭头进行调试来查看堆栈和内存是如何更改的。(操作码上方有注释来描述每个部分的作用)
在运行调试中,首先,我们使用 MSTORE8
将单个字节 0x22
写入内存位置 32(0x20)
时。
内存从:
改变为:
但是,上面只增加了 1 个字节,但是却多了好多个 0。
内存拓展
当合约写入内存时,需要为写入的字节数支付 Gas,也就是扩大内存的开销,如果我们正在写入一个以前没有写入到内存区域,那么第一次使用它会产生额外的内存拓展开销。
写入之前从未触及的内存空间时,内存以 32 字节(256 位)为增量拓展。前 724 个字节,内存拓展呈线性增长,之后呈二次方增长。(由以太坊黄皮书公式 326 扩大内存的 Gas 开销得出,公式为:
拓展内存时为每个额外的字的开销。其中 a 是合约调用中写入的最大内存位置,以 32 字节为单位,用 1024 字节内存为例,那么 a = 32。)
在位置 32 处写入 1 字节之前,我们的内存是 32 个字节。此时我们开始向未触及的内存空间写入内容,结果,内存增加了 32 个字节,增加到了 64 个字节。内存中所有位置的都初始被定义为 0 ,这也是为什么在上一步中,2200000000000000000000000000000000000000000000000000000000000000
被添加到内存中的原因。
内存是一个字节数据
调试过程中,我们可能注意到,第二件事发生在我们从内存位置33 (0x21)
运行 MLOAD 时。我们将以下值返回调用栈。
3300000000000000000000000000000000000000000000000000000000000000
内存读取可以从一个非 32 字节元素开始。
内存是一个字节数组,这意味着可以从任何内存位置开始读取(和写入)。我们不限于 32 的倍数。内存是线性的,可以在字节级别进行寻址。内存只能在函数中新建。它可以是新实例化的复杂类型,如数组/结构(例如,通过新建一个int[...]
)或从存储引用的变量中复制。
空闲内存指针
空闲内存指针只是一个指向空闲内存开始位置的指针。他确保智能合约可以跟踪到哪些内存位置已写入,哪些时未写入。这可以防止合约覆盖已分配给另一个变量的某些内存。当一个变量被写入内存时,合约将首先引用空闲内存指针来确定数据应该存储在哪里。然后,它通过记录要写入新位置的数据量来更新空闲内存指针。这两个值的简单相加将产生新的空闲内存开始得到位置。
字节码
空闲内存指针是通过下面这 5 个操作码在运行时字节码的定义的
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这些操作码声明空闲内存指针位于内存中字节0x40
(十进制中的 64 )处,值为0x80
(十进制中的 128 )。
Solidity 的内存布局保留了 4 个 32 字节的插槽:
0x00 - 0x3f(64 bytes)
:暂存空间,可用于语句之间,及内联汇编和哈希散列方法0x40 - 0x5f(32 bytes)
:空闲内存指针,当前分配的内存大小,空闲内存的起始位置,初始化为0x80
。0x60 - 0x7f(32 bytes)
:插槽 0 ,用作动态内存数组的初始值,永远不应写入
我们可以看到,0x40
是空闲内存指针的预定义位置。而值0x80
只是在 4 个 32 字节保留值插槽之后可写入的第一个内存字节。
合约中的内存
接下来,看看内存和空闲内存指针式如何在 Solidity 代码中更新的。
我们创建 MemoryLane 合约来进行演示。合约的memoryLane()
定义了两个长度分别为5 和 2 的数组,并将uint256
类型的 1 复制给 b[0]
。
// 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));
}
}
要查看合约代码在 EVM 中执行的详细信息可以将其复制到 Remix IDE 中编译并部署合约。调用memoryLane()
后进入 DeBug 摸索来逐步执行操作码(以上操作可参考:https://remix-ide.readthedocs.io/en/latest/tutorial_debug.html)。
将简化版的操作码提取到 EVM Playground 中,可以通过这个链接查看具体的操作码及注释信息( https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b#:~:text=version%20into%20an-,EVM%20Playground,-and%20will%20run )
这里将操作码分成 6 个不同的部分依次解读,删除了 JUMP 以及内存操作无关的操作码,同时将注释添加了进来,方便查看当前执行了什么操作。
1)空闲指针初始化(EVM Playground 操作码代码 1-15 行)
////////////////////////////////////////
// Free Memory Pointer Initialisation //
////////////////////////////////////////
// value to store for free memory pointer 0x80 = 128 in decimal
PUSH1 0x80
// location for free memory pointer 0x40 = 64 in decimal
PUSH1 0x40
MSTORE
// jump location (required to prevent stack underflow)
PUSH2 0xffff
首先,0x80(十进制128)先入栈,这是由 Solidity 内存布局规定的值,当前内存中没有任何东西
接下来,由 Solidity 内存布局确定的空闲内存指针位置 0x40(十进制 64)入栈。
最后,我们调用 MSTORE,它将第一项从栈 0x40 弹出,以确定在内存中写入的位置,并将第二个值 0x80 作为写入的内容。这样留下了一个空栈,但已经填充了一部分到内存中。内存由十六进制字符表示,其中每个字符代表 4 位。例如:在内存中有 192 个十六进制字符,这意味着我们有 96 个字节(1 字节 = 8位 = 2个十六进制字符),如果我们回顾 Solidity 的内存布局会发现,前 64 个字节将被分配位暂存空间,接下来的 32 个字节将用于空闲内存指针。
2)内存分配变量”a”和空闲内存指针更新(EVM Playground 第 16-34 行)
//////////////////////////////////////////////////////////////////
// Memory Allocation Varibale "a" & Free Memory Pointer Update ///
//////////////////////////////////////////////////////////////////
// load free memory pointer
PUSH1 0x40
MLOAD
// duplicate free memory pointer
DUP1
// 0xa0 = 160 in decimal, 32 * 5 = 160 first array is length 5
PUSH1 0xa0
// free memory pointer (0x80) + space for array (0xa0) = new free memory pointer
ADD
// Save this new value 0x120 to the free memory location
PUSH1 0x40
MSTORE
接下来的部分,我们将跳到每个部分的结束状态,并简洁概述。
首先,为变量 “a”(bytes32[5])分配下一个内存,并更新空闲内存指针。编译器将通过数组大小和默认数组元素大小确定需要多少空间。Solidity 中内存数组中的元素都是占据 32 字节的倍数(这同样适用于 bytes1[],但 bytes 和 string 不适用)。当前需要分配的内存为 5 * 32 字节,表示为 160 或 0xa0(16 进制的 160)。我们可以看到它被压入栈中并添加到当前空闲内存指针 0x80(十进制中的 128)来获取新的空闲内存指针值。这将返回 0x120(十进制的 288 = 128 + 160),我们可以看到它已被写入空闲内存指针位置。调用栈将变量 “a” 的内存位置保存在栈 0x80 上,以便以后可以在需要时引用它。0xffff 代表一个 JUMP(无条件跳转) 位置,可以忽略,因为它与内存操作无关。
3)内存初始化变量”a”(EVM Playground 第 35-95 行)
/////////////////////////////////////////
// Memory Initialisation Varaible "a" ///
/////////////////////////////////////////
// duplicate 0x80
DUP1
// push 0x05 = 5 in decimal (array Length)
PUSH1 0x85
// Swqp the top items on the stack in this case 0x05 and 0x80
SWAP1
// push 0x20 = 32 in decimal (array item size)
PUSH1 0x20
// Duplicate the 3rd item on the stack in this case 0x05 to the top of the stack
DUP3
// 0x05 * 0x20 = 5 * 32 in decmial = 160 on top of the stack (size of array in bytes)
MUL
// Duplicate 0xa0 = 160 in decimal
DUP1
// Returns size of calldata in bytes currently just function signature = 0x04 or 4 in decmial
CALLDATASIZE
// duplicate 4th item on stack (0x80)
DUP4
// 0x80 (byte offset in the memory where the result will be copied.), 0x04 (byte offset in the calldata to copy.), 0xa0 (byte size to copy.) this offsets the 4 bytes in our call data with a size of 0xa0 which yeild a 160 bit set of 0's to be stored at the free memory pointer location
// this effectively initialises our array in memory
CALLDATACOPY
// The remaining lines in this section manipulate the stack to ensure we have the memory location of variable "a" and removes any items that are no longer needed
// duplicate 0xa0
DUP1
// duplicate 0x80
DUP3
// new free memory pointer as before
ADD
// swap 1st (0x120) item on the stack and 3rd (0x80)
SWAP2
// pop top item off stack (0x80)
POP
// pop top item off stack (0xa0)
POP
// Swap top 2 items 0x120 & 0x05
SWAP1
// pop top item off stack (0x05)
POP
// pop top item off stack (0x120)
POP
// swap top 2 items 0x80 & 0xb6 (jump location)
SWAP1
// simulating a JUMP remove the top item off stack with POP
POP
// Simulated jump location
PUSH2 0xffff
// Simulated jump location
PUSH2 0xffff
// simulating a JUMP, remove the top item off stack with POP
POP
已经分配好了内存并且更新了空闲内存指针,接下来需要为变量”a”初始化内存空间。由于该变量只是被声明并没有被赋值,它将被初始化为零值。
EVM 通过使用了 CALLDATACOPY (复制消息数据)操作码来进行操作,其中存在 3 个变量。
- memoryOffset/destOffset(将数据复制到的内存位置)
- calldataOffset/offset(需要复制的 calldata 中的字节偏移量)
- size/length(要复制的字节大小)
- 表达式:
memory[destOffset:dedstOffset+length] = msg.data[offset:offset+length]
在这个例子中,memoryOffset(destOffset)
是变量”a”(0x80)的内存位置。calldataOffset(Offset)
是实际 calldata 的大小,因为并不需要复制任何 calldata,所以初始化内存为零,最后,传入的变量为 0xa0(十六进制的 160)
这是可以看到我们的内存已经拓展到 288 字节(这包括插槽 0 ),并且调用栈再次保存了变量的内存位置和以及栈上的 JUMP 地址。
4)内存分配变量 “b” 和空闲内存指针更新(EVM Playground 第 96-112 行)
/////////////////////////////////////////////////////////////////
// Memory Allocation Varibale "b" & Free Memory Pointer Update //
/////////////////////////////////////////////////////////////////
// free memory pointer load in
PUSH1 0x40
MLOAD
// duplicate free memory pointer (0x120)
DUP1
// 0x40 = 64 in decimal, 32 * 2 = 64 second array is length 2
PUSH1 0x40
// free memory pointer (0x120) + space for array (0x40) = new free memory pointer
ADD
// save new free memory pointer value at free memory location 0x40
PUSH1 0x40
MSTORE
这与变量 “a” 的内存分配和空闲内存指针更新相同,只是这次是针对 “bytes32[2] memory b”。内存指针更新为 0x160(十进制为 352),等于先前的空闲内存指针 288 加上新变量的大小 64(以 bytes 64 为单位)。空闲内存指针已在内存中更新为 0x160,那么现在在栈上就拥有变量 “b”(0x120)的内存位置。
5)内存初始化变量 “b”(EVM Playground 第 113-162 行)
////////////////////////////////////////
// Memory Initialisation Variable "b" //
////////////////////////////////////////
// duplicate 0x120 (memory start location for variable "b")
DUP1
// 0x02 = 2 in decimal = array length
PUSH1 0x02
// swap top 2 items 0x02 & 0x120
SWAP1
// 0x20 = 32 in decimal (array item size in bytes)
PUSH1 0x20
// duplicate 3rd item on the stack 0x02
DUP3
// 0x02 * 0x20 = 0x40 = 64 (amount of bytes in memory to initialise)
MUL
// duplicate 0x40 (free memory pointer location)
DUP1
// same as before 4 bytes for function signature 0x04
CALLDATASIZE
// duplicate 4th item on the stack = 0x120
DUP4
// 0x120 (byte offset in the memory where the result will be copied.), 0x04 (byte offset in the calldata to copy.), 0x40 (byte size to copy.)
CALLDATACOPY
// The remaining lines in this section manipulate the stack to ensure we have the memory location of variable "a" and removes any items that are no longer needed
//duplicate the top of the stack 0x40
DUP1
// duplicate 3rd item on the stack 0x120
DUP3
// add together yields free memory pointer value
ADD
// swap 0x160 & 0x120
SWAP2
// pop top item off stack (0x120)
POP
// pop top item off stack (0x40)
POP
// swap 0x160 & 0x02
SWAP1
// pop top item off stack (0x02)
POP
// pop top item off stack (0x160)
POP
// jump location to top of the stack 0xbe
SWAP1
// simulate jump pop jump location off stack
POP
与变量 “a” 的内存初始化相同。现在内存已增加到 352 字节,栈内仍然保存 2 个变量的内存位置。
6)b[0] 赋值(EVM Playground 第 163-207 行)
//////////////////////////
// Assign Value to b[0] //
//////////////////////////
// push 0x01, value to add b[0]
PUSH1 0x01
// push 0x00
PUSH1 0x00
// left shift operation no shift, first input is 0
SHL
// duplicate 2nd item on stack (0x120)
DUP2
// push 0x00 = [0] where in the array should this item go
PUSH1 0x00
// push 0x20 = 64 bytes the length of the array
PUSH1 0x02
// duplicate 2nd item on stack (0x00)
DUP2
// 0x00 < 0x20 = true = 0x01 (check the user is not trying to store a value at a location that doesn't exist in the array)
LT
// jump location
PUSH2 0x00d7
// 2 POPs since this is a JUMPI (checking if LT returned true or false)
// simulate JUMPI
POP
// simulate JUMPI
POP
// push 0x20 (32 bytes aray item size)
PUSH1 0x20
// 0x20 * 0x00 = 0x00 = 0 in decimal (array item size * index to determine byte offset)
MUL
// 0x00 + 0x120
ADD
// duplicate 2nd on stack 0x01 (value for b[0])
DUP2
// duplicate 2nd on stack 0x120 (memory location for b[])
DUP2
// store 0x01 at memory location 0x120
MSTORE
// clean up stack
POP
POP
POP
POP
最后,我们开始为数组 “b” 索引 0 赋值。代码指出 b[0] 的值应该为 1。该值被压入栈 0x01。接下来发生向左移位,但是移位的输入为 0,这意味着我们的值不会改变。接下来,要写入 0x00 的数组索引位置被压入堆栈,并检查该值是否小于数组 0x02 的长度。如果不是,则执行跳转到处理此错误状态的字节码的不同部分。MUL(乘法)和 ADD(加法) 操作码用于确定需要将值写入内存中的哪个位置以使其对应于正确的数组索引。
0x20 (10 进制为 32) * 0x00 (10 进制为 0) = 0x00
需要记住,内存数组是 32 字节的元素,因此该值表示数组索引的起始位置。鉴于我们正在写入索引 0,没有偏移量,也就是从 0x00 开始写入。
0x00 + 0x120 = 0x120 (10 进制为 288)
ADD 用于将此偏移值添加到变量 “b” 的内存位置。偏移量为 0,直接将数据写入分配的内存位置。最后, MSTORE 将值 0x01 存储到这个内存位置 0x120。
下图显示了函数执行结束时的系统状态。所有栈项都已弹出。请注意,实际上在 remix 中还有一些项目留在堆栈上,一个 JUMP 位置和函数签名,但是它们与内存操作无关,因此在 EVM playground 中被省略了。
内存已更新为包含 b[0] = 1 赋值,在我们内存的倒数第三行,0 值变成了 1。可以验证该值位于正确的内存位置,b[0] 应占用位置 0x120 - 0x13f(bytes 289 - 320)。
我们现在对合约内存的工作原理有了一定程度的了解。在后续需要编写代码时,将为我们提供很好理解与帮助。当你跳过一些合同操作码,看到某些内存位置不断弹出 (0x40) ,现在就知道他们的确切含义了。
来源:慢雾科技公众号
链接: