以太坊源码解析:evm

本篇文章分析的源码地址为:https://github.com/ethereum/go-ethereum
分支:master
commit id: 257bfff316e4efb8952fbeb67c91f86af579cb0a

引言

以太坊的智能合约是一个非常棒的想法,所以学习以太坊一定要学一下智能合约。而在以太坊源码里,evm 模块实现了执行智能合约的虚拟机,无论是合约的创建还是调用,都是由 evm 模块完成。所以学习以太坊也一定要学习 evm 模块。这篇文章里,我们通过源代码,看一下这个模块是如何实现的。

需要说明的是,如果想要完整了解以太坊智能合约的实现,达到自己也能写一个类似功能的程度,只学习 evm 模块是不够的,还需要学习智能合约的编译器 solidity 项目。evm 只是实现了一个虚拟机,它在以太坊区块链环境中,逐条地解释执行智能合约的指令,而这是相对比较简单的一步。更重要且更有难度的是,如何设计一个智能合约语言,以及如何编译它。但我在编译原理方面懂得也不多,时间有限就先不深究 solidity 语言的设计和编译了。

(solidity 语言的设计很简单,但仍然是图灵完备的。因此如果要学习编译原理,拿它来作为一个实践项目学习起来应该会非常棒。)

evm 实现结构

我们先看一下 evm 模块的整体实现结构的示意图:


其实 evm 的整体设计还是挺简单的。我们先简单说明一下示意图,后面各小节会针对各功能块进行详细的说明。

首先 evm 模块的核心对象是 EVM,它代表了一个以太坊虚拟机,用于创建或调用某个合约。每次处理一个交易对象时,都会创建一个 EVM 对象。

EVM 对象内部主要依赖三个对象:解释器 Interpreter、虚拟机相关配置对象 vm.Config、以太坊状态数据库 StateDB

StateDB 主要的功能就是用来提供数据的永久存储和查询。关于这个对象的详细信息,可以查看我写的这篇文章

Interpreter 是一个接口,在代码中由 EVMInterpreter 实现具体功能。这是一个解释器对象,循环解释执行给定的合约指令,直接遇到退出指令。每执行一次指令前,都会做一些检查操作,确保 gas、栈空间等充足。但各指令真正的解释执行代码却不在这个对象中,而是记录在 vm.ConfigJumpTable 字段中。

vm.Config 为虚拟机和解释器提供了配置信息,其中最重要的就是 JumpTableJumpTablevm.Config 的一个字段,它是一个由 256 个 operation 对象组成的数组。解释器每拿到一个准备执行的新指令时,就会从 JumpTable 中获取指令相关的信息,即 operation 对象。这个对象中包含了解释执行此条指令的函数、计算指令的 gas 消耗的函数等。

在代码中,根据以太坊的版本不同,JumpTable 可能指向四个不同的对象:constantinopleInstructionSetbyzantiumInstructionSethomesteadInstructionSetfrontierInstructionSet。这四套指令集多数指令是相同的,只是随着版本的更新,新版本比旧版本支持更多的指令集。

大体了解了 evm 模块的设计结构以后,我们在后面的小节里,从源码的角度详细看一下各个功能块的实现。

以太坊虚拟机:EVM

EVM 对象是 evm 模块对外导出的最重要的对象,它代表了一个以太坊虚拟机。利用这个对象,以太坊就可以创建和调用合约了。下面我们从虚拟机的创建、合约的创建、合约的调用三个方面来详细介绍一下。

创建 EVM

前面我们提到过,每处理一笔交易,就要创建一个 EVM 来执行交易中的数据。这是在 ApplyTransaction 这个函数中体现的:

func ApplyTransaction(config *params.ChainConfig, bc ChainContext, author *common.Address, gp *GasPool, statedb *state.StateDB, header *types.Header, tx *types.Transaction, usedGas *uint64, cfg vm.Config) (*types.Receipt, uint64, error) {
    msg, err := tx.AsMessage(types.MakeSigner(config, header.Number))
    if err != nil {
        return nil, 0, err
    }
    // Create a new context to be used in the EVM environment
    context := NewEVMContext(msg, header, bc, author)
    // Create a new environment which holds all relevant information
    // about the transaction and calling mechanisms.
    vmenv := vm.NewEVM(context, statedb, config, cfg)
    // Apply the transaction to the current state (included in the env)
    _, gas, failed, err := ApplyMessage(vmenv, msg, gp)
    if err != nil {
        return nil, 0, err
    }

    ......
}

ApplyTransaction 函数的功能就是将交易的信息记录到以太坊状态数据库(state.StateDB)中,这其中包括转账信息和执行合约信息(如果交易中有合约的话)。这个函数一开始调用 Transaction.AsMessage 将一个 Transaction 对象转换成 Message 对象,我觉得这个转换主要是为了语义上的一致,因为在合约中,msg 全局变量记录了附带当前合约的交易的信息,可能是为了一致,这里也将「交易」转变成「消息」传给 EVM 对象。

然后 ApplyTransaction 创建了 Context 对象,这个对象中主要包含了一些访问当前区块链数据的方法。

接下来 ApplyTransaction 利用这些参数,调用 vm.NewEVM 函数创建了以太坊虚拟机,通过 ApplyMessage 执行相关功能。ApplyMessage 只有一行代码,它最终调用了 StateTransition.TransitionDb 方法。我们来看一下这个方法:

func (st *StateTransition) TransitionDb() (ret []byte, usedGas uint64, failed bool, err error) {
    // 执行一些检查
    if err = st.preCheck(); err != nil {
        return
    }

    ......

    // 判断是否是创建新合约
    contractCreation := msg.To() == nil

    ......

    if contractCreation {
        // 调用 EVM.Create 创建合约
        ret, _, st.gas, vmerr = evm.Create(sender, st.data, st.gas, st.value)
    } else {
        // 不是创建合约,则调用 EVM.Call 调用合约
        st.state.SetNonce(msg.From(), st.state.GetNonce(sender.Address())+1)
        ret, st.gas, vmerr = evm.Call(sender, st.to(), st.data, st.gas, st.value)
    }

    ......

    // 归还剩余的 gas,并将已消耗的 gas 计入矿工账户中
    st.refundGas()
    st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))

    return ret, st.gasUsed(), vmerr != nil, err
}

StateTransition 对象记录了在处理一笔交易过程中的状态数据,比如 gas 的花费等。在 StateTransition.TransitionDb 这个方法中,首先调用 StateTransaction.preCheck 检查交易的 Nonce 值是否正确,并从交易的发送者账户中「购买」交易中规定数量的 gas,如果「购买」失败,那么此次的交易就失败了。

接下来 StateTransaction.TransitionDb 判断当前的交易是否创建合约,这是通过交易的接收者是否为空来判断的。如果需要创建合约,则调用 EVM.Create 进行创建;如果不是,则调用 EVM.Call 执行合约代码。(如果是一笔简单的转账交易,接收者肯定不为空,那么就会调用 EVM.Call 实现转账功能)。

EVM 对象执行完相关功能后,StateTransaction.TransitionDb 调用 StateTransaction.refundGas 将未用完的 gas 还给交易的发送者。然后将消耗的 gas 计入矿工账户中。

上面是创建 EVM 并调用其相关方法的环境说明。说了这么多「前戏」,我们现在正式介绍 EVM 的创建:

func NewEVM(ctx Context, statedb StateDB, chainConfig *params.ChainConfig, vmConfig Config) *EVM {
    evm := &EVM{
        Context:      ctx,
        StateDB:      statedb,
        vmConfig:     vmConfig,
        chainConfig:  chainConfig,
        chainRules:   chainConfig.Rules(ctx.BlockNumber),
        interpreters: make([]Interpreter, 0, 1),
    }

    if chainConfig.IsEWASM(ctx.BlockNumber) {
        panic("No supported ewasm interpreter yet.")
    }

    // vmConfig.EVMInterpreter will be used by EVM-C, it won't be checked here
    // as we always want to have the built-in EVM as the failover option.
    evm.interpreters = append(evm.interpreters, NewEVMInterpreter(evm, vmConfig))
    evm.interpreter = evm.interpreters[0]

    return evm
}

NewEVM 函数用来创建一个新的虚拟机对象,它有四个参数,含义分别如下:

  • ctx: 提供访问当前区块链数据和挖矿环境的函数和数据
  • statedb: 以太坊状态数据库对象
  • chainConfig: 当前节点的区块链配置信息
  • vmConfig: 虚拟机配置信息

注意 vmConfig 参数中的数据可能是不全的,比如缺少 Config.JumpTable 数据(Config.JumpTable 会在 NewEVMInterpreter 中填充)。

NewEVM 函数的实现也相关简单,除了把参数记录下来以外,主要就是调用 NewEVMInterpreter 创建一个解释器对象。我们顺便看一下这个函数的实现:

func NewEVMInterpreter(evm *EVM, cfg Config) *EVMInterpreter {
    // We use the STOP instruction whether to see
    // the jump table was initialised. If it was not
    // we'll set the default jump table.
    if !cfg.JumpTable[STOP].valid {
        switch {
        case evm.ChainConfig().IsConstantinople(evm.BlockNumber):
            cfg.JumpTable = constantinopleInstructionSet
        case evm.ChainConfig().IsByzantium(evm.BlockNumber):
            cfg.JumpTable = byzantiumInstructionSet
        case evm.ChainConfig().IsHomestead(evm.BlockNumber):
            cfg.JumpTable = homesteadInstructionSet
        default:
            cfg.JumpTable = frontierInstructionSet
        }
    }

    return &EVMInterpreter{
        evm:      evm,
        cfg:      cfg,
        gasTable: evm.ChainConfig().GasTable(evm.BlockNumber),
    }
}

这个方法在创建解释器之前,首先填充了 Config.JumpTable 字段。可以看到这里跟据以太坊版本不同,使用了不同的对象填充这个字段。然后函数生成一个 EVMInterpreter 对象并返回(这里还填充了 gasTable 这个字段,但此处我们先暂时忽略,在「gas 的消耗」小节中再详细说明)。

总之,以太坊在每处理一笔交易时,都会调用 NewEVM 函数创建 EVM 对象,哪怕不涉及合约、只是一笔简单的转账。 NewEVM 的实现也很简单,只是记录相关的参数,同时创建一个解释器对象。Config.JumpTable 字段在开始时是无效的,在创建解释器对象时对其进行了填充。

创建合约

上面我们已经看到,如果交易的接收者为空,则代表此条交易的目的是要创建一条合约,因此调用 EVM.Create 执行相关的功能。现在我们就来看看这个方法是如何实现合约的创建的。

先来看看 EVM.Create 的代码:

func (evm *EVM) Create(caller ContractRef, code []byte, gas uint64, value *big.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
    contractAddr = crypto.CreateAddress(
        caller.Address(), evm.StateDB.GetNonce(caller.Address()))
    return evm.create(caller, &codeAndHash{code: code}, gas, value, contractAddr)
}

EVM.Create 的代码很简单,就是通过当前合约的创建者地址和其账户中的 Nonce 值,计算出来一个地址值,作为合约的地址。然后将这个地址和其它参数传给 EVM.create 方法。这里唯一需要注意的是由于用到了账户的 Nonce 值,所以同一份合约代码,每次创建合约时得到的合约地址都是不一样的(因为合约是通过发送交易创建,而每发送一次交易 Nonce 值都会改变)。

创建合约的重点在 EVM.create 方法中,下面是这个方法的第一部分代码:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) {
    // 检查合约创建的递归调用次数
    if evm.depth > int(params.CallCreateDepth) {
        return nil, common.Address{}, gas, ErrDepth
    }
    // 检查合约创建者是否有足够的以太币
    if !evm.CanTransfer(evm.StateDB, caller.Address(), value) {
        return nil, common.Address{}, gas, ErrInsufficientBalance
    }
    // 增加合约创建者的 Nonce 值
    nonce := evm.StateDB.GetNonce(caller.Address())
    evm.StateDB.SetNonce(caller.Address(), nonce+1)

    ......
}

第一段代码比较简单,主要是一些检查,并修改合约创建者的账户的 Nonce 值。

第一个 if 判断中的 EVM.depth 记录者合约的递归调用次数。在 solidity 语言中,允许在合约中通过 new 关键字创建新的合约对象,但这种「在合约中创建合约」的递归调用是有限制的,这也是这个 if 判断的意义。EVM.depth 在解释器对象开始运行时(即 EVMInterpreter.Run 中)加 1,解释器运行结束后减 1(不知道为什么不把这个加减操作放在 EVM.create 中进行)。

我们继续看 EVM.create 的下一段代码:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) {
    ......

    contractHash := evm.StateDB.GetCodeHash(address)
    if evm.StateDB.GetNonce(address) != 0 || 
        (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {
        return nil, common.Address{}, 0, ErrContractAddressCollision
    }
    // Create a new account on the state
    snapshot := evm.StateDB.Snapshot()
    evm.StateDB.CreateAccount(address)
    if evm.ChainConfig().IsEIP158(evm.BlockNumber) {
        evm.StateDB.SetNonce(address, 1)
    }
    evm.Transfer(evm.StateDB, caller.Address(), address, value)

    ......
}

这段代码在状态数据库中创建了合约账户,并将交易中约定好的以太币数值(value)转到这个新创建的账户里。这里在创建新账户前,会先检查这个账户是否已经存在,如果已经存在就直接返回错误了。

我们继续下面的代码:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) {
    ......
    contract := NewContract(caller, AccountRef(address), value, gas)
    contract.SetCodeOptionalHash(&address, codeAndHash)

    if evm.vmConfig.NoRecursion && evm.depth > 0 {
        return nil, address, gas, nil
    }

    ......
    ret, err := run(evm, contract, nil, false)

    ......
    if err == nil && !maxCodeSizeExceeded {
        createDataGas := uint64(len(ret)) * params.CreateDataGas
        if contract.UseGas(createDataGas) {
            evm.StateDB.SetCode(address, ret)
        } else {
            err = ErrCodeStoreOutOfGas
        }
    }
    ......

    return ret, address, contract.Gas, err

这段代码首先创建了一个 Contract 对象。一个 Contract 对象包含和维护了合约在执行过程中的必要信息,比如合约创建者、合约自身地址、合约剩余 gas、合约代码和代码的 jumpdests 记录(关于 jumpdests 记录请参看后面的「跳转指令」小节)。

创建 Contract对象以后,紧跟着的是一个 if 判断。如果以太坊虚拟机被配置成不可递归创建合约,而当前创建合约的过程正是在递归过程中,则直接返回成功,但并没有返回合约代码(第一个返回参数)。(我其实没太看懂这个判断和处理,如果不能递归,为什么直接返回成功呢?)

接着代码调用 run 函数运行合约的代码。我们一会再详细看这个 run 函数,先来看看 run 返回后的处理。如果运行成功,且合约代码没有超过长度限制(maxCodeSizeExceeded 为 false,一会再说这个变量),则调用 StateDB.SetCode 将合约代码存储到以太坊状态数据库的合约账户中,当然存储需要消耗一定数据的 gas。

你可能会奇怪为什么存储的合约代码是合约运行后的返回码,而不是原来交易中的数据(即 Transaction.data.Payload,或者说 EVM.Create 方法的 code 参数)。这是因为合约源代码在编译成二进制数据时,除了合约原有的代码外,编译器还另外插入了一些代码,以便执行相关的功能。对于创建来说,编译器插入了执行合约「构造函数」(即合约对象的 constructor 方法)的代码。因此在将编译器编译后的二进制提交以太坊节点创建合约时,EVM 执行这段二进制代码,实际上主要执行了合约的 constructor 方法,然后将合约的其它代码返回,所以才会有这里的 ret 变量作为合约的真正代码存储到状态数据库中。

对于 maxCodeSizeExceeded 变量,它记录的是合约的代码(即 ret 变量)是否超过某一长度。前面的代码段中 run 函数后面省略的代码主要是处理与这个变量相关的逻辑。我觉得这个并不重要,所以就没有放上来。

下面我们就来看看 run 函数,看它是怎么执行一个合约创建的过程的:

func run(evm *EVM, contract *Contract, input []byte, readOnly bool) ([]byte, error) {
    if contract.CodeAddr != nil {
        precompiles := PrecompiledContractsHomestead
        if evm.ChainConfig().IsByzantium(evm.BlockNumber) {
            precompiles = PrecompiledContractsByzantium
        }
        if p := precompiles[*contract.CodeAddr]; p != nil {
            return RunPrecompiledContract(p, input, contract)
        }
    }

    for _, interpreter := range evm.interpreters {
        if interpreter.CanRun(contract.Code) {
            if evm.interpreter != interpreter {
                // Ensure that the interpreter pointer is set back
                // to its current value upon return.
                defer func(i Interpreter) {
                    evm.interpreter = i
                }(evm.interpreter)
                evm.interpreter = interpreter
            }
            return interpreter.Run(contract, input, readOnly)
        }
    }
    return nil, ErrNoCompatibleInterpreter
}

这个函数不长,所以我一下子全放上来了。函数前半部分判断合约的地址是否是一些特殊地址,如果是则执行其对应的对象的 Run 方法。关于这些特殊地址的详细信息,请参看后面的「预定义合约」小节,这里就不展开细说了。

run 函数的后半部分代码是一个 for 循环,从当前 EVM 对象中选择一个可以运行的解释器,运行当前的合约并返回。当前源代码中只有一个版本的解释器,就是 EVMInterpreter,我们现在先大体上看一下这个对象的的 Run 方法:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......
    in.evm.depth++
    defer func() { in.evm.depth-- }()
    ......

    var (
        pc   = uint64(0) // program counter
    )
    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......
        op = contract.GetOp(pc)
        operation := in.cfg.JumpTable[op]
        ......

        res, err := operation.execute(&pc, in, contract, mem, stack)

        ......
        switch {
        case err != nil:
            return nil, err
        case operation.reverts:
            return res, errExecutionReverted
        case operation.halts:
            return res, nil
        case !operation.jumps:
            pc++
        }
    }
    return nil, nil
}

我将此方法中当前我们关心的代码在这里展示出来。可见这个方法就是从给定的代码的第 0 个字节开始执行,直到退出。

我们前面说过,编译合约时编译器会插入一些代码。在刚才展示的创建合约的过程中,执行的就是这些插入的代码。所以创建合约的过程并没有见到有「创建」的 go 代码,而是和调用合约一样,只是执行编译出来的指令。

为了更清楚的了解这一过程,我们来看一个实例。我使用 solidity 编译器将下面这个简单的合约编译成指令的汇编形式。合约源代码如下:

contract test { 
    uint256 x;

    constructor() public {
        x = 0xfafa;
    }

    function multiply(uint256 a) public view returns(uint256){
        return x * a;
    }
    function multiplyState(uint256 a) public returns(uint256){
        x = a * 7;
        return x;
    }
}

编译成汇编代码后,最前面一部分的指令如下:

    mstore(0x40, 0x80)
    callvalue
    dup1
    iszero
    tag_1
    jumpi /* 跳转到 tag_1 */
    0x00
    dup1
    revert

tag_1:
    /* "../test.sol":63:111  constructor() public {... */
    pop
    0xfafa  /* 0xfafa 关键数字 */
    0x00
    dup2
    swap1
    sstore
    pop
    dataSize(sub_0)
    dup1
    dataOffset(sub_0)
    0x00
    codecopy
    0x00
    return
stop

sub_0: assembly {
    ......

我们不需要看懂每条指令,也能明白这其中的意思。指令一开始作了一些操作,然后会跳转到 tag_1 处继续执行。在 tag_1 处的代码,我们可以看到 0xfafa 这个关键数字,这是我们在 constructor 方法中为 x 设置的初始值。所以我们可以知道 tag_1 处的代码执行了合约的初始化工作。最后通过 codecopy 指令,将 sub_0 处至最后的数据拷贝出来并返回。所以合约真正的代码是从 sub_0 开始到结尾处的数据。这也证实了我们前面说的,存储到状态数据库中的合约代码,是从 run 函数返回后的数据。

调用合约

EVM 对象有三个方法实现合约的调用,它们分别是:

  • EVM.Call
  • EVM.CallCode
  • EVM.DelegateCall
  • EVM.StaticCall

我们先说一下这四个调用合约的方法的差异,然后再通过 EVM.Call 看一下调用合约的代码是如何实现的。

EVM.Call 实现的基本的合约调用的功能,没什么特殊的。后面的三个调用方式都是与 EVM.Call 比较产生的差异。所以这里只介绍后面三个调用方式的特殊性。

EVM.CallCode 和 EVM.DelegateCall

首先需要了解的是,EVM.CallCodeEVM.DelegateCall 的存在是为了实现合约的「库」的特性。我们知道编程语言都有自己的库,比如 go 的标准库,C++ 的 STL 或 boost。作为合约的编写语言,solidity 也想有自己的库。但与普通语言的实现不同,solidity 写出来的代码要想作为库被调用,必须和普通合约一样,布署到区块链上取得一个固定的地址,其它合约才能调用这个「库合约」提供的方法。但合约又涉及到一些特有的属性,比如合约的调用者、自身地址、自身所拥有的以太币的数量等。如果我们直接去调用「库合约」的代码,用到这些属性时必然是「库合约」自己的属性,但这可能不是我们想要的。

例如,设想一个「库合约」的方法实现了一个这样的操作:从自已账户中给指定账户转一笔钱。如果这里的「自己账户」指的是「库合约」的账户,那肯定是不现实的(因为没有人会出钱布署一个有很多以太币的合约,并且让别人把这些币转走)。

此时 EVM.DelegateCall 就派上用场了。这个调用方式将「库合约」的调用者,设置成自己的调用者;将「库合约」的地址,设置成自己的地址(但代码还是「库合约」的代码)。如此一来,「库合约」的属性,就完全和自己的属性一样了,「库合约」的代码就像是自己的写的代码一样。

例如 A 账户调用了 B 合约,而在 B 合约中通过 DelegateCall 调用了 C 合约,那么 C 合约的调用者将被修改成 A , C 合约的地址将被修改成 B 合约的地址。所以在刚才用来转账的「库合约」的例子中,「自己账户」指的不再是「库合约」的账户了,而是调用「库合约」的账户,转账也就可以按我们想要的方式进行了。

EVM.CallCodeEVM.DelegateCall 类似,不同的是 EVM.CallCode 不改变「库合约」的调用者,只是改变「库合约」的合约地址。也就是说,如果 A 通过 CallCode 的方式调用 B,那么 B 的调用者是 A,而 B 的账户地址也被改成了 A。

总结一下就是,EVM.CallCodeEVM.DelegateCall 修改了被调用合约的上下文环境,可以让被调用的合约代码就像自己写的代码一样,从而达到「库合约」的目的。具体来说,EVM.DelegateCall 会修改被调用合约的调用者和合约本身的地址,而 EVM.CallCode 只会修改被调用合约的本身的地址。

了解了这两个方法的目的和功能,我们来看看代码中它们是如何实现各自的功能的。对于 EvM.CallCode 来说,它通过下面展示的几行代码来修改被调用合约的地址:

func (evm *EVM) CallCode(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ......

    var (
        to       = AccountRef(caller.Address())
    )
    contract := NewContract(caller, to, value, gas)
    contract.SetCallCode(&addr, evm.StateDB.GetCodeHash(addr), evm.StateDB.GetCode(addr))

    ......
}

可以看到,在利用现有数据生成一个合约对象时,将合约对象的地址 to 变量设置成调用者,也就是 caller 的地址。(但在调用 Contract.SetCallCode 时, code 的地址还是 addr

对于 EVM.DelegateCall 来说,它是通过下面几行代码修改被调用合约的调用者和自身地址的:

func (evm *EVM) DelegateCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
    ......

    var (
        to       = AccountRef(caller.Address())
    )
    contract := NewContract(caller, to, nil, gas).AsDelegate()

    ......
}

func (c *Contract) AsDelegate() *Contract {
    parent := c.caller.(*Contract)
    c.CallerAddress = parent.CallerAddress
    c.value = parent.value

    return c
}

这里首先也是通过将 to 变量设置成调用者,也就是 caller 的地址,达到修改被调用合约的自身地址的目的。被调用合约的调用者是通过 Contract.AsDelegate 修改的。这个方法里,将合约的调用者地址 CallerAddress 设置成目前调用者的 CallerAddress,也即当前调用者的调用者的地址(有些绕,仔细看一下就能明白)。

EVM.StaticCall

EVM.StaticCallEVM.Call 类似,唯一不同的是 EVM.StaticCall 不允许执行会修改永久存储的数据的指令。如果执行过程中遇到这样的指令,就会失败。比如对于以下合约:

contract test { 
    uint256 x;

    function multiplyState(uint256 a) public returns(uint256){
        x = a * 7;
        return x;
    }
}

就不能使用 EVM.StaticCall 调用 multiplyState 方法,因为它会修改合约的状态 x 变量,而 x 变量是需要在以太坊状态数据库永久存储的。

EVM.StaticCall 是如何实现拒绝执行会修改永久存储数据的指令的呢?在解释器的 EVMInterpreter.Run 方法以及 EVMInterpreter.enforceRestrictions 方法中,有几行这样的代码:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......
    if readOnly && !in.readOnly {
        in.readOnly = true
        defer func() { in.readOnly = false }()
    }
    ......

    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......
        operation := in.cfg.JumpTable[op]
        ......
        if err := in.enforceRestrictions(op, operation, stack); err != nil {
            return nil, err
        }
        ......
    }
}

func (in *EVMInterpreter) enforceRestrictions(op OpCode, operation operation, stack *Stack) error {
    if in.evm.chainRules.IsByzantium {
        if in.readOnly {
            if operation.writes || (op == CALL && stack.Back(2).BitLen() > 0) {
                return errWriteProtection
            }
        }
    }
    return nil
}

可以看到, EVMInterpreter.Run 有一个 readOnly 参数,如果为 true,就会设置 EVMInterpreter.readOnly 为 true;而在循环解释执行指令时,会在 EVMInterpreter.enforceRestrictions 方法中对其进行检查,如果是只读,但将要执行的指令有写操作,或者使用 CALL 指令向其它账户转账时,都会直接返回错误。

从上面的代码可以看出,EVM.StaticCall 的实现需要存储指令信息的 operation 对象配合,在其中记录指令是否有写操作。而 readOnly 参数正是来自于 EVM.StaticCall

func (evm *EVM) StaticCall(caller ContractRef, addr common.Address, input []byte, gas uint64) (ret []byte, leftOverGas uint64, err error) {
    ......
    // run 函数的最后一个参数 readOnly 为 true
    ret, err = run(evm, contract, input, true)
    ......
}

func run(evm *EVM, contract *Contract, input []byte, readOnly bool) ([]byte, error) {
    ......
    for _, interpreter := range evm.interpreters {
        if interpreter.CanRun(contract.Code) {
            ......
            // 调用 EVMInterrepter.Run,最后一个参数 readOnly 正是 EVM.StaticCall 传入的 true
            return interpreter.Run(contract, input, readOnly)
        }
    }
    ...... 
}
EVM.Call 的实现

现在我们来看一下最基本的 EVM.Call 的实现,它是如何调用合约的。代码较长,我们仍然分段来看:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    if evm.vmConfig.NoRecursion && evm.depth > 0 {
        return nil, gas, nil
    }

    // Fail if we're trying to execute above the call depth limit
    if evm.depth > int(params.CallCreateDepth) {
        return nil, gas, ErrDepth
    }
    // Fail if we're trying to transfer more than the available balance
    if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
        return nil, gas, ErrInsufficientBalance
    }

    ......
}

方法开始的一部分代码比较简单,与 EVM.create 类似,也是判断递归层次和合约调用者是否有足够的交易中约定的以太币。需要注意的是 input 参数,这是调用合约的 public 方法的参数。

我们继续看下一部分代码:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ......

    var (
        to       = AccountRef(addr)
        snapshot = evm.StateDB.Snapshot()
    )
    if !evm.StateDB.Exist(addr) {
        precompiles := PrecompiledContractsHomestead
        if evm.ChainConfig().IsByzantium(evm.BlockNumber) {
            precompiles = PrecompiledContractsByzantium
        }
        if precompiles[addr] == nil && 
            evm.ChainConfig().IsEIP158(evm.BlockNumber) && value.Sign() == 0 {
            ......
            return nil, gas, nil
        }
        evm.StateDB.CreateAccount(addr)
    }
    evm.Transfer(evm.StateDB, caller.Address(), to.Address(), value)
    // Initialise a new contract and set the code that is to be used by the EVM.
    // The contract is a scoped environment for this execution context only.
    contract := NewContract(caller, to, value, gas)
    contract.SetCallCode(&addr, evm.StateDB.GetCodeHash(addr), evm.StateDB.GetCode(addr))

    ......
}

这部分的代码做了两件事件。一是判断合约地址是否存在;二是使用当前的信息创建一个合约对象,其中 StateDB.GetCode 从状态数据库中获取合约的代码,填充到合约对象中。

一般情况下,被调用的合约地址应该存在于以太坊状态数据库中,也就是说合约已经创建成功了。否则就返回失败。但有一种例外,就是被调用的合约地址是预先定义的情况,此时即使地址不在状态数据库中,也要立即创建一个。(关于预定义的合约地址的详细信息,请参看后面的「预定义合约」小节)。

我们继续看后面的代码:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ......

    ret, err = run(evm, contract, input, false)

    // When an error was returned by the EVM or when setting the creation code
    // above we revert to the snapshot and consume any gas remaining. Additionally
    // when we're in homestead this also counts for code storage gas errors.
    if err != nil {
        evm.StateDB.RevertToSnapshot(snapshot)
        if err != errExecutionReverted {
            contract.UseGas(contract.Gas)
        }
    }
    return ret, contract.Gas, err
}

最后这部分代码主要是对 run 函数的调用,然后处理其返回值并返回,方法结束。这里的 run 函数与创建合约时调用的是同一个函数,因此不再过多介绍这个函数,想要详细了解可以回看前面「创建合约」小节中对于 run 函数的介绍。

到这里你可能会有疑问:没看到哪里调用合约代码呀?只不过是使用 StateDB.GetCode 获取合约代码,然后使用 input 中的数据作为参数,调用解释器运行合约代码而已,哪里有调用合约 public 方法的影子?

这里确实与创建合约时类似,没有「调用」的影子。还记得前面我们介绍合约创建时,提到过合约编译器在编译时,会插入一些代码吗?在介绍合约创建时,我们只介绍了编译器插入的创建合约的代码,解释器执行这些代码,就可以将合约的真正代码返回。类似的,编译器还会插入一些调用合约的代码,只要使用正确的参数执行这些代码,就可以「调用」到我们想调用的合约的 public 方法。

想要了解这整个机制,我们需要先介绍一下「函数选择子」这个概念。在 solidity 的这篇官方文档中,介绍了什么是「函数选择子」。简单来说,就是合约的 public 方法的声明字符串的 Keccak-256 哈希的前 4 个字节。(关于「函数选择子」的详细说明和计算方法,请参看这篇文章)。

「函数选择子」告诉了以太坊虚拟机我们想要调用合约的哪个方法,它和参数数据一起,被编码到了交易的 data 数据中。但我们刚才通过对合约调用的分析,并没有发现有涉及到解析「函函数选择子」的地方呀?这是因为「函数选择子」和参数的解析功能并不是由以太坊虚拟机的 go 代码码完成的,而是由合约编译器在编译时插入的代码完成了。

我们还以「合约创建」小节中合约为例。我们在那里提到过,实际的合约代码是从 sub_0 这个标签处开始的,而当时这个标签后的指令我们并没有列出来。现在我们就把这些指令列出来。

合约的源代码如下:

    contract test { 
    uint256 x;

    constructor() public {
        x = 0xfafa;
    }

    function multiply(uint256 a) public view returns(uint256){
        return x * a;
    }
    function multiplyState(uint256 a) public returns(uint256){
        x = a * 7;
        return x;
    }
}

编译成汇编代码后,sub_0 标签后面的部分代码如下:

sub_0: assembly {
    mstore(0x40, 0x80)
    callvalue
    dup1
    iszero
    tag_1
    jumpi
    0x00
    dup1
    revert
tag_1:
    pop
    jumpi(tag_2, lt(calldatasize, 0x04))
    shr(0xe0, calldataload(0x00))
    dup1
    0x1122db9a  // 方法 multiplyState 的函数选择子的值
    eq
    tag_3
    jumpi
    dup1
    0xc6888fa1  // 方法 multiply 的函数选择子的值
    eq
    tag_4
    jumpi
tag_2:
    ......
tag_3:
    ......
tag_4:
    ......

    ......

我们不需要读懂所有指令,只需注意 0x1122db9a0xc6888fa1 前后的一些指令就可以了。这两个值,正好分别是 test.multiplyStatetest.multiply 的函数选择子的值,其实不用看其它指令,我们也能猜到这里是在比较参数中的函数选择子的值,如果找到了(eq 指令,表示相等),就跳到相应的代码(tag_3tag_4)去执行。这样通过参数中的函数选择子,就达到了调用合约指定 public 方法的目的。(在这个例子中,tag_3 对应 test.multiplyStatetag_4 对应 test.multiply

所以总得来看,调用合约与创建合约的 go 源码没有太大的差别,基本都是创建解释器对象,然后解释执行合约指令。通过执行不同的合约指令,达到创建或调用的目的。

解释器对象:EVMInterpreter

解释器对象 EVMInterpreter 用来解释执行指定的合约指令。不过需要说明一点的是,实际的指令解释执行并不真正由解释器对象完成的,而是由 vm.Config.JumpTable 中的 operation 对象完成的,解释器对象只是负责逐条解析指令码,然后获取相应的 operation 对象,并在调用真正解释指令的 operation.execute 函数之前检查堆栈等对象。也可以说,解释器对象只是负责解释的调度工作。

EVMInterpreter 关键方法是 Run 方法,我们在这一小节里主要介绍一下这个方法的实现。这个方法的代码比较长,我们分开来一部分一部分的来看。下面是第一部分的代码:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    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-- }()

    // Make sure the readOnly is only set if we aren't in readOnly yet.
    // This makes also sure that the readOnly flag isn't removed for child calls.
    if readOnly && !in.readOnly {
        in.readOnly = true
        defer func() { in.readOnly = false }()
    }

    // Reset the previous call's return data. It's unimportant to preserve the old buffer
    // as every returning call will return new data anyway.
    in.returnData = nil

    // Don't bother with the execution if there's no code.
    if len(contract.Code) == 0 {
        return nil, nil
    }

    ......
}

这一部分的代码比较简单,只是初始化 EVMInterpreter 对象的一些字段。intPool 代表的是 big.Int 对象的一个池子,这样可以节省频繁创建和销毁 big.Int 对象的开销。这里还增加了 EVM.depth 的值,这个值在「创建合约」小节提到过。 在一个合约中可以使用 new 创建另外一个合约,这种情况属于合约的递归创建,EVM.depth 记录了递归的层数。

我们继续看下面的代码:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......

    var (
        ......
        mem   = NewMemory() // bound memory
        stack = newstack()  // local stack
        // For optimisation reason we're using uint64 as the program counter.
        // It's theoretically possible to go above 2^64. The YP defines the PC
        // to be uint256. Practically much less so feasible.
        pc   = uint64(0) // program counter
        ......
    )
    ......
}

这一段是一些变量的声明。之所以把这一段单独拿出来,是想要强调 memstack 变量。在合约的执行过程中,有三种存储数据的方式,其中两种就是内存块和栈(详情请参看「存储」小节),它们分别由 memstack 变量代表。mem 是一个 *Memory 类型的对象,它代表了一块平坦的内存空间;stack*Stack 类型,它实现了一个有 push 和 pop 等典型栈操作的栈对象。

我们继续看后面的代码。后面的代码都在一个 for 循环里,这个 for 循环不断的执行合约的执行,直到执行被中断,或遇到错误,遇到停止执行的指令。这个 for 循环比较大,我们依然分开来看:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......

    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......
        op = contract.GetOp(pc)
        operation := in.cfg.JumpTable[op]

        // 检查指令的有效性
        if !operation.valid {
            return nil, fmt.Errorf("invalid opcode 0x%x", int(op))
        }
        // 检查栈空间是否充足
        if err := operation.validateStack(stack); err != nil {
            return nil, err
        }
        // 检查是否有写指令限制
        if err := in.enforceRestrictions(op, operation, stack); err != nil {
            return nil, err
        }

        ......
    }
}

for 循环一开始,就从合约代码中提取当前将要执行的指令的值(pc 跟踪记录了当前将要执行的指令的位置),然后从 vm.Config.JumpTable 中取出记录指令详细信息的 operation 对象。这个对象记录了指令的很多详细信息,比如解释指令的函数、指令消耗的 gas 的计算函数、指令是否是写操作等(详细请参看下面的「跳转表:vm.Config.JumpTable」小节)。紧接着的还有检查栈空间和是否有写指令限制的代码。

我们接着看 for 循环的中间部分的代码:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......

    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......

        // 如果将要执行的指令需要用到内存存储空间,
        // 则计算所需要的空间大小
        var memorySize uint64
        if operation.memorySize != nil {
            memSize, overflow := bigUint64(operation.memorySize(stack))
            if overflow {
                return nil, errGasUintOverflow
            }
            if memorySize, overflow = math.SafeMul(toWordSize(memSize), 32); overflow {
                return nil, errGasUintOverflow
            }
        }

        // 计算将要执行的指令所需要的 gas 值并扣除。如果 gas 不足,就直接失败返回了
        cost, err = operation.gasCost(in.gasTable, in.evm, contract, stack, mem, memorySize)
        if err != nil || !contract.UseGas(cost) {
            return nil, ErrOutOfGas
        }
        // 保证足够的内存存储空间
        if memorySize > 0 {
            mem.Resize(memorySize)
        }

        ......
    }
}

在中间部分的代码中,主要做了两件事,一是计算将要执行的指令使用的内存存储空间,并保证 mem 对象中有足够的空间;二是计算将要消耗的 gas,并立即消耗这些 gas,如果不够用,则返回失败。

我们接下来看最后一部分代码:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......

    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......

        // 调用指令的解释执行函数
        res, err := operation.execute(&pc, in, contract, mem, stack)

        ......

        if operation.returns {
            in.returnData = res
        }

        // 退出,或向前移动指令指针
        switch {
        case err != nil:
            return nil, err
        case operation.reverts:
            return res, errExecutionReverted
        case operation.halts:
            return res, nil
        case !operation.jumps:
            pc++
        }
    }
    return nil, nil
}

这段代码调用 operation.execute 函数,用来真正的解释指令做的事情。在其返回后,通过 switch 判断错误值,或向前移动 pc

总得来说,解释器对象 EVMInterpreter 并没有什么复杂的东西,无非解析合约代码获取指令码,并从 vm.Config.JumpTable 中取得指令信息,然后检查栈、内存存储、gas 等内容,最后调用解释指令的函数执行指令的功能,仅此而已。

预定义合约

解释器对象的 Run 方法是在 run 函数中被调用的。在被调用之前,run 还处理了一种情况,我们称之为「预定义合约」。如下代码所示:

func run(evm *EVM, contract *Contract, input []byte, readOnly bool) ([]byte, error) {
    if contract.CodeAddr != nil {
        precompiles := PrecompiledContractsHomestead
        if evm.ChainConfig().IsByzantium(evm.BlockNumber) {
            precompiles = PrecompiledContractsByzantium
        }
        if p := precompiles[*contract.CodeAddr]; p != nil {
            return RunPrecompiledContract(p, input, contract)
        }
    }

    ......
}

这段代码查看合约的 CodeAddr 是否是预定义合约的地址,如果是就调用 RunPrecompiledContract 运行这些预定义合约,而不再启动解释器。(注意这里用的是 CodeAddr,因为 DelegateCall 等功能可能会改变合约地址,但 CodeAddr 是不会被改变的)

根据以太坊版本的不同,预定义合约的集合也不同,PrecompiledContractsByzantium 这个变量里的数据比较新,所以我们就选这个变量看一下:

var PrecompiledContractsByzantium = map[common.Address]PrecompiledContract{
    common.BytesToAddress([]byte{1}): &ecrecover{},
    common.BytesToAddress([]byte{2}): &sha256hash{},
    common.BytesToAddress([]byte{3}): &ripemd160hash{},
    common.BytesToAddress([]byte{4}): &dataCopy{},
    common.BytesToAddress([]byte{5}): &bigModExp{},
    common.BytesToAddress([]byte{6}): &bn256Add{},
    common.BytesToAddress([]byte{7}): &bn256ScalarMul{},
    common.BytesToAddress([]byte{8}): &bn256Pairing{},
}

可以看到这些所谓的「预定义合约」的地址也是预先定义好的,它们从 1 至 8,类似于 0x0000000000000000000000000000000000000008 这样。与地址对应的对象也都有一个 Run 方法,用来执行各自合约的内容,但它们直接是用 go 代码实现的,而不是解释执行某一份合约。从对象的名字就可以看出来,这些对象都有各自特定的功能。它们是用来实现一些 solidity 的内置函数的。比如对于 solidity 的 ecrecover 这个内置函数来说,它通过编译器编译后,最终是通过使用 STATICCALL 这个指令实现的。STATICCALL 的调用方式如下:

staticcall(gas, toAddr, inputOffset, inputSize, retOffset, retSize)

实现 ecrecover 时,其第二个参数 toAddr 就是 0x0000000000000000000000000000000000000001。最终执行 ecrecover.Run 方法实现此功能。

另外在使用 EVM.Call 调用合约时,如果被调用的合约地址是预定义合约的地址,且它不存在于状态数据库中,就使用预定义地址直接创建一个账户:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ......
    if !evm.StateDB.Exist(addr) {
        precompiles := PrecompiledContractsHomestead
        if evm.ChainConfig().IsByzantium(evm.BlockNumber) {
            precompiles = PrecompiledContractsByzantium
        }
        if precompiles[addr] == nil && evm.ChainConfig().IsEIP158(evm.BlockNumber) && value.Sign() == 0 {
            ......
            return nil, gas, nil
        }
        evm.StateDB.CreateAccount(addr)
    }
}

gas 的消耗

我们知道,合约中指令的执行、数据的存储都是需要消耗 gas 的。这一小节中我们集中看一下如何确定一个特定的动作需要消耗的 gas 值。

gas 值只是一个数值,它有单价,标记在 Transaction.data.Price 字段中。在合约执行开始之前,以太坊会根据交易中的 gas 值和单价,从交易发起人账户中扣除相应的以太币(所谓的「购买」gas);合约执行完成后,再将未使用的 gas 退还能交易发起人。如下代码所示:

func (st *StateTransition) TransitionDb() (ret []byte, usedGas uint64, failed bool, err error) {
    // 在 st.preCheck 中「购买」 gas
    if err = st.preCheck(); err != nil {
        return
    }

    // 执行合约
    ......

    // 退还没用完的 gas,并把已使用的 gas 计入矿工账户中
    st.refundGas()
    st.state.AddBalance(st.evm.Coinbase, new(big.Int).Mul(new(big.Int).SetUint64(st.gasUsed()), st.gasPrice))

    ......
}

这里也可以看出,在合约执行过程中,gas 就是一个整数值,不涉及到账户上以太币的变化。初始化给合约的是可以使用的 gas 的最大值,随着合约的执行, gas 逐渐被消耗,直到合约停止执行或 gas 被耗尽。

我们知道,智能合约中每执行一条指令都需要消耗 gas。实际上在指令在被解释执行之前,就已经计算好需要消耗的 gas ,并首先消耗掉这些 gas 后,才开始解释执行指令。如下代码所示:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......
    for atomic.LoadInt32(&in.evm.abort) == 0 {
        ......
        operation := in.cfg.JumpTable[op] 
        ......

        cost, err = operation.gasCost(in.gasTable, in.evm, contract, stack, mem, memorySize)
        if err != nil || !contract.UseGas(cost) {
            return nil, ErrOutOfGas
        }

        ......
        res, err := operation.execute(&pc, in, contract, mem, stack)
        ......
    }
}

可以看到,在每次执行 operation.execute 解释指令时,都会先调用 operation.gasCost 计算需要的 gas 值,并调用 Contract.useGas 消耗掉这些 gas。

你可能会注意到,上面的代码中调用 operation.gasCost 时第一个参数用到了 EVMInterpreter.gasTable。这个字段中的值定义了一些特殊指令在计算所需 gas 时用到的数值,它在解释器创建时被初始化:

func NewEVMInterpreter(evm *EVM, cfg Config) *EVMInterpreter {
    ......
    return &EVMInterpreter{
        ......
        gasTable: evm.ChainConfig().GasTable(evm.BlockNumber),
    }
}

vm.Config.JumpTable 类似,根据以太坊版本的不同,这里初始化的对象也不同。我们挑一个比较高的版本,看一下 EVMInterpreter.gasTable 最终会被一些什么值填充:

GasTableConstantinople = GasTable{
    ExtcodeSize: 700,
    ExtcodeCopy: 700,
    ExtcodeHash: 400,
    Balance:     400,
    SLoad:       200,
    Calls:       700,
    Suicide:     5000,
    ExpByte:     50,

    CreateBySuicide: 25000,
}

其实 gasCost 是一个函数类型的字段,根据指令的不同,这个字段指向的函数也不同,因而计算方式也不同。这些信息都是在 JumpTable 中配置好的(参见「跳转表:vm.Config.JumpTable」小节)。比如对于 ADD 指令,它的定义如下(下面的代码是函数 newFrontierInstructionSet 的片段):

    ADD: {
        execute:       opAdd,
        gasCost:       constGasFunc(GasFastestStep),
        validateStack: makeStackFunc(2, 1),
        valid:         true,
    },

constGasFuncGasFastestStep 的定义如下:

func constGasFunc(gas uint64) gasFunc {
    return 
        func(gt params.GasTable, 
                evm *EVM, 
                contract *Contract, 
                stack *Stack, 
                mem *Memory, 
                memorySize uint64) (uint64, error) 
        {
            return gas, nil
        }
}

const (
    GasFastestStep uint64 = 3
)

可见对于 ADD 指令来说,它的 gas 消耗是固定的,就是 3。但对于某些指令,gas 的计算就要复杂一些了,这里就不一一列举了,感兴趣的可以直接去源码中查看。

事实上,不仅指令的解释执行本身需要消耗 gas ,当使用内存存储(Memory 对象代表的存储空间,详见下面「Memory」小节)和 StateDB 永久存储时,都需要消耗 gas 。因此在所有 operation.gasCost 所指向的函数中,对 gas 的计算都需要考虑三方面的内容:解释指令本身需要的 gas 、使用内存存储需要的 gas 、使用 StateDB 存储需要的 gas 。对于大多数指令,后两项是用不到的,但对于某些指令(比如 CODECOPY 或 SSTORE),它们的 gasCost 函数会考虑到内存和 StateDB 使用的情况。

这里需要提一下内存存储的 gas 消耗。计算内存存储 gas 值的函数为 memoryGasCost。这个函数会根据所要求的空间大小,计算将要消耗的 gas 值。但只有新需求的空间大小超过当前空间大小时,超过的部分才需要消耗 gas 。

最后需要说明一点的是,除上上面提到的每次执行指令前消耗 gas ,还有两种情况情况会消耗 gas 。一种情况是在执行合约之前;另一种是在创建合约之后。

在执行合约之前消耗的 gas 被称之为 「intrinsic gas」,如下代码所示:

func (st *StateTransition) TransitionDb() (ret []byte, usedGas uint64, failed bool, err error) {
    ......

    // Pay intrinsic gas
    gas, err := IntrinsicGas(st.data, contractCreation, homestead)
    if err != nil {
        return nil, 0, false, err
    }
    if err = st.useGas(gas); err != nil {
        return nil, 0, false, err
    }

    ......
    // 执行合约
    if contractCreation {
        ret, _, st.gas, vmerr = evm.Create(sender, st.data, st.gas, st.value)
    } else {
        // Increment the nonce for the next transaction
        st.state.SetNonce(msg.From(), st.state.GetNonce(sender.Address())+1)
        ret, st.gas, vmerr = evm.Call(sender, st.to(), st.data, st.gas, st.value)
    }

    ......
}

从代码中可以看出,在合约还没有执行之前,就要先 useGas。此时 gas 值是由 IntrinsicGas 计算出来的。这个函数根据合约代码中非 0 字节的数量来计算消耗的 gas 值。(感觉这里好霸道,啥也不干先交钱)

另一种情况在创建合约之后。由于合约创建成功后要把合约代码存储到状态数据库 StateDB 中,所以当然也需要消耗 gas。如下代码所示:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address) ([]byte, common.Address, uint64, error) {
    ......

    // 创建合约。合约代码在返回值 ret 中
    ret, err := run(evm, contract, nil, false)

    maxCodeSizeExceeded := evm.ChainConfig().IsEIP158(evm.BlockNumber) && 
        len(ret) > params.MaxCodeSize
    if err == nil && !maxCodeSizeExceeded {
        // 合约创建成功,将合约保存到 StateDB 之前,先 useGas
        createDataGas := uint64(len(ret)) * params.CreateDataGas
        if contract.UseGas(createDataGas) {
            evm.StateDB.SetCode(address, ret)
        } else {
            err = ErrCodeStoreOutOfGas
        }
    }
}

可以看到在合约创建成功后,要调用 StateDB.SetCode 将合约代码保存到状态数据库中。但在保存之前,要先消耗一定数据的 gas 。如果此时 gas 不够用,合约没有被保存,也就创建失败啦。

跳转表:vm.Config.JumpTable

我们前面已经提到过,解释器不是真正解释指令。指令的真正解释函数,记录在 vm.Config.JumpTable 字段中。在 创建解释器的 NewEVMInterpreter 函数中,根据以太坊版本的不同,会使用不同的对象填充这个字段:

func NewEVMInterpreter(evm *EVM, cfg Config) *EVMInterpreter {
    // We use the STOP instruction whether to see
    // the jump table was initialised. If it was not
    // we'll set the default jump table.
    if !cfg.JumpTable[STOP].valid {
        switch {
        case evm.ChainConfig().IsConstantinople(evm.BlockNumber):
            cfg.JumpTable = constantinopleInstructionSet
        case evm.ChainConfig().IsByzantium(evm.BlockNumber):
            cfg.JumpTable = byzantiumInstructionSet
        case evm.ChainConfig().IsHomestead(evm.BlockNumber):
            cfg.JumpTable = homesteadInstructionSet
        default:
            cfg.JumpTable = frontierInstructionSet
        }
    }

    return &EVMInterpreter{
        evm:      evm,
        cfg:      cfg,
        gasTable: evm.ChainConfig().GasTable(evm.BlockNumber),
    }
}

这里面共有四个集合,稍微看一下就能明白,frontierInstructionSet 这个对象包含了最基本的指令信息,其它三个是对这个集合的扩充,最全的一个是 constantinopleInstructionSet(这跟以太坊的升级历史是一样的)。

这些对象的类型都是 [256]operation,使用时以指令的 opcode 值为索引,从中取出存放指令详细信息的 operation 对象。所有 opcode 定义在 opcodes.go 文件中,这是就不一一列出了。

operation 对象详细记录了指令的所有信息,它的定义如下:

type operation struct {
    execute executionFunc              // 指令的解释执行函数
    gasCost gasFunc                    // 计算指令将要消耗的 gas 值的函数
    validateStack stackValidationFunc  // 检查栈空间是否足够指令使用的函数
    memorySize memorySizeFunc          // 计算指令将要消耗的内存存储空间大小的函数

    halts   bool // 指令执行完成后是否停止解释器的执行
    jumps   bool // 是否是跳转指令(非跳转指令执行时不会修改 pc 变量的值)
    writes  bool // 是否是写指令(会修改 StatDB 中的数据)
    valid   bool // 是否是有效指令
    reverts bool // 指令指行完后是否中断执行并恢复状态数据库
    returns bool // 指令是否有返回值
}

这个结构体的字段几乎都比较好理解,这里就不多解释了。只有 reverts 字段需要多说一下。目前只有 REVERT 指令会设置这个字段为 true( REVERT 指令应该对应了 solidity 语言的 requirerevert 函数)。如果这个字段为 true,则当前指令执行完成后,EVMInterpreter.Run 立即返回错误 errExecutionReverted。这个错误值会一直传回给调用 run 函数的代码中,比如 EVM.createEVM.Call。如果 run 返回的是这个错误值,那么将剩余的 gas 返还给调用者(出乎意料,如果是其它错误,所有 gas 将直接被耗尽),如下代码所示:

func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *big.Int) (ret []byte, leftOverGas uint64, err error) {
    ......

    ret, err = run(evm, contract, input, false)
    if err != nil {
        evm.StateDB.RevertToSnapshot(snapshot)
        if err != errExecutionReverted {
            contract.UseGas(contract.Gas)
        }
    }
}

跳转指令

跳转指令设计得稍微有点特殊,因此这里单独介绍一下。在合约的指令中,有两个跳转指令(不算 CALL 这类):JUMP 和 JUMPI。它们的特殊的地方在于,跳转后的目标地址的第一条指令必须是 JUMPDEST。

我们以 JUMP 指令为例,看一下它的解释函数的代码:

func opJump(pc *uint64, interpreter *EVMInterpreter, contract *Contract, memory *Memory, stack *Stack) ([]byte, error) {
    pos := stack.pop()
    if !contract.validJumpdest(pos) {
        nop := contract.GetOp(pos.Uint64())
        return nil, fmt.Errorf("invalid jump destination (%v) %v", nop, pos)
    }
    *pc = pos.Uint64()

    interpreter.intPool.put(pos)
    return nil, nil
}

这个函数解释执行了 JUMP 指令。代码首先从栈中取出一个值,作为跳转目的地。这个值其实就是相对于合约代码第 0 字段的偏移。然后代码会调用 Contract.validJumpdest 判断这个目的地的第一条指令是否是 JUMPDEST,如果不是这里就出错了。

现在的关键问题是如何判断目的地的第一条指令是 JUMPDEST。你可能会说很简单,看看目的地的第 0 个字节的值是否是 JUMPDEST 这个 opcode 码不是完了嘛。如果这个字节是一条指令,那确实是这样;但如果这个字节是数据,那就不对了。比如 JUMPDEST 这个 opcode 的值为 0x5b, 如果有下面一段指令:

60 5b 60 80 ......

把它翻译成指令的形式为:

PUSH1 0x5b
PUSH1 0x80
......

可以看到,这里的 0x5b 是作为数据出现的,而非指令。但如果某条 jump 指令跳到了第 1 个字节(从 0 开始计数),并把这个 0x5b 当成指令数据,得出跳转后第 0 条指令为 JUMPDEST,显然是不对的。

因此判断目的地第一条指令是否是 JUMPDEST,要保证两点:一是它的值为 JUMPDEST 指令的 opcode 的值;二是这是一条指令,而非普通数据。

下面我们介绍一下 Contract.validJumpdest 是如何做的。除了比对 opcode 外(这个很简单),Contract 还会创建一个位向量对象(即 bitvec,bit vector)。这个对象会从头至尾分析一遍合约指令,如果合约某一偏移处的字节属于普通数据,就将 bitvec 中这个偏移值对应的「位」设置为 1,如果是指令则设置为 0。在 Contract.validJumpdest 中,通过检查跳转目的地的偏移值在这个位向量对象中的「位」是否为 0,来判断此处是否是正常指令。

我们详细看一下 Contract.validJumpdest 的代码:

func (c *Contract) validJumpdest(dest *big.Int) bool {
    udest := dest.Uint64()
    if dest.BitLen() >= 63 || udest >= uint64(len(c.Code)) {
        return false
    }

    // 检查目的地指令值是否为 JUMPDEST
    if OpCode(c.Code[udest]) != JUMPDEST {
        return false
    }

    // 下面的代码都是检查目的地的值是否是指令,而非普通数据

    // CodeHash 不为空的情况。一般调用合约时会进入这个分支
    if c.CodeHash != (common.Hash{}) {
        // Does parent context have the analysis?
        analysis, exist := c.jumpdests[c.CodeHash]
        if !exist {
            // Do the analysis and save in parent context
            // We do not need to store it in c.analysis
            analysis = codeBitmap(c.Code)
            c.jumpdests[c.CodeHash] = analysis
        }
        return analysis.codeSegment(udest)
    }

    // CodeHash 为空的情况,
    // 一般是因为新合约创建、还未将合约写入状态数据库
    if c.analysis == nil {
        c.analysis = codeBitmap(c.Code)
    }
    return c.analysis.codeSegment(udest)
}

代码的一开始检查目的地的偏移不能太大,因为合约代码不可能那么长。然后就是最简单的检查目的地第 0 个字节是否是 JUMPDEST 这个 opcode 值。当这些都符合要求时,就会继续检查目的地的值是否是指令而非数据。

检查是否为指令的代码有两个分支,但基本上它们做的事情是一样的。只不过 Contract.jumpdests 中包含了合约调用者的数据,而 Contract.analysis 只包含了当前合约的数据。这里的重点是 codeBitmap 函数生成的对象,以及这个对象的 codeSegment 方法。

我们先看一下 codeBitmap 函数:

func codeBitmap(code []byte) bitvec {
    bits := make(bitvec, len(code)/8+1+4)
    for pc := uint64(0); pc < uint64(len(code)); {
        op := OpCode(code[pc])

        if op >= PUSH1 && op <= PUSH32 {
            // PUSH1 代表 push 指令后的 1 个字节都是用来入栈的数据
            // PUSH2 代表 push 指令后的 2 个字节都是用来入栈的数据
            // 以及类推,所以 numbits 为 op - PUSH1 + 1
            numbits := op - PUSH1 + 1 
            pc++
            for ; numbits >= 8; numbits -= 8 {
                bits.set8(pc) // 8
                pc += 8
            }
            for ; numbits > 0; numbits-- {
                bits.set(pc)
                pc++
            }
        } else {
            pc++
        }
    }
    return bits
}

这个函数用来生成位向量。位向量的类型为 bitvec,它的真正类型是 []byte。在这个函数中,code 参数代表了某个合约的完整的合约代码,函数从头到尾扫描每一条指令,把普通数据的偏移值在位向量中设置成 1。bits.set 表示将指定偏移位设置成 1;bits.set8 表示将指定偏移位及其后面 7 个位都设置成 1。

(从这个函数上看,貌似只有 push 指令会在代码中夹杂数据,其它指令都不会如此。另外,我觉得这种从头到尾扫描的方式无法保证严格正确,即可能有些是数据但没有在位向量中置 1。也可能是合约编译器做了处理,保证这里可以得到正确结果)

有了 bitvec 对象,就可以通过 bitvec.codeSegment 判断某一个偏移位是否是数据了:

func (bits *bitvec) codeSegment(pos uint64) bool {
    return ((*bits)[pos/8] & (0x80 >> (pos % 8))) == 0
}

这个方法只是查看指定偏移位的值是否为0,如果是,则代表这个偏移处的值是一条指令,而非数据。

总得来说,以太坊虚拟机中对跳转指令限制得比较严格,跳转目的地的第 0 条指令必须是 JUMPDEST,并且不光得 opcode 值相同,还得保证这确实是一条指令,而非普通数据。这种严格的限制可以保证虚拟机执行的正确性,也可能可以避免一些恶意攻击。

存储

以太坊虚拟机中有三个地方可以存储合约的数据:栈、内存块存储、状态数据库。其中只有状态数据库是永久性的,其它两个在合约运行结束后,就销毁了。

栈和内存块存储在解释器准备启动时被创建,如下所示:

func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
    ......

    var (
        mem   = NewMemory() // bound memory
        stack = newstack()  // local stack
    )
    ......
}

下面我们分别详细介绍一下这几个存储对象。

Stack

Stack 对象为合约的运行提供了栈功能,其中的数据先进后出。它的定义很简单:

type Stack struct {
    data []*big.Int
}

它只有一个 data 字段,这是一个可变长数据,用来充当栈存储空间。可以看到它的元素的类型是 big.Int

newstack 函数用来创建一个新的栈对象:

func newstack() *Stack {
    return &Stack{data: make([]*big.Int, 0, 1024)}
}

这个函数也很简单,生成了一个预留 1024 个存储空间的数组填充 data 字段。

Stack 对象有几个操作方法,完全是按照「先进后出」的栈结构语义来实现的,比如 push 和 pop,再比如 swap 方法,其参数 n 也是指从栈顶(即数组最后一个元素)向前 n 个元素,而非从数组第 0 个元素开始算起。Stack 的方法实现都很简单,几乎都是一行代码搞定,因此这里就不再我说了。

Memory

Memory 对象为合约的运行提供了一整块的平坦的存储空间。它的定义如下:

type Memory struct {
    store       []byte
    lastGasCost uint64
}

可以看到这个对象的定义也很简单,store 字段用来提供一块平坦的内存作为存储空间。lastGasCost 用来在每次使用存储空间时,参与计算消耗的 gas。

Memory 对象提供了一些操作方法,如果 set 和 set32,用来将指定的数据存储到指定偏移中。Memory.Resize 方法接收一个数值,保证当前拥有的空间大小不小于这个数值。其它方法比较简单,就不一一介绍了。

需要注意的是,在合约中使用内存存储有可能是需要消耗 gas 的。消耗的大小由 memoryGasCost 计算确定。从这个函数的实现来看,只要所需空间超过当前空间的大小,超过部分就需要消耗 gas。

永久存储:StateDB

永久存储对象 StateDB 即是以太坊的状态数据库,这个数据库中包含了所有账户的信息,包括账户相关的合约代码和合约的存储数据。这个对象不是 evm 模块中的对象。想要详细了解状态数据库,可以参看这篇文章

其它辅助对象

为了文章的完整性,这里稍微提一下 evm 模块中的其它辅助对象。但其实这些对象不是 evm 的核心功能,无需花太多精力去了解。

intPool

intPoolbig.Int 类型的变量的一个缓存池。在 evm 模块的代码中,尤其是指令的解释函数中,当需要新申请或销毁一个 big.Int 变量时,都可以通过 intPool 来进行。

intPool 内部,这个对象使用 Stack 对象存储一些暂时不被使用的 big.Int 变量。当有人调用 intPool.get 申请一个 big.Int 变量时,它会从栈中取出一个,或栈为空时新创建一个;当有人调用 intPool.put 销毁一个 big.Int 变量时,它会将这个变量放到内部的栈中缓存起来(除非栈中缓存的数据已达到上限),供后续有人调用 intPool.get 时重复利用。

logger

在 evm 模块中,log 对象有 JSONLoggerStructLogger 两种,它们都以一定的格式输出一些 log 数据,这些功能没有什么可详细说的,了解一下就行。

如果设置 vm.Config.Debug 字段为 true ,那么在虚拟机运行时,StructLogger 会有一些详细的 log 输出,方便进行调试。但我没有找到有哪个选项或配置文件可以设置这个字段,或许只是在调试代码时手动写一下这个值吧。

总结

evm 模块实现了运行以太坊智能合约的虚拟机,是一个非常重要的模块。在这篇文章里,我们详细了解了 evm 模块的实现,包括合约是如何创建和调用的;合约指令是如何被解释执行的等等。

我们在文章开始已经说过,以太坊智能合约是很重要也很复杂的实现,不能只靠 evm 模块了解智能合约的全部,evm 只提供了智能合约执行的虚拟机,是比较小的一部分。如果想要完整了解以太坊智能合约的实现,还需要学习智能合约的编译器 solidity 项目

水平有限,如果文章中有错误,还请不吝指正。


本文最先发表于我的博客,欢迎评论和转载。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容