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
这是一个简单的程序,它将数字 05
和 04
放入栈内然后相加。
我们也可以通过 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 - 1
或 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
). 要解决这个问题,在进入循环之前检查输入值是否为零(提示:使用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
函数, 传递两个参数 a
和 b
,他们相加的结果存储在变量x
中。注意,定义在合约顶部的变量被持久化到合约存储树中(使用SSTORE)。
回到容器会话, 使用cd /home/eris/.eris
和ls
,你会发现一个目录和合约文件
编译合约:
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