EVM介绍

原文地址

这是一个了解EVM及其与Solidity关系以及如何使用某些调试工具的指南。

安全性

EVM是面向安全的虚拟机,旨在允许不受信任的代码由全球计算机网络执行。为安全起见,它规定了以下限制

  • 程序执行中的每个计算步骤都必须预先支付金额,从而防止拒绝服务攻击。
  • 程序只能通过发送单个任意长度的字节数组来进行交互;但是他们无法访问彼此的状态。
  • 程序是在沙盒中执行的;EVM程序仅可以访问和修改自己的内部状态,或者触发其他EVM程序的执行。
  • 程序执行是完全确定的,对于从相同状态开始的任何情况都产生相同的状态转换

概述

EVM是一个基于堆栈的虚拟机,它具有临时的内存字节数组存储和持久的键值存储(持久的存储在Merkle树中)。堆栈上的元素是32字节长度,并且所有键和值都是以32字节存储的。有超过100个操作码,按十六进制分类。以下是pyethereum客户端的操作符列表,使用粗略的类别名称进行注释。

# schema: [opcode, ins, outs, gas]

opcodes = {

    # arithmetic
    0x00: ['STOP', 0, 0, 0],
    0x01: ['ADD', 2, 1, 3],
    0x02: ['MUL', 2, 1, 5],
    0x03: ['SUB', 2, 1, 3],
    0x04: ['DIV', 2, 1, 5],
    0x05: ['SDIV', 2, 1, 5],
    0x06: ['MOD', 2, 1, 5],
    0x07: ['SMOD', 2, 1, 5],
    0x08: ['ADDMOD', 3, 1, 8],
    0x09: ['MULMOD', 3, 1, 8],
    0x0a: ['EXP', 2, 1, 10],
    0x0b: ['SIGNEXTEND', 2, 1, 5],

    # boolean
    0x10: ['LT', 2, 1, 3],
    0x11: ['GT', 2, 1, 3],
    0x12: ['SLT', 2, 1, 3],
    0x13: ['SGT', 2, 1, 3],
    0x14: ['EQ', 2, 1, 3],
    0x15: ['ISZERO', 1, 1, 3],
    0x16: ['AND', 2, 1, 3],
    0x17: ['OR', 2, 1, 3],
    0x18: ['XOR', 2, 1, 3],
    0x19: ['NOT', 1, 1, 3],
    0x1a: ['BYTE', 2, 1, 3],

    # crypto
    0x20: ['SHA3', 2, 1, 30],
    
    # contract context
    0x30: ['ADDRESS', 0, 1, 2],
    0x31: ['BALANCE', 1, 1, 20],
    0x32: ['ORIGIN', 0, 1, 2],
    0x33: ['CALLER', 0, 1, 2],
    0x34: ['CALLVALUE', 0, 1, 2],
    0x35: ['CALLDATALOAD', 1, 1, 3],
    0x36: ['CALLDATASIZE', 0, 1, 2],
    0x37: ['CALLDATACOPY', 3, 0, 3],
    0x38: ['CODESIZE', 0, 1, 2],
    0x39: ['CODECOPY', 3, 0, 3],
    0x3a: ['GASPRICE', 0, 1, 2],
    0x3b: ['EXTCODESIZE', 1, 1, 20],
    0x3c: ['EXTCODECOPY', 4, 0, 20],

    # blockchain context
    0x40: ['BLOCKHASH', 1, 1, 20],
    0x41: ['COINBASE', 0, 1, 2],
    0x42: ['TIMESTAMP', 0, 1, 2],
    0x43: ['NUMBER', 0, 1, 2],
    0x44: ['DIFFICULTY', 0, 1, 2],
    0x45: ['GASLIMIT', 0, 1, 2],
  
    # storage and execution
    0x50: ['POP', 1, 0, 2],
    0x51: ['MLOAD', 1, 1, 3],
    0x52: ['MSTORE', 2, 0, 3],
    0x53: ['MSTORE8', 2, 0, 3],
    0x54: ['SLOAD', 1, 1, 50],
    0x55: ['SSTORE', 2, 0, 0],
    0x56: ['JUMP', 1, 0, 8],
    0x57: ['JUMPI', 2, 0, 10],
    0x58: ['PC', 0, 1, 2],
    0x59: ['MSIZE', 0, 1, 2],
    0x5a: ['GAS', 0, 1, 2],
    0x5b: ['JUMPDEST', 0, 0, 1],

    # logging
    0xa0: ['LOG0', 2, 0, 375],
    0xa1: ['LOG1', 3, 0, 750],
    0xa2: ['LOG2', 4, 0, 1125],
    0xa3: ['LOG3', 5, 0, 1500],
    0xa4: ['LOG4', 6, 0, 1875],
	
    # arbitrary length storage (proposal for metropolis hardfork)
    0xe1: ['SLOADBYTES', 3, 0, 50],
    0xe2: ['SSTOREBYTES', 3, 0, 0],
    0xe3: ['SSIZE', 1, 1, 50],

    # closures
    0xf0: ['CREATE', 3, 1, 32000],
    0xf1: ['CALL', 7, 1, 40],
    0xf2: ['CALLCODE', 7, 1, 40],
    0xf3: ['RETURN', 2, 0, 0],
    0xf4: ['DELEGATECALL', 6, 0, 40],
    0xff: ['SUICIDE', 1, 0, 0],
}

# push
for i in range(1, 33):
    opcodes[0x5f + i] = ['PUSH' + str(i), 0, 1, 3]

# duplicate and swap
for i in range(1, 17):
    opcodes[0x7f + i] = ['DUP' + str(i), i, i + 1, 3]
    opcodes[0x8f + i] = ['SWAP' + str(i), i + 1, i + 1, 3]

该表告诉我们每个操作码从堆栈中弹出多少个参数并压回几个参数,以及消耗了多少Gas。大多数操作码从堆栈中取出一些参数,并将一个或零个结压回栈。但是有些像GAS和PC一样的操作符,并不会从堆栈中取出任何参数,但是会将剩余的气体和程序计数器分别压入堆栈。许多操作码,如SHA3,CREATE和RETURN,从堆栈中获取引用内存中位置和大小的参数,允许它们对一个连续的内存数组进行操作。

所有的运算都是使用栈上的元素对大整数进行的(如32字节大端整数)。目前,唯一的加密操作是SHA3散列函数,它从内存中获取任意长度的字节数组(由内存中的初始位置和长度指定),并在堆栈上输出散列值。智能合约和区块链的上下文允许访问各种有用的环境信息,例如,CALLDATACOPY将发送到合约的数据(称为call-data)复制到内存中,NUMBER可以根据区块编号执行时间锁定。

EVM通过MLOAD和MSTORE对其内存进行操作,并通过SLOAD和SSTORE对其持久存储进行操作。JUMP可用于跳转到程序中的任意点,其中这些点必须是JUMPDEST。PUSH1-PUSH32操作码将1-32字节的任何数据压入堆栈。DUP1-DUP16操作码将堆栈顶部16个元素之一的副本放到栈顶。SWAP1-SWAP16操作码将栈顶元素与栈中前16个元素的某一个交换。

LOG操作码支持事件日志记录,它在区块中进行记录,并且能够被轻量级客户机有效地验证。最后,CALL和CREATE允许合约调用和创建其他合约,RETURN用于从一个调用中返回一块内存。SUICIDE导致合约被销毁,并将所有资金发送到指定的地址。

每个操作码的规范可以在黄皮书上找到源代码在Github上,或者从EVM的实现中寻找。

注意,EVM是图灵完全的:它既有图灵机的基本结构(用于管理内存和跳转到程序中的任意点的操作),也有基于代理的消息传递系统,其中代理可能是任意代码(用于调用和创建其他合约以及返回值的操作)。为了确保每个操作都能终止,所有操作都带有明确的成本标记,以gas表示。执行必须指定一个最大的Gas量,这样当超过这个量就会抛出一个OutOfGas异常。操作码列表指定每个操作码消耗多少气体。 此外,某些操作消耗的气体量可通过下表参数化(详细信息请参阅黄皮书):

# Non-opcode gas prices
GDEFAULT = 1
GMEMORY = 3
GQUADRATICMEMDENOM = 512  # 1 gas per 512 quadwords
GSTORAGEREFUND = 15000
GSTORAGEKILL = 5000
GSTORAGEMOD = 5000
GSTORAGEADD = 20000
GEXPONENTBYTE = 10    # cost of EXP exponent per byte
GCOPY = 3             # cost to copy one 32 byte word
GCONTRACTBYTE = 200   # one byte of code in contract creation
GCALLVALUETRANSFER = 9000   # non-zero-valued call
GLOGBYTE = 8          # cost of a byte of logdata

GTXCOST = 21000       # TX BASE GAS COST
GTXDATAZERO = 4       # TX DATA ZERO BYTE GAS COST
GTXDATANONZERO = 68   # TX DATA NON ZERO BYTE GAS COST
GSHA3WORD = 6         # Cost of SHA3 per word
GSHA256BASE = 60      # Base c of SHA256
GSHA256WORD = 12      # Cost of SHA256 per word
GRIPEMD160BASE = 600  # Base cost of RIPEMD160
GRIPEMD160WORD = 120  # Cost of RIPEMD160 per word
GIDENTITYBASE = 15    # Base cost of indentity
GIDENTITYWORD = 3     # Cost of identity per word
GECRECOVER = 3000     # Cost of ecrecover op

GSTIPEND = 2300

GCALLNEWACCOUNT = 25000
GSUICIDEREFUND = 24000

除Gas耗尽异常外,还包括无效的操作符、堆栈下溢和无效的跳转目的地等。栈的大小也有限制,而且调用深度也有限制,这样从一个合约到另一个合约的链式调用不能太长。例如,尽管提供了大量的Gas,递归调用合约终止也会停止。以太坊交易和原子性的,一旦抛出异常,交易状态会被回滚。唯一例外的是gas的支付在OutOfGas异常之前使用的任何Gas都将被扣除并发送给矿工。请注意,这样的交易仍然包含在块中,以便他们可以支付费用,如果不是这样,这将为矿工提供一个重要的DoS攻击载体。

执行

让我们看一些简单的执行。为此,我在一个repo中收集了一些有用的工具,包括go-ethereum提供的一些不错的工具的分支。安装这些工具请参考安装指南

下面是我写的一些非常简单的字节码:

6005600401

运行 echo 6005600401 | disasm 执行反汇编, 结果如下:

0      PUSH1  => 05
2      PUSH1  => 04
4      ADD

这是一个简单的程序,它将数字 0504放入栈内然后相加。

我们也可以通过 evm --debug --code 6005600401 在EVM中执行, 得到的结果如下

VM STAT 4 OPs
PC 00000000: PUSH1 GAS: 9999999997 COST: 3
STACK = 0
MEM = 0
STORAGE = 0

PC 00000002: PUSH1 GAS: 9999999994 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000004: ADD GAS: 9999999991 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000004
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000005: STOP GAS: 9999999991 COST: 0
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000009
MEM = 0
STORAGE = 0

其中--debug debug标志在每个步骤中为我们打印堆栈、内存和存储的当前状态,并显示每个操作码和Gas用量。注意0x04和0x05是如何被压入栈(填充为32字节)并由ADD使用的,这使得结果0x09留在堆栈上。如果要返回值,而不是简单地留在栈上,我们需要修改字节码,以便将值复制到内存中,然后返回:

$ echo 60056004016000526001601ff3  | disasm
60056004016000526001601ff3
0      PUSH1  => 05
2      PUSH1  => 04
4      ADD
5      PUSH1  => 00
7      MSTORE
8      PUSH1  => 01
10     PUSH1  => 1f
12     RETURN

值(0x09)存储在0x0位置。然而,由于存储的元素来自堆栈,所以它是一个32字节的元素,使用大端字节编码(即左填充0)的0x09。因此,为了只返回一个字节0x09,我们从内存0x1f位置开始返回长度为0x01的字节数组。或者,我们可以从位置0x00开始返回长度为0x20的字节数组,返回值是用零填充的32字节数据。

通过 evm --debug --code 60056004016000526001601ff3 执行上面逻辑:

VM STAT 8 OPs
PC 00000000: PUSH1 GAS: 9999999997 COST: 3
STACK = 0
MEM = 0
STORAGE = 0

PC 00000002: PUSH1 GAS: 9999999994 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000004: ADD GAS: 9999999991 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000004
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000005: PUSH1 GAS: 9999999988 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000009
MEM = 0
STORAGE = 0

PC 00000007: MSTORE GAS: 9999999982 COST: 6
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000000
0001: 0000000000000000000000000000000000000000000000000000000000000009
MEM = 32
0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0016: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
STORAGE = 0

PC 00000008: PUSH1 GAS: 9999999979 COST: 3
STACK = 0
MEM = 32
0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0016: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 09  ...............?
STORAGE = 0

PC 00000010: PUSH1 GAS: 9999999976 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000001
MEM = 32
0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0016: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 09  ...............?
STORAGE = 0

PC 00000012: RETURN GAS: 9999999976 COST: 0
STACK = 2
0000: 000000000000000000000000000000000000000000000000000000000000001f
0001: 0000000000000000000000000000000000000000000000000000000000000001
MEM = 32
0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0016: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 09  ...............?
STORAGE = 0

OUT: 0x09

注意32字节的大端字节0x09如何存储在内存中,以及程序最终如何输出0x09。

如果我们添加的参数大于一个字节,我们可以使用不同的PUSH操作符。例如,要添加像257 (0x0101)和258 (0x0102)这样的双字节数,我们使用PUSH2 (0x61):

$ echo 61010161010201 | disasm
61010161010201
0      PUSH2  => 0101
3      PUSH2  => 0102
6      ADD

执行 evm --debug --code 61010161010201 结果如下

VM STAT 4 OPs
PC 00000000: PUSH2 GAS: 9999999997 COST: 3
STACK = 0
MEM = 0
STORAGE = 0

PC 00000003: PUSH2 GAS: 9999999994 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000101
MEM = 0
STORAGE = 0

PC 00000006: ADD GAS: 9999999991 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000102
0001: 0000000000000000000000000000000000000000000000000000000000000101
MEM = 0
STORAGE = 0

PC 00000007: STOP GAS: 9999999991 COST: 0
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000203
MEM = 0
STORAGE = 0

其中 0x0203 = 515 = 257 + 258

如果我们想将参数作为调用数据传递,而不是硬编码它们,该怎么办?为了方便起见,我们需要首先就格式规则达成一致。例如,所有输入值都左填充为32字节。然后我们可以这样做:

$ echo 60003560203501 | disasm
60003560203501
0      PUSH1  => 00
2      CALLDATALOAD
3      PUSH1  => 20
5      CALLDATALOAD
6      ADD

要正确执行,我们必须传递正确的填充输入:

$ evm --debug --code 60003560203501 --input 00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000004
VM STAT 6 OPs
PC 00000000: PUSH1 GAS: 9999999997 COST: 3
STACK = 0
MEM = 0
STORAGE = 0

PC 00000002: CALLDATALOAD GAS: 9999999994 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000000
MEM = 0
STORAGE = 0

PC 00000003: PUSH1 GAS: 9999999991 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000005: CALLDATALOAD GAS: 9999999988 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000020
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000006: ADD GAS: 9999999985 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000004
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000007: STOP GAS: 9999999985 COST: 0
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000009
MEM = 0
STORAGE = 0

如果你想让你的程序有多个可能的函数呢?结合调用数据的格式化和调用函数等问题,产生了应用程序二进制接口(ABI)标准,并被高级语言如Solidity或serpent接受。我们稍后再讨论这个问题

首先,我们如何控制流程? 当然是使用布尔表达式和跳转!下面是一个简单的循环:

$ echo  6000356000525b600160005103600052600051600657 | disasm
6000356000525b600160005103600052600051600657
0      PUSH1  => 00
2      CALLDATALOAD
3      PUSH1  => 00
5      MSTORE
6      JUMPDEST
7      PUSH1  => 01
9      PUSH1  => 00
11     MLOAD
12     SUB
13     PUSH1  => 00
15     MSTORE
16     PUSH1  => 00
18     MLOAD
19     PUSH1  => 06
21     JUMPI

这里我们从call-data中加载一些值,然后通过多次循环将计数器的值存到内存中,并在每次循环是递减。这个循环实际上是从JUMPDEST地方开始的.最后一个操作码JUMPI接受一个值和一个位置,如果该值非零,则跳转到程序中的位置。如果跳转位置不是JUMPDEST,则抛出异常。在本例中,JUMPDEST的位置是0x06,它检查的值是计数器变量,从内存中加载。

通过执行evm --debug --code 6000356000525b600160005103600052600051600657 --input 0000000000000000000000000000000000000000000000000000000000000005 来循环五次。看看您是否能够破译代码——查找内存中递减的计数器变量。

如果我们运行的代码没有任何输入,或者输入为零,会发生什么? 循环会运行0次吗?为什么或为什么不(EVM没有负数的概念, 所以-1实际是 2^256 - 10xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff). 要解决这个问题,在进入循环之前检查输入值是否为零(提示:使用ISZERO)。

注意,我们在内存的使用上非常低效,因为我们不断地在内存中多次从同一个位置加载,然后存储到该位置。我们可以使用DUP和SWAP操作码将需要的内容保存在堆栈上,而不是访问内存字节数组。Solidity编译器一直在进行这种优化。下面是不使用内存的循环:

$ echo 6000355b6001900380600357 | disasm
6000355b6001900380600357
0      PUSH1  => 00
2      CALLDATALOAD
3      JUMPDEST
4      PUSH1  => 01
6      SWAP1
7      SUB
8      DUP1
9      PUSH1  => 03
11     JUMPI

非常简单! SWAP操作确保计数器(要递减的值)位于堆栈的顶部,这正是SUB操作码所期望的。DUP1用于复制堆栈上的计数器,因此JUMPI可以使用它,并且下一次通过循环时仍然可以使用它进行减法。除此之外,循环的工作方式完全相同。还要注意,由于在循环开始之前我们没有将计数器存储到内存中,所以JUMPDEST的位置是0x03而不是0x06。

通过下面操作执行五次循环evm --debug --code 6000355b6001900380600357 --input 0000000000000000000000000000000000000000000000000000000000000005观察计数器在堆栈上而不是在内存中保持和递减。

在继续之前还有一个改进。传递一个32字节填充的字符串是很糟糕的;调用数据的大小应该是它需要的大小在本例中,我们希望使用—input 05调用5次循环,并使用—input 0101运行257次循环。问题是, CALLDATALOAD加载32字节大端数, 所以 --input 05 在栈中变成了 0500000000000000000000000000000000000000000000000000000000000000由于EVM中没有字节移位操作符,我们必须使用除法。在本例中,我们要除以256^(32-L)其中L是调用数据的长度。这具有字节右移(32-L)字节的效果。更新后的字节码如下:

$ echo  366020036101000a600035045b6001900380600c57 | disasm
366020036101000a600035045b6001900380600c57
0      CALLDATASIZE
1      PUSH1  => 20
3      SUB
4      PUSH2  => 0100
7      EXP
8      PUSH1  => 00
10     CALLDATALOAD
11     DIV
12     JUMPDEST
13     PUSH1  => 01
15     SWAP1
16     SUB
17     DUP1
18     PUSH1  => 0c
20     JUMPI

我们可以通过 evm --debug --code 366020036101000a600035045b6001900380600c57 --input 05 执行五次循环或者通过 evm --debug --code 366020036101000a600035045b6001900380600c57 --input 0101执行257次循环。确保您了解如何使用EXP和DIV来实现移位,这是高级语言广泛使用的一种非常常见的范例。

合约

到目前为止,我们只研究了EVM的基本执行环境。但是EVM嵌入了区块链账户状态。以太坊中所有的账户都存储在一个Merkle基数树中。EVM中的程序位于称为合约的帐户中。除了地址、余额和序列号(等于帐户发送的事务数——也称为nonce)之外,合约还保留其EVM字节码的散列和内部存储树的Merkle根。一个账户最多可以有一个与之关联的程序,通过一个交易可以再任何时候创建一个合约。或者使用CALL操作符从另外一个合约中触发来创建。注意,一旦部署,合约的代码可能不会更改。帐户/合约存储的Merkle根将在任何交易成功之后更新,其中SSTORE操作码的执行将导致一个值存储在一个新键上,或者对存储在现有键上的值进行更改。

合约的创建以一种特殊的方式进行,将交易发送到空地址,并将合约代码作为数据。以太坊的状态转移函数或通过创建一个新账户来创建合约,并运行调用数据中指定的程序,之后将EVM返回的内容设置为新合约的代码。也就是说,在创建过程中发送的代码与存储在合约中的代码不一样——相反,它是所谓的“部署代码”,其中包含包装在某些操作中的实际合约代码,这些操作将把它复制到内存中并返回。

例如,如果我们编写的一个程序(它不返回任何东西),并将其作为数据发送到空地址,程序将执行,但是生成的帐户将没有代码,因此对该帐户的任何事务将导致没有代码运行。

以简单的加法程序6005600401为例,我们可以使用evm-deploy工具生成部署代码:

$ echo 6005600401 | evm-deploy | disasm 
600580600b6000396000f36005600401
0      PUSH1  => 05
2      DUP1
3      PUSH1  => 0b
5      PUSH1  => 00
7      CODECOPY
8      PUSH1  => 00
10     RETURN
11     PUSH1  => 05
13     PUSH1  => 04
15     ADD

在这里,我们知道程序的长度是0x05,并且我们知道它嵌入到部署代码中,从位置11 (0x0b)开始。因此,我们将这段代码复制到内存中(位置0x00)并返回它。注意,使用DUP1可以使代码的长度(在本例中是0x05)在栈上,用于CODECOPY和返回。当部署代码运行时,返回值应该是目标代码,即:6005600401

$ evm --debug --code 600580600b6000396000f36005600401
VM STAT 7 OPs
PC 00000000: PUSH1 GAS: 9999999997 COST: 3
STACK = 0
MEM = 0
STORAGE = 0

PC 00000002: DUP1 GAS: 9999999994 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000003: PUSH1 GAS: 9999999991 COST: 3
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000005
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000005: PUSH1 GAS: 9999999988 COST: 3
STACK = 3
0000: 000000000000000000000000000000000000000000000000000000000000000b
0001: 0000000000000000000000000000000000000000000000000000000000000005
0002: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 0
STORAGE = 0

PC 00000007: CODECOPY GAS: 9999999979 COST: 9
STACK = 4
0000: 0000000000000000000000000000000000000000000000000000000000000000
0001: 000000000000000000000000000000000000000000000000000000000000000b
0002: 0000000000000000000000000000000000000000000000000000000000000005
0003: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 32
0000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0016: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
STORAGE = 0

PC 00000008: PUSH1 GAS: 9999999976 COST: 3
STACK = 1
0000: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 32
0000: 60 05 60 04 01 00 00 00 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
STORAGE = 0

PC 00000010: RETURN GAS: 9999999976 COST: 0
STACK = 2
0000: 0000000000000000000000000000000000000000000000000000000000000000
0001: 0000000000000000000000000000000000000000000000000000000000000005
MEM = 32
0000: 60 05 60 04 01 00 00 00 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
STORAGE = 0

OUT: 0x6005600401

有状态的EVM

我对evm工具做了一些修改,以便它可以在调用之间保持状态。只需使用datadir标志。例如,让我们使用返回0x5和0x4之和的合约。我们可以运行部署代码,因此实际上部署了合约(用正确的代码创建的帐户),然后我们可以与它交互:

$ evm --code $(echo "60056004016000526001601ff3" | evm-deploy)  --datadir evm-data
Loading database
Loading root hash 0000000000000000000000000000000000000000000000000000000000000000
Contract Address: 1F2A98889594024BFFDA3311CBE69728D392C06D
VM STAT 0 OPs
OUT: 0x60056004016000526001601ff3

他给我们返回了合约地址:(1F2A98889594024BFFDA3311CBE69728D392C06D).这个地址是从哪儿来的?它是列表[发送者地址,发送者序列号]RLP编码的sha3散列值。
默认的发送者是 0x000000000000000000000000000073656e646572 ,序列号从0x0开始。python代码如下:

>>> import rlp, sha3
>>> sha3.sha3_256(rlp.encode(["000000000000000000000000000073656e646572".decode('hex'), 0])).hexdigest()[24:]
'1f2a98889594024bffda3311cbe69728d392c06d'

注意,这意味着地址从相同的状态开始是严格确定的。如果我们再次部署合同,发送者的序列号将是0x1:

$ evm --code $(echo "60056004016000526001601ff3" | evm-deploy)  --datadir evm-data
Datadir already exists
Loading database
Loading root hash BFDDB19821CE2BFAB71C4BA9E8ADC6CF083DAE0EF9206AA506BC88B0F9064182
Contract Address: 14F6D12ECEBB7606C528880AD8B97C25AB7D4AD9
VM STAT 0 OPs
OUT: 0x60056004016000526001601ff3

对于相同的输出产生不同的地址:

>>> import rlp, sha3
>>> sha3.sha3_256(rlp.encode(["000000000000000000000000000073656e646572".decode('hex'), 1])).hexdigest()[24:]
'14f6d12ecebb7606c528880ad8b97c25ab7d4ad9'

现在我们可以发送一个交易给合约

$ evm --to 14F6D12ECEBB7606C528880AD8B97C25AB7D4AD9 --datadir evm-data
Datadir already exists
Loading database
Loading root hash 60209E93FEFD3DD5CF1D6B3FBDC33DA1B020C5B880A51E8306A3F5FDF269122A
Loaded account for receiver 14F6D12ECEBB7606C528880AD8B97C25AB7D4AD9
CODE: 60056004016000526001601FF3
VM STAT 0 OPs
OUT: 0x09

注意它是如何返回0x09的

异常

这里有一些异常的例子。

无效操作符 (5f 是一个无效的操作符):

evm --debug --code 5f

栈下溢 (JUMP (0x56) 只要要求一个参数):

evm --debug --code 56

无效的跳转目的地 (0x0不是一个 JUMPDEST, 在本例它是一个 PUSH):

evm --debug --code 600056

这是一个out-of-gas异常 (PUSH 要求3个gas):

evm --debug --gas 1 --code 6000

更多说明参见evm –help

内存和存储

除了堆栈之外,EVM还附带一个临时内存字节数组和持久存储树。对内存字节数组的访问是相对便宜的,内存越界访问也不例外;当您访问内存时,内存会根据需要增长,您只需为大小的变化支付相应的费用。

例如,第一次访问内存位置0x1000花费了我们很多钱,因为内存的大小从0增长到4096,而第二次几乎没有,因为内存的大小没有变化:

evm --debug --code 611000805151

存储空间实际上是无限的,或者说是2^256,但是相对来说比较昂贵,将一个非零的区域赋值需要消耗20000个Gas,相反则需要5000个气体。例如,我们将0x2存储在0x0位置,然后用0x1覆盖它:

evm --debug --code 60026000556001600055

Solidity

最后我们讨论一下Solidity。solability是一种高级的、类似javascript的、面向合约的语言,可以编译为EVM。他有许多EVM没有的高级特性,如type、arrays和函数调用。它还符合Ethereum ABI,这是一个关于参数和函数调用如何在调用数据中编码的规范。总之,调用数据的前四个字节是函数标识符,对应于函数签名规范版本的sha3散列的前四个字节。其余参数以32字节形式填充并传递。

首先,我们需要Solidity编译器,solc。因为它是用c++编写的,所以安装起来很麻烦。幸运的是,您可以使用docker和Eris Industries提供好的镜像。

首先,我们创建一个工作目录:

export SOLC_WORKSPACE=$HOME/solidity_workspace
mkdir $SOLC_WORKSPACE

现在我们运行docker容器(如果你还没有eris/compiler的镜像,这个会进行下载):

docker run --name solc -v $SOLC_WORKSPACE:/home/eris/.eris -it quay.io/eris/compilers /bin/bash

这个命令会让您进入Solidity编译器的容器,$SOLC_WORKSPACE目录会被挂载到容器的 /home/eris/.eris目录下,$SOLC_WORKSPACE目录中的任何文件改动都会立即在容器中得到体现。运行 solc --help确保编译器已被安装。

使用docker通常涉及两个终端会话,一个在容器中,另一个在主机上。容器会话允许交互访问所需的任何二进制文件(在本例中是solc),而主机会话允许文件正常地在主机进行更改,并立即反映在容器中。

打开另一个窗口作为主机会话, 设置 SOLC_WORKSPACE 通过 export SOLC_WORKSPACE=$HOME/solidity_workspace,并存储下面的合约为:$SOLC_WORKSPACE/add.sol:

contract Addition{
	int x;
        function add(int a, int b){
		x = a + b;
    	}
}

这个合约运行用户调用 add 函数, 传递两个参数 ab ,他们相加的结果存储在变量x中。注意,定义在合约顶部的变量被持久化到合约存储树中(使用SSTORE)。

回到容器会话, 使用cd /home/eris/.erisls,你会发现一个目录和合约文件

编译合约:

solc --bin-runtime --optimize -o . add.sol

在主机会话, 你会发现合约在 $SOLC_WORKSPACE/Addition.bin-runtime目录.

通过使用 --bin-runtime,我们将得到与部署后合同中一样的代码——我们可以使用evm工具进行测试。如果使用 --bin 代替 --bin-runtime, 并通过 evm运行, 将得到和--bin-runtime一样的输出,使用—bin编译的合约的返回值是使用—bin-runtime编译的合约。

让我们反编译solidity合约:

$ echo $(cat MyContract.bin-runtime)  | disasm 
606060405260e060020a6000350463a5f3c23b8114601a575b005b60243560043501600055601856
0      PUSH1  => 60
2      PUSH1  => 40
4      MSTORE
5      PUSH1  => e0
7      PUSH1  => 02
9      EXP
10     PUSH1  => 00
12     CALLDATALOAD
13     DIV
14     PUSH4  => a5f3c23b
19     DUP2
20     EQ
21     PUSH1  => 1a
23     JUMPI
24     JUMPDEST
25     STOP
26     JUMPDEST
27     PUSH1  => 24
29     CALLDATALOAD
30     PUSH1  => 04
32     CALLDATALOAD
33     ADD
34     PUSH1  => 00
36     SSTORE
37     PUSH1  => 18
39     JUMP

:加法本身发生在底部。注意,就在ADD之前,使用CALLDATALOADs,我们从调用数据的0x04和0x24位置加载32字节的参数,而不是0x00和0x20,以便在函数标识符的前四个字节中留出空间。在本例中,您可能已经猜到,我们唯一的函数的函数标识符是a5f3c23b。26行之前所有的代码都是为了检测调用数据的前四个字节是否是a5f3c23b。因为CALLDATALOAD会获取一个32字节的数据,而我们只需要4个字节,所以我们必须通过除以一个大整数来进行字节移位,因此需要EXP和DIV。如果调用数据的前四个字节匹配a5f3c23b,则加载参数,添加它们,并存储在0x00位置。否则,我们停止。

注意,我们可以通过运行solc --hashes add.sol来验证函数标识符 , 或者在python如下操作

>>> import sha3
>>> sha3.sha3_256("add(int256,int256)").hexdigest()[:8]
'a5f3c23b'

为了正确调用函数, 我们可以如下操作 evm --debug --code $(cat Addition.bin-runtime) --input a5f3c23b00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000004

一个更有趣的合约将有一个get函数,所以我们可以找出最后存储的值:

contract Addition{
	int x;
	function add(int a, int b){
		x = a + b;
	}

	function get() returns (int){
		return x;
	}

}

使用我们的evm工具,我们可以部署此合约,添加两个值,然后检索存储的值。
将合约存储为add.sol. 现在,从容器内部编译合同:

solc --bin --optimize -o . add.sol

这回创建一个 Addition.bin文件, 在容器外面的的目录为 $SOLC_WORKSPACE.
在主机的 $SOLC_WORKSPACE 目录,我们可以部署合约:

$ evm --code $(cat Addition.bin) --datadir evm-data
Loading database
Loading root hash 0000000000000000000000000000000000000000000000000000000000000000
Contract Address: 1F2A98889594024BFFDA3311CBE69728D392C06D
VM STAT 0 OPs
OUT: 0x606060405260e060020a60003504636d4ce63c81146024578063a5f3c23b146031575b005b6000546060908152602090f35b60243560043501600055602256

现在我们调用 add 函数, 并存储 5+4的结果:

evm --datadir evm-data --to 0x1F2A98889594024BFFDA3311CBE69728D392C06D --input a5f3c23b00000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000004

最后让我们调用 get 函数. 首先我们获取函数标识:

$ solc --hashes add.sol
======= Addition =======
Function signatures: 
6d4ce63c: get()
a5f3c23b: add(int256,int256)

现在我们可以进行调用

$ evm --datadir evm-data --to 0x1F2A98889594024BFFDA3311CBE69728D392C06D --input 6d4ce63c
Datadir already exists
Loading database
Loading root hash F3C30A7CD9769C45590C236816F2714E96198DBD7FEC33AE892E861816F548B2
Loaded account for receiver 1F2A98889594024BFFDA3311CBE69728D392C06D
CODE: 606060405260E060020A60003504636D4CE63C81146024578063A5F3C23B146031575B005B6000546060908152602090F35B60243560043501600055602256
VM STAT 0 OPs
OUT: 0x0000000000000000000000000000000000000000000000000000000000000009

总结

以上就是对Ethereum虚拟机的介绍。希望您现在能够更深入地了解它的工作原理,并将这些知识传授给其他人,使更多的人了解以太坊底层工作原理,从而促进更多的分析和处理以太坊合约的工具出现。

题图来自unsplash:https://unsplash.com/photos/H1SOELwNtTw