前面的文章 go-ethereum 源码笔记(core 模块-世界状态,交易收据管理) 讨论了 geth 中对交易的处理,其中有一个阶段需要通过 EVM 执行,如果交易转入方地址为空,则调用 EVM 的 Create 函数创建合约;如果不为空,则为普通转账,调用 Call() 函数。这一篇我们将深入以太坊的虚拟机看看具体的过程。
这一篇需要对智能合约, ERC-20 标准,编译原理(了解编译器,解释器,虚拟机等概念),计算机组成原理(了解基于栈的虚拟机与基于寄存器的虚拟机的区别)有所了解。
以太坊与比特币最大的不同是以太坊设计了图灵完备的虚拟机用于执行合约代码,geth 中的 EVM 本质上就是一个字节码执行引擎。
设计目标
在以太坊的设计原理中描述了 EVM 的设计目标:
- 简单:操作码尽可能的简单,低级,数据类型尽可能少,虚拟机结构尽可能少。
- 结果明确:在 VM 规范里,没有任何可能产生歧义的空间,结果应该是完全确定的,此外,计算步骤应该是精确的,以便可以计量 gas 消耗量。
- 节约空间:EVM 汇编码应该尽可能紧凑。
- 预期应用应具备专业化能力:在 VM 上构建的应用能够处理20字节的地址,以及32位的自定义加密值,拥有用于自定义加密的模数运算、读取区块和交易数据和状态交互等能力。
- 简单安全:能够容易地建立一套操作的 gas 消耗成本模型,让 VM 不被利用。
- 优化友好:应该易于优化,以便即时编译(JIT)和 VM 的加速版本能够构建出来。
特点:
- 区分临时存储(Memory,存在于每个 VM 实例中,并在 VM 执行结束后消失)和永久存储(Storage,存在于区块链的状态层)。
- 采用基于栈(stack)的架构。
- 机器码长度为32字节。
- 没有重用 Java,或其他一些 Lisp 方言,Lua 的虚拟机,自定义虚拟机。
- 使用可变的可扩展内存大小。
- 限制调用深度为 1024。
- 没有类型。
原理
通常智能合约的开发流程是使用 solidity 编写逻辑代码,通过编译器编译成 bytecode,然后发布到以太坊上,以太坊底层通过 EVM 模块支持合约的执行和调用,调用时根据合约地址获取到代码,即合约的字节码,生成环境后载入到 EVM 执行。
大致流程如下图1,指令的执行过程如下图2,从 EVM code 中不断取出指令执行,利用 Gas 来实现限制循环,利用栈来进行操作,内存存储临时变量,账户状态中的 storage 用来存储数据。
源码
代码结构
EVM 模块的文件比较多,这里先给出每个文件的简述,先对每个文件提供的功能有个简单的了解。
1 | . |
数据结构
在 EVM 模块中,有两个高层次的结构体,分别是 Context,EVM。
1 | type Context struct { |
Context 给 EVM 提供运行合约的上下文信息,其中 CanTransfer
是返回账户是否有足够余额的函数,Transfer
可以用来完成转账操作,GetHash
返回第 n 个区块的哈希值。其他的属性在之前文章有过描述,代码中也有注释,这里不再赘述。EVM 结构体中稍值得一提的是 interpreter,它根据代码以及 jump_table 中对应的指令逐条执行合约,这部分我们将在稍后看到。
我们先从go-ethereum 源码笔记(core 模块-世界状态,交易收据管理) 中谈到的 Create,Call 方法谈起。
Create
Create 方法可以用来创建一个新合约。
1 | func (evm *EVM) create(caller ContractRef, code []byte, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) { |
首先会进行一系列验证,调用栈的深度不能超过 1024;调用的账户有足够多的余额;对调用者地址的 nonce+1,通过地址和 nonce 生成合约地址,通过合约地址获取合约哈希值,确保调用的地址不能已经存在合约。接着利用 StateDB 创建一个快照,如果之后的调用出现问题可以回滚。在进行了这一系列初始化之后,发起一笔转账操作,发送方地址余额减 value 值,合约账户的余额加 value 值,接着通过调用 contract 的 SetCallCode
,根据发送方地址,合约地址,金额 value,gas,合约代码,代码哈希初始化合约对象,然后调用 run(evm, contract, nil)
执行合约的初始化代码,这个生成的代码有一定的长度限制,当合约创建成功,没有错误返回,则计算存储代码所需的 gas,如果没有足够 gas 则进行报错。之后就是错误处理了,如果存在错误,需要回滚到之前创建的状态快照,没有错误则返回创建成功。可以看到,合约代码通过 state 模块的 SetCode,保存在账户中 codehash 指向的存储区域,这部分的代码都属于对世界状态的修改。
Call
当进行转账或执行合约代码,会调用 Call 方法,合约里的 call 指令也会调用这个方法。
1 | func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) { |
和 Create 方法类似,不过 Create 方法的资金转移发生在创建合约用户账户和合约账户之间,而 Call 方法的资金转移发生在合约发送方和合约接收方之间。Call 方法需要先检查合约调用深度;确保账户有足够余额;调用 Call 方法的可能是一个转账操作,也可能是一个运行合约的操作,所以接下来会通过 StateDB 查看指定的地址是否存在,如果不存在的话,接着查看该地址是否为内置合约,这些预编译的合约在 core/vm/constracts.go
中定义,主要是用于加密操作,如果本地确实没有合约接收方的账户,创建一个接收方的账户,更新本地的状态数据库。接着 evm 会调用 Transfer 方法(即 Context 里的 TransferFunc)进行转账。最后,通过 StateDB 的 GetCode
拿到该地址对应的代码,通过 run(evm, contract, input)
运行合约,如果是单纯的转账,通过 GetCode
拿到的代码是空,自然也没有合约的运行。这一步完成后,就可以返回执行结果了。合约产生的 gas 总数会加入到矿工账户,作为矿工收入。
解释器执行合约指令
我们已经大致了解了合约是怎么创建和执行的,执行合约的关键在于 Call 方法的最后通过 run(evm, contract, input)
运行合约,接下来我们就深入到 run
函数,看看 evm 中解释器执行合约的过程。
1 | func run(evm *EVM, contract *Contract, input []byte) ([]byte, error) { |
在 run 方法中,首先检查调用的是否是之前编译好的原生合约,如果是原生合约则调用原生合约,否则调用解释器执行合约。解释器的代码在 core/vm/interpreter.go
中。
要理解虚拟机中解释器的的执行过程,首先需要知道该解释器的初始化过程:
1 | func NewInterpreter(evm *EVM, cfg Config) *Interpreter { |
Interpreter 中很重要的一个配置是 Jumptable,它保存的是指令集,在不同的以太坊版本有不同的指令集。查看 core/vm/jump_table.go
中的 newFrontierInstructionSet
, newHomesteadInstructionSet
, newByzantiumInstructionSet
, newConstantinopleInstructionSet
方法,你会发现指令集是向前兼容的。
这里以 ADD 指令为例:
1 | ADD: { |
ADD 指令对应的方法 opAdd
在 core/vm/instructions.go
中。其中 execute
指的是对应的操作函数,gasCost
对应的是 gas 消耗,validateStack
用来验证栈深度,valid
指明操作是否有效。
1 | func opAdd(pc *uint64, evm *EVM, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) { |
通过栈拿到需要进行运算的 x, y 值(弹出一个值,取一个值,进行运算后这个值就变成结果值),在进行加法运算后,该结果会被缓存。不同的指令的实现自然不同,指令不多,但这里也不一一介绍了。还需要了解的一点是,这些指令都对应了一个编码,这些是在 core/vm/opcodes.go
中定义的,这个编码是一个 byte,合约编译出来的 bytecode,一个 OpCode 就对应其中的一位(可以参考EVM指令集)。
了解了指令集后,我们再看 Run
方法。
1 | func (in *Interpreter) Run(contract *Contract, input []byte) (ret []byte, err error) { |
Run 方法会循环执行合约的代码,主要逻辑在 for 循环中,直到遇到 STOP,RETURN,SELFDESTRUCT 指令被执行,或者是遇到任意错误,或者说 done 标志被父 context 设置。这个循环才会结束。大致的执行过程是首先通过 op = contract.GetOp(pc)
拿到下一个需要执行的指令,接着通过 operation := in.cfg.JumpTable[op]
拿到对应的 operation,operation 的类型在 core/vm/jump_table.go
中定义,其中包括指令对应的方法,计算 gas 的方法,验证栈溢出的方法,计算内存的方法等。进行一系列检查后再通过 operation.execute
执行指令。最后一个指令的执行结果会决定整个合约代码的执行结果。到这一步,我们就完整地过了一遍以太坊虚拟机的执行过程。
总结
- EVM 是一个256位的机器,以32字节来处理数据最佳
- EVM 选择基于栈的虚拟机的原因其实不难理解,基于栈的虚拟机代码尺寸更小(因为可以用更小的空间放更多的指令),而且移植性更好(不需要通用寄存器),代码生成也更简单,不需要考虑为临时变量分配空间的问题。但从速度上来说,基于寄存器的架构更占优势。
- 在以太坊中部署的智能合约是完全不可修改的,只能通过部署新的合约来部署新的合约
- Solidity 等编译器的优化一般以减少 gas 的使用为目标
References
- contracts_and_transactions_intro
- EIP 20: ERC-20 Token Standard
- EIP 158: State clearing
- Ethereum Virtual Machine (EVM) Awesome List-Awesome-List)
- Difference between CALL, CALLCODE and DELEGATECALL
- Diving Into The Ethereum VM
- Design-Rationale
- takenobu-hs/ethereum-evm-illustrated
- 以太坊智能合约 OPCODE 逆向之理论基础篇
- Virtual Machine Showdown: Stack Versus Registers