EVM运行原理(2022-11-24)

83 阅读5分钟

EVM运行原理

solidity生命周期

67275c3f63d60c1010fe80755d2ec2e.jpg

EVM运行原理

EVM是栈式虚拟机,其核心特征就是所有操作数都会被存储在栈上。

下面我们将通过一段简单的Solidity语句代码看看其运行原理:

uint a = 1; uint b = 2; uint c = a + b; 这段代码经过编译后,得到的字节码如下:

PUSH1 0x1 PUSH1 0x2 ADD 为了读者更好了解其概念,这里精简为上述3条语句,但实际的字节码可能更复杂,且会掺杂SWAP和DUP之类的语句。

我们可以看到,在上述代码中,包含两个指令:PUSH1和ADD,它们的含义如下:

  • PUSH1:将数据压入栈顶。
  • ADD:POP两个栈顶元素,将它们相加,并压回栈顶。

下图中,sp表示栈顶指针,pc表示程序计数器。

当执行完push1 0x1后,pc和sp均往下移:

image.png

类似地,执行push1 0x2后,pc和sp状态如下:

image.png

最后,当add执行完后,栈顶的两个操作数都被弹出作为add指令的输入,两者的和则会被压入栈:

image.png

栈 栈用于存储字节码指令的操作数。

在Solidity中,局部变量若是整型、定长字节数组等类型,就会随着指令的运行入栈、出栈。

例如,在下面这条简单的语句中,变量值1会被读出,通过PUSH操作压入栈顶:

uint i = 1; 对于这类变量,无法强行改变它们的存储方式,如果在它们之前放置memory修饰符,编译会报错。

内存 内存类似java中的堆,它用于储存”对象”。

在Solidity编程中,如果一个局部变量属于变长字节数组、字符串、结构体等类型,其通常会被memory修饰符修饰,以表明存储在内存中。

本节中,我们将以字符串为例,分析内存如何存储这些对象。

对象存储结构

下面将用assembly语句对复杂对象的存储方式进行分析。

assembly语句用于调用字节码操作。

mload指令将被用于对这些字节码进行调用。mload(p)表示从地址p读取32字节的数据。 开发者可将对象变量看作指针直接传入mload。

在下面代码中,经过mload调用,data变量保存了字符串str在内存中的前32字节。

string memory str = "aaa";
bytes32 data;
assembly{
    data := mload(str)
}  

掌握mload,即可用此分析string变量是如何存储的。

下面的代码将揭示字符串数据的存储方式:

function strStorage() public view returns(bytes32, bytes32){
    string memory str = "你好";
    bytes32 data;
    bytes32 data2;
    assembly{
        data := mload(str)
        data2 := mload(add(str, 0x20))
    }   
    return (data, data2);
}

data变量表示str的031字节,data2表示str的3263字节。运行strStorage函数的结果如下:

  • 0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000006
  • 1: bytes32: 0xe4bda0e5a5bd0000000000000000000000000000000000000000000000000000 可以看到,

第一个数据字得到的值为6,正好是字符串”你好”经UTF-8编码后的字节数。

第二个数据字则保存的是”你好”本身的UTF-8编码。

熟练掌握了字符串的存储格式之后,我们就可以运用assembly修改、拷贝、拼接字符串。

内存分配方式

既然内存用于存储对象,就必然涉及到内存分配方式。

memory的分配方式非常简单,就是顺序分配。

下面我们将分配两个对象,并查看它们的地址:

function memAlloc() public view returns(bytes32, bytes32){
    string memory str = "aaa";
    string memory str2 = "bbb";
    bytes32 p1;
    bytes32 p2;
    assembly{
        p1 := str
        p2 := str2
    }   
    return (p1, p2);
}

运行此函数后,返回结果将包含两个数据字:

  • 0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000080
  • 1: bytes32: 0x00000000000000000000000000000000000000000000000000000000000000c0 这说明,第一个字符串str1的起始地址是0x80,第二个字符串str2的起始地址是0xc0,之间64字节,正好是str1本身占据的空间。

此时的内存布局如下,其中一格表示32字节(一个数据字,EVM采用32字节作为一个数据字,而非4字节): image.png

  • 0x40~0x60:空闲指针,保存可用地址,本例中是0x100,说明新的对象将从0x100处分配。可以mload(0x40)获取到新对象的分配地址。
  • 0x80~0xc0:对象分配的起始地址。这里分配了字符串aaa
  • 0xc0~0x100:分配了字符串bbb
  • 0x100~…:因为是顺序分配,新的对象将会分配到这里。 状态存储:顾名思义,状态存储用于存储合约的状态字段

从模型而言,存储由多个32字节的存储槽构成

在前文中,介绍了Demo合约的set函数,里面0x0表示的是状态变量_state的存储槽。

所有固定长度变量会依序放到这组存储槽中。

对于mapping和数组,存储会更复杂,其自身会占据1槽,所包含数据则会按相应规则占据其他槽.

比如mapping中,数据项的存储槽位由键值k、mapping自身槽位p经keccak计算得来。

从实现而言,不同的链可能采用不同实现,比较经典的是以太坊所采用的MPT树。

EVM源码

留个坑,以后来填。

以太坊源码研读0xa0 EVM机制