以太坊源码解读(21)EVM 解释器
之前我们说到EVM解释器是面对Contract对象的,不论是Contract的创建还是调用,都会通过run()函数来调用Interpreter的Run()方法。该方法初始化执行过程中所需要的一些变量,然后进入堆栈操作的主循环。
一、Interpreter.Run()
a. 初始化执行循环中的中间变量
if in.intPool == nil { in.intPool = poolOfIntPools.get() defer func() { poolOfIntPools.put(in.intPool) in.intPool = nil }() } // Increment the call depth which is restricted to 1024 in.evm.depth++ defer func() { in.evm.depth-- }() // 将readOnly设置为true,如果调用方法是静态调用,则设置为只读,一旦不是出现写操作,则退出 if readOnly && !in.readOnly { in.readOnly = true defer func() { in.readOnly = false }() } // 清空之前Call调用的结果 in.returnData = nil // 如果合约代码为空则直接退出 if len(contract.Code) == 0 { return nil, nil } var ( op OpCode // 当前的指令集 mem = NewMemory() // 新建内存 stack = newstack() // 新建堆栈 pc = uint64(0) // program counter cost uint64 pcCopy uint64 // needed for the deferred Tracer gasCopy uint64 // for Tracer to log gas remaining before execution logged bool // deferred Tracer should ignore already logged steps ) // 设置合约输入参数 contract.Input = input // Reclaim the stack as an int pool when the execution stops defer func() { in.intPool.put(stack.data...) }()
b. 进入主循环 1-根据pc获取一条指令 2-检查堆栈上的参数 是否服符合指令函数的要求 3-如果当前解释器是只读的,如果当前指令是写指令,可能导致世界状态改变,直接退出循环 4-计算指令所需要的内存大小 5-获取这个指令需要gas消耗,然后从交易余额中扣除当前指令的消耗,如果余额不足,直接返回 6-内存大小调整到合适大小 7-执行指令 8-处理指令的返回值
for atomic.LoadInt32(&in.evm.abort) == 0 { // 第一步:根据pc获取一条指令 op = contract.GetOp(pc) // 第二步:根据指令从JumpTable中获得操作码 operation := in.cfg.JumpTable[op] // 检查-1:操作码是否有效 if !operation.valid { return nil, fmt.Errorf("invalid opcode 0x%x", int(op)) } // 检查-2:检查堆栈上的参数是否符合指令函数的要求 if err := operation.validateStack(stack); err != nil { return nil, err } // 第三步:如果当前解释器是只读的,若碰到写指令,则直接退出 if err := in.enforceRestrictions(op, operation, stack); err != nil { return nil, err } var memorySize uint64 // 第四步:计算指令需要的内存大小 if operation.memorySize != nil { // 先判断内存大小是否足够 memSize, overflow := bigUint64(operation.memorySize(stack)) if overflow { return nil, errGasUintOverflow } // 如果足够则以32 bytes为单位进行内存扩充,并计算gas if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow { return nil, errGasUintOverflow } } // 第五步:获取指令所需要的gas,然后从交易余额中扣除,如果余额不足,直接返回 cost, err = operation.gasCost(in.gasTable, in.evm, contract, stack, mem, memorySize) if err != nil || !contract.UseGas(cost) { return nil, ErrOutOfGas } // 第六步:重新调整刚才获得的SafeSize的内存大小 if memorySize > 0 { mem.Resize(memorySize) } // 第七步:重要!!!!执行指令!!!!! res, err := operation.execute(&pc, in, contract, mem, stack) // 检查intPool的完整性 if verifyPool { verifyIntegerPool(in.intPool) } // 如果指令定义returns为true,将返回值复制给解释器的returnData成员 if operation.returns { in.returnData = res } // 第八步:处理返回值 switch { case err != nil: // 返回指令执行错误,直接返回 return nil, err case operation.reverts:9 // revert指令返回 return res, errExecutionReverted case operation.halts: // 如果指令为终止指令,直接退出(STOP,RETURN,SELFDESTRUCT) return res, nil case !operation.jumps: // 如果不是跳转指令,pc自增,进行下一条指令运行,重新循环 pc++ } }
总体来说,解释器执行循环的过程如下图:
二、EVM指令与操作
我们先看下EVM模块的代码结构:
evm.go 定义了EVM运行环境结构体,并实现 转账处理 这些比较高级的,跟交易本身有关的功能 vm/evm.go 定义了EVM结构体,提供Create和Call方法,作为虚拟机的入口,分别对应创建合约和执行合约代码 vm/interpreter.go 虚拟机的调度器,开始真正的解析执行合约代码 vm/opcodes.go 定义了虚拟机指令码(操作码) vm/instructions.go 绝大部分的操作码对应的实现都在这里 vm/gas_table.go 绝大部分操作码所需的gas都在这里计算 vm/jump_table.go 定义了operation,就是将opcode和gas计算函数、具体实现函数等关联起来 vm/stack.go evm所需要的栈 vm/memory.go evm的内存结构 vm/intpool.go *big.Int的池子,主要是性能上的考虑,跟业务逻辑无关
从上图来看,opcodes中储存的是所有指令码,比如ADD的指令码就是0x01。jump_table定义了每一个指令对应的指令码、gas花费;instructions中是所有的指令执行函数的实现,通过这些函数来对堆栈stack进行操作,比如pop()、push()等。
当一个contract对象传入interpreter模块,首先调用了contract的GetOp(n)方法,其内部调用了GetByte(n)方法,从而从Contract对象的Code中拿到n对应的指令。参数n就是我们上面再Run()函数中定义的pc,是一个程序的计数器。每次指令执行后都会让pc++,从而调用下一个指令,除非指令执行到最后是退出函数,比如return、stop或selfDestruct。
// GetOp returns the n'th element in the contract's byte array func (c *Contract) GetOp(n uint64) OpCode { return OpCode(c.GetByte(n)) } // GetByte returns the n'th byte in the contract's byte array func (c *Contract) GetByte(n uint64) byte { if n < uint64(len(c.Code)) { return c.Code[n] } return 0 }
三、基于堆栈的虚拟机
虚拟机实际上是从软件层面对物理机器的模拟,但以太坊虚拟机相对于我们日常常见到的狭义的虚拟机如vmware或者v-box不同,仅仅是为了模拟对字节码的取指令、译码、执行和结果储存返回等操作,这些步骤跟真实物理机器上的概念都很类似。当然,不管虚拟机怎么实现,最终都还是要依靠物理资源。
如今虚拟机的实现方式有两种,一种就是基于栈的,另一种是基于寄存器的。基于栈的虚拟机有JVM,CPython等,而基于寄存器的有Dalvik以及Lua5.0。这两种实现方式虽然机制不同,但最终都要实现: 1、从内存中取指令; 2、译码,将指令转义成特定的操作; 3、执行,也就是在栈或者寄存器中进行计算; 4、返回计算结果。
我们这里简单通过一张图回顾上面那个ADD指令的执行,了解一下基于栈的计算如何执行,以便我们能对以太坊EVM的原理有很深的理解。
加入我们栈上先PUSH了3和4在栈顶,现在当收到ADD指令时,调用opAdd()函数。先执行x=stack.pop(),将栈顶的3取出并赋值给x,删除栈顶的3,然后执行y = stack.peek(),取出此时栈顶的4但是不删除。然后执行y.Add(x,y)得到y==7,再讲7压如栈顶。
当然,上述过程任然是抽象的,是经过译码后的内容。原代码是[]bytes类型的数据,这里就暂时不做介绍了。这涉及到了solidity的原理,后面我会抽个时间总结一下,solidty如何将合约代码转成字节码,而字节码又是如何在EVM中进行译码的。
下一章:MPT 默克前缀树原理
MPT是以太坊中一种使用很广泛的数据结构,用来管理以太坊账户的状态、状态的变更、交易和收据等。在以太坊中MPT有以下几个应用的场景:1、交易树:记录交易的状态和变化。缓存到MTP2、收据树(交易收据):交易收据的存储 ...