以太坊虚拟机EVM的作用是将智能合约代码翻译成可以在以太坊上执行的机器码,并且提供一个沙盒运行环境,在运行期间不能访问宿主机的网络,文件,系统,即使不同的合约之间也有有限的访问权限。

EVM的特点

  官方给出的EVM主要的设计目标如下:

  • 简单性,操作码尽可能少且低级,数据类型尽可能少,虚拟机的结构尽可能简单。
  • 确定性,EVM的语句没有产生歧义的空间,结果在不同机器上的执行结果是确定一致的。
  • 节约空间,EVM的组件尽可能紧凑。
  • 区块链定制化的,必须可以处理20bytes的账户地址,自定义32bytes密码学算法的等。
  • 安全模型简单安全,Gas的计价模型应该是简单易行准确的。
  • 便于优化,以便即时编译(JIT)和VM的性能优化。

EVM基本信息

  以太坊是一种基于栈的虚拟机,基于栈的虚拟机数据的存取为先进先出,在后面介绍EVM指令的时候会看到这个特性。同时基于栈的虚拟机实现简单,移植性也不错,这也是以太坊选择基于栈的虚拟机的原因。

  EVM采用了32字节(256bit)的字长,最多可以容纳2014个为最小的操作单位。

数据管理

  接下来看一下EVM的数据是如何管理的。

  可以看到code和storage里存储的数据是非易失的,而stack,args,memory里存储的数据是易失的,其中code的数据是智能合约的二进制源码,是非易失的很好理解,部署合约的时候data字段也就是合约内容会存储在EVM的code中。
  如果要操作这些存储结构里的数据,就需要用到EVM指令,由于EVM的操作码被限制在一个字以内,所以EVM最多容纳256条指令,目前EVM已经定义了约142条指令,还有100多条用于以后的扩展。这142条指令包括了算法运算,密码学计算,栈操作,memory,storage操作等。

  接下来看一下各个存储位置的含义;

Stack

   stack可以免费使用,没有gas消耗,用来保存函数的局部变量,数量被限制在了16个,当在合约里中声明的局部变量超过16个时,再编译合约就会遇到Stack too deep, try removing local variables错误。
  介绍几个EVM操作栈的指令,在后面分析合约的时候会用到;

  • Pop指令(操作码0x50)用来从栈顶弹出一个元素;
  • PushX指令用来把紧跟在后面的1-32字节元素推入栈顶,Push指令一共32条,从Push1(0x60)到Push32(0x7A),因为栈的一个是256bit,一个字节8bit,所以Push指令最多可以把其后32字节的元素放入栈中而不溢出。
  • DupX指令用来复制从栈顶开始的第1-16个元素,复制后把元素在推入栈顶,Dup指令一共16条,从Dup1(0x80)到Dup16(0x8A)。
  • SwapX指令把栈顶元素和从栈顶开始数的第1-16个元素进行交换,Swap指令一共16条,从Swap1(0x01)一直到Swap16(0x9A)。
Args

  args也叫calldata,是一段只读的可寻址的保存函数调用参数的空间,与栈不同的地方的是,如果要使用calldata里面的数据,必须手动指定偏移量和读取的字节数。
  EVM提供的用于操作calldata的指令有三个:

  • calldatasize返回calldata的大小。
  • calldataload从calldata中加载32bytes到stack中。
  • calldatacopy拷贝一些字节到内存中。

  通过一个合约来看一下如何使用calldata,假如我们要写一个合约,合约有一个add的方法,用来把传入的两个参数相加,通常会这样写。

1
2
3
4
5
6
7
pragma solidity ^0.5.1;
contract Calldata {
function add(uint256 a, uint256 b) public view returns (uint256) {
return a + b;
}
}

  当然我们也可以用内联汇编的形式这样写。

1
2
3
4
5
6
7
8
9
10
11
contract Calldata {
function add(uint256 a, uint256 b) public view returns (uint256) {
assembly {
let a := mload(0x40)
let b := add(a, 32)
calldatacopy(a, 4, 32)
calldatacopy(b, add(4, 32), 32)
result := add(mload(a), mload(b))
}
}
}

  首先我们我们加载了0x40这个地址,这个地址EVM存储空闲memory的指针,然后我们用a重命名了这个地址,接着我们用b重命名了a偏移32字节以后的空余地址,到目前为止这个地址所指向的内容还是空的。
  calldatacopy(a, 4, 32)这行代码把calldata的从第4字节到第36字节的数据拷贝到了a中,同样calldatacopy(b, add(4, 32), 32)把36到68字节的数据拷贝到了b中,接着add(mload(a), mload(b))把栈中的a,b加载到内存中相加。最后的结果等价于第一个合约。
  而为什么calldatacopy(a, 4, 32)的偏移要从4开始呢?在EVM中,前四位是存储函数指纹的,计算公式是bytes4(keccak256(“add(uint256, uint256)”)),从第四位开始才是args。

Memory

  Memory是一个易失性的可以读写修改的空间,主要是在运行期间存储数据,将参数传递给内部函数。内存可以在字节级别寻址,一次可以读取32字节。
  EVM提供的用于操作memory的指令有三个:

  • Mload加载一个字从stack到内存;
  • Mstore存储一个值到指定的内存地址,格式mstore(p,v),存储v到地址p;
  • Mstore8存储一个byte到指定地址 ;

  当我们操作内存的时候,总是需要加载0x40,因为这个地址保存了空闲内存的指针,避免了覆盖已有的数据。

Storage

  Storage是一个可以读写修改的持久存储的空间,也是每个合约持久化存储数据的地方。Storage是一个巨大的map,一共2^256个插槽,一个插糟有32byte。
  EVM提供的用于操作storage的指令有两个:

  • Sload用于加载一个字到stack中;
  • Sstore用于存储一个字到storage中;

  solidity将定义的状态变量,映射到插糟内,对于静态大小的变量从0开始连续布局,对于动态数组和map则采用了其他方法,下篇文章在讲 (╹▽╹)