Read OSS

区块执行:BlockChain、StateDB 与 EVM

高级

前置知识

  • 第 1-2 篇:架构与启动流程
  • 以太坊状态模型(账户、存储、状态 trie)
  • Merkle Patricia Trie 基础知识

区块执行:BlockChain、StateDB 与 EVM

这里才是以太坊真正运转的地方。此前介绍的一切——CLI 框架、Node 容器、服务注入——都是为了支撑同一件事:执行区块,推进世界状态。本文将完整追踪一个区块从到达到状态提交的全过程,重点覆盖三大子系统:负责追踪规范链的 BlockChain 管理器、在内存中持有世界状态的 StateDB,以及执行合约字节码的 EVM。

BlockChain:规范链管理器

BlockChain 结构体是链状态的核心协调者。它负责管理规范链、处理区块插入与重组,并维护最近访问数据的 LRU 缓存:

type BlockChain struct {
    chainConfig *params.ChainConfig
    cfg         *BlockChainConfig
    db          ethdb.Database
    // ... caches, trie database, snapshot tree, etc.
}

BlockChainConfig 结构体(注意不要与 params.ChainConfig 混淆)控制运行时行为,包括 trie 缓存限制、快照设置、归档模式、状态裁剪策略以及 VM 配置。这正是 eth.New() 根据 CLI 参数和协议默认值组装出来的配置对象。

几个缓存常量揭示了性能优先级的考量:

const (
    bodyCacheLimit     = 256
    blockCacheLimit    = 256
    receiptsCacheLimit = 32
    txLookupCacheLimit = 1024
)

交易查找缓存最大(1024 条),原因在于 eth_getTransactionByHash 是调用频率最高的 RPC 方法之一。

classDiagram
    class BlockChain {
        -chainConfig *ChainConfig
        -cfg *BlockChainConfig
        -db ethdb.Database
        -triedb *triedb.Database
        -snaps *snapshot.Tree
        -bodyCache LRU
        -blockCache LRU
        -txLookupCache LRU
        +InsertChain(blocks) error
        +CurrentBlock() *Header
        +StateAt(root) (*StateDB, error)
        +GetBlock(hash, number) *Block
    }
    class BlockChainConfig {
        +TrieCleanLimit int
        +TrieDirtyLimit int
        +StateScheme string
        +ArchiveMode bool
        +SnapshotLimit int
        +VmConfig vm.Config
        +triedbConfig() *triedb.Config
    }
    BlockChain --> BlockChainConfig

triedbConfig() 方法是区块链层与存储层之间的关键桥梁——它将 BlockChainConfig 的参数转换为对应的 triedb.Config,并在基于哈希和基于路径的存储方案之间做出选择。我们将在第 4 篇中深入探讨这一机制。

共识引擎:验证与执行的分离

consensus.Engine 接口将区块验证与区块执行清晰地分开。这一分离至关重要——验证负责检查区块是否可能合法(难度值正确、封印有效、叔块引用合规),而执行则将区块应用于状态,产生下一个世界状态。

classDiagram
    class Engine {
        <<interface>>
        +Author(header) address, error
        +VerifyHeader(chain, header) error
        +VerifyHeaders(chain, headers) chan, chan
        +VerifyUncles(chain, block) error
        +Prepare(chain, header) error
        +Finalize(chain, header, state, body)
        +FinalizeAndAssemble(chain, header, state, body, receipts) block, error
        +Seal(chain, block, results, stop) error
    }
    class Beacon {
        -ethone Engine
    }
    class Clique {
        -config *CliqueConfig
    }
    class EthashFaker {
    }
    Beacon ..|> Engine
    Clique ..|> Engine
    EthashFaker ..|> Engine
    Beacon --> Clique : wraps
    Beacon --> EthashFaker : wraps

合并(Merge)之后,所有共识引擎都由 beacon.New() 包装。Beacon 引擎负责处理权益证明相关的逻辑,同时将合并前的逻辑委托给内层引擎。由于 Geth 现在只支持合并后的网络(在 CreateConsensusEngine 中强制执行),TerminalTotalDifficulty 必须始终被设置。

状态执行流水线

执行流水线是区块转化为状态变更的核心路径。整个过程从 StateProcessor.Process() 开始,它会遍历区块中的每一笔交易:

flowchart TD
    A["StateProcessor.Process(block, statedb)"] --> B["Apply pre-execution system calls<br/>(beacon root, parent hash)"]
    B --> C["For each transaction:"]
    C --> D["TransactionToMessage()"]
    D --> E["ApplyTransactionWithEVM()"]
    E --> F["State Transition:<br/>intrinsic gas → EVM execution → refunds"]
    F --> G["Generate receipt"]
    G --> C
    C --> H["postExecution()<br/>(withdrawals, requests)"]
    H --> I["Finalize via consensus engine"]
    I --> J["Return receipts, logs, gas used"]

函数首先执行系统调用——处理 beacon 区块根(EIP-4788)和父区块哈希(EIP-2935,适用于 Prague 和 Verkle)。随后遍历所有交易,通过 ApplyTransactionWithEVM 逐一处理。整个区块共享同一个 EVM 实例,在交易之间只替换 TxContext,不重新创建 EVM。

ExecutionResult 记录每笔交易的执行结果:

type ExecutionResult struct {
    UsedGas    uint64
    MaxUsedGas uint64
    Err        error
    ReturnData []byte
}

提示: 调试 EVM 执行问题时,要注意 ExecutionResult.Err 并不是"出了什么问题"意义上的 Go 错误——它是协议层面的执行结果。如果返回 ErrExecutionRevertedReturnData 不为空,说明合约是主动 revert 的,ReturnData 中包含 revert 的原因数据。

IntrinsicGas 函数在 EVM 开始执行之前计算基础 gas 成本,考虑因素包括:创建合约还是调用合约、calldata 中零字节与非零字节的成本、访问列表条目,以及授权列表条目。EIP-2028 降低 calldata 成本和 EIP-3860 限制 initcode 大小,都是在这里得到执行的。

StateDB:内存中的世界状态

StateDB 是以太坊世界状态的内存缓存层。它位于 EVM 与持久化 trie 之间,提供高速读写能力,同时追踪所有修改以支持回滚:

type StateDB struct {
    db         Database
    prefetcher *triePrefetcher
    reader     Reader
    trie       Trie

    originalRoot common.Hash
    stateObjects map[common.Address]*stateObject
    stateObjectsDestruct map[common.Address]*stateObject
    mutations    map[common.Address]*mutation

    refund uint64
    // journal for revert snapshots, logs, etc.
}

几个关键的设计决策值得关注:

  1. 惰性 trie 解析 —— trie 字段采用"首次访问时才解析"的策略,即在真正读取状态之前,不会加载实际的 Merkle trie 根节点。

  2. 对象追踪 —— stateObjects 持有正在被修改的账户对象;stateObjectsDestruct 追踪已销毁的账户;mutations 在交易边界记录账户级别的变更。

  3. 基于 journal 的快照 —— StateDB 通过 journal 模式创建回滚点。当 EVM 执行到 REVERT 操作码时,journal 会向前回放,撤销当前调用帧内的所有状态变更。

  4. 预取机制 —— triePrefetcher 在后台 goroutine 中提前加载 trie 节点,减少执行过程中因等待磁盘读取而产生的阻塞。

classDiagram
    class StateDB {
        -db Database
        -trie Trie
        -stateObjects map~Address → stateObject~
        -mutations map~Address → mutation~
        -refund uint64
        +GetBalance(addr) uint256
        +SetState(addr, key, value)
        +CreateAccount(addr)
        +Snapshot() int
        +RevertToSnapshot(id)
        +Commit(block, collectLeaf) (Hash, error)
    }
    class stateObject {
        -address Address
        -data types.StateAccount
        -code []byte
        -dirtyStorage Storage
        -originStorage Storage
    }
    StateDB o-- stateObject

EVM 架构:基于跳转表的解释器

EVM 是以太坊计算模型的核心。Geth 将其实现为一个跳转表驱动的解释器——一个紧凑的主循环,每次取出一个操作码,在包含 256 个条目的跳转表中查找对应的处理函数,然后执行。

EVM 结构体携带了所有执行上下文:

type EVM struct {
    Context BlockContext  // Block-level: coinbase, gas limit, block number, time
    TxContext             // Tx-level: origin, gas price, blob hashes
    StateDB StateDB       // World state access
    table   *JumpTable    // Fork-specific opcode handlers
    depth   int           // Call stack depth
    chainConfig *params.ChainConfig
    chainRules  params.Rules
    Config  Config        // VM config: tracer, extra EIPs
}

BlockContextTxContext 的拆分是有意为之的。区块级上下文(coinbase、时间戳、区块号)在同一区块内的所有交易中保持不变,而交易级上下文(发送方地址、gas 价格)则随每笔交易切换。这样一来,NewEVM 每个区块只需调用一次,在交易之间通过 SetTxContext 替换上下文即可。

Run() 方法是解释器的主循环:

flowchart TD
    START["Run(contract, input, readOnly)"] --> CHECK["Code empty?"]
    CHECK -->|yes| RET_NIL["Return nil, nil"]
    CHECK -->|no| INIT["Create Memory, Stack, ScopeContext"]
    INIT --> LOOP["Main Loop"]
    LOOP --> FETCH["op = contract.GetOp(pc)"]
    FETCH --> LOOKUP["operation = jumpTable[op]"]
    LOOKUP --> VALIDATE["Check stack bounds"]
    VALIDATE --> GAS["Deduct constantGas"]
    GAS --> DYNGAS["Calculate & deduct dynamicGas"]
    DYNGAS --> EXEC["Execute operation.execute()"]
    EXEC --> NEXT["Advance pc"]
    NEXT --> LOOP
    EXEC -->|STOP/RETURN/REVERT| EXIT["Return result"]

跳转表中的每个条目都是一个 operation 结构体:

type operation struct {
    execute     executionFunc
    constantGas uint64
    dynamicGas  gasFunc
    minStack    int
    maxStack    int
    memorySize  memorySizeFunc
    undefined   bool
}

这一设计将每个操作码的所有行为——执行函数、gas 成本、栈要求、内存大小计算——全部收纳在同一个结构体中。新增一个操作码,只需在表中添加一条记录即可。

分叉分层:新 EIP 如何添加操作码

各分叉专属的指令集通过分层叠加的方式构建。每个新分叉复制上一分叉的指令表,再对特定条目进行覆盖。jump_table.go 中的链条从 Frontier 出发,一直延伸到 Amsterdam:

var (
    frontierInstructionSet         = newFrontierInstructionSet()
    homesteadInstructionSet        = newHomesteadInstructionSet()
    // ... each one builds on the previous
    pragueInstructionSet           = newPragueInstructionSet()
    osakaInstructionSet            = newOsakaInstructionSet()
    amsterdamInstructionSet        = newAmsterdamInstructionSet()
)

newAmsterdamInstructionSet() 为例,它复制 Osaka 的指令集,并启用 EIP-7843(SLOTNUM 操作码)和 EIP-8024:

func newAmsterdamInstructionSet() JumpTable {
    instructionSet := newOsakaInstructionSet()
    enable7843(&instructionSet)
    enable8024(&instructionSet)
    return validate(instructionSet)
}

NewEVM() 被调用时,会根据 chainRules 选择正确的指令集。chainRules 是根据 ChainConfig 在当前区块号和时间戳下推导出的一组布尔标志,选择逻辑使用逆时间顺序的 switch 语句——最新的分叉最先被检查。

提示: 要追踪某个 EIP 的具体实现,可以直接搜索对应的 enable 函数(例如 enable4844)。这些函数会原地修改跳转表,为每个受影响的操作码设置 executeconstantGasdynamicGas 以及栈参数。

这条执行流水线——BlockChain → StateProcessor → EVM → StateDB——就是 Geth 的核心所在。但所有这些状态在提交之后,究竟存储到了哪里?这正是第 4 篇要回答的问题:我们将追踪数据如何从内存中的 StateDB,经由 Merkle Patricia Trie,最终落盘到键值存储中。