---
id: 30
title: "客户端里的 EVM：解释器、JIT 与 revm/evmone 一览"
slug: 0-26-evm-in-clients
date: 2026/10/09
summary: 执行客户端把 EVM 当作一块可替换的执行器，中间隔着一层状态访问接口。go-ethereum 的内置解释器、evmone 与 revm 定位各不相同，多实现一致性靠规范测试向量保障，解释器的优化空间则集中在分派、gas 计量与内存管理上。
keywords: EVM实现,evmone,revm,多客户端一致性,解释器优化
heroImage: /images/articles/photos/0-26-evm-in-clients.jpg
---

执行客户端（execution client）要同时处理区块同步、交易池、状态存储、JSON-RPC 与共识对接，以太坊虚拟机（Ethereum Virtual Machine，EVM）只是其中一块。EVM 不知道区块从哪里来，也不决定一笔交易的 gas 价格由哪条规则给出，它接收字节码、调用数据、执行环境与 gas，返回返回数据、剩余 gas、异常状态与一组状态变更。

把这条边界画清楚，才能理解 go-ethereum、evmone 与 revm 为什么处在不同的位置：一个是客户端内置的解释器，一个是跨语言的独立库，一个是被当作依赖导入的 Rust 框架。下面只讨论通用客户端里 EVM 的嵌入方式与多实现一致性如何保障，不展开并行调度与多引擎设计，那部分见文末延伸阅读。

## EVM 与客户端其余部分的边界

无论哪一个实现，EVM 与宿主之间的交互都收敛到同一组操作：读账户的余额、nonce 与代码，读存储槽，读历史区块哈希；写回存储变更，发出日志，创建或删除账户；以及 gas 的收支。差别只在于这组操作以什么形式暴露。

go-ethereum 的 `core/vm` 把 EVM 实现为一个结构体加一个解释器循环。解释器按 fork 选择一张有 256 个条目的跳表（jump table），每个条目包含执行函数、常量 gas、动态 gas 计算函数、栈高度约束与内存需求。循环的顺序是取操作码、校验栈、扣常量 gas、计算动态 gas、必要时扩展内存、调用执行函数，gas 不足统一返回 `ErrOutOfGas`。状态访问走 `StateDB`，区块与交易上下文走 `BlockContext` 与 `TxContext`。内在 gas、nonce 校验、手续费扣除与退款计算不在解释器里，而在状态转换层完成。

revm 的结构更显式：`Evm` 由 `Context` 与构造器组成，`Context` 又由三块构成，环境（`Transaction`、`Block`、`Cfg`）、`Journal` 与 `Database`。`Database` 是一个只有四个必需方法的接口：`basic` 读账户、`code_by_hash` 读代码、`storage` 读存储槽、`block_hash` 读历史区块哈希。这些数据被读入后缓存在 `Journal` 中，`Journal` 同时记录执行期间的变更，并在执行结束后交回状态差异。解释器不直接接触数据库，而是通过 `Host` 特征访问，该特征按区块、交易、配置、数据库与 Journal 分组，覆盖 `sload`、`sstore`、`tload`、`tstore`、账户加载、日志与自毁。

evmone 把这条边界做成了一个 C 语言的 ABI。它实现 EVMC（Ethereum Client-VM Connector API），该接口被定义为 EVM 与客户端之间的低层 ABI，客户端侧规定 EVM 访问环境与状态的方式。因为边界是 C ABI，evmone 可以被其他语言写的客户端加载；Erigon 的复盘提到，它通过 EVMC 把 evmone 接入了自己的执行层，并额外包了一层 C++ 代码来降低接口开销。

## 三种实现的定位差异

| 实现 | 语言与许可 | 形态 | 状态接口 | 典型复用方式 |
|------|------------|------|----------|--------------|
| go-ethereum `core/vm` | Go，库代码 LGPL-3.0，`cmd/` 下为 GPL-3.0 | 客户端内置解释器 | `StateDB` | 被 fork 为 L2 执行客户端，例如 OP Stack 的 op-geth |
| evmone | C++20，Apache-2.0 | 独立模块，通过 EVMC 暴露 | EVMC 宿主接口 | 被 Silkworm 等客户端加载为执行模块 |
| revm | Rust，MIT | 库与框架，按 crate 拆分 | `Database` 与 `Host` 特征 | Reth、Foundry、Helios 等直接作为依赖，也是 L2 与 zkVM 的常见选择 |

三种形态对应三种工程取舍。内置解释器可以和自己的状态层、跳表与追踪器一起优化，代价是很难被别的客户端复用。独立库可以跨语言复用，但必须穿过一层通用接口，接口两侧的调用开销就是成本。同一篇复盘提到，EVMC 设计上允许宿主调用 EVM、也允许 EVM 反调宿主，后者在通过 CGo 接入 Erigon 时带来了可观的接口开销，最终需要补充 C++ 代码来消除。这条经验说明，把最快的 EVM 接进客户端，与让客户端立刻变快，是两件事。

库形态的收益在生态层面更明显。revm 的文档列出的使用方包括区块构建者、Reth 与 Helios 这类客户端、Foundry 与 Hardhat 这类工具、多条 L2 以及若干 zkVM 项目。反过来，基于 go-ethereum 做 fork 的路线在 L2 侧出现了收缩信号：Optimism 文档显示 op-geth 已于 2026 年 5 月 31 日结束支持，且不支持当前激活的 Karst 硬分叉，主推的执行客户端换成基于 Reth 与 revm 的 op-reth。

## 一致性不是自测出来的：state test 与多客户端测试

共识要求所有节点对同一段字节码得出完全相同的状态。任何单客户端自测都只能证明自洽，不能证明一致。

历史上不一致的代价可以量化。2016 年 11 月 24 日，以太坊在区块 2686351 发生分叉：当导致空账户删除的交易以 out-of-gas 结束时，go-ethereum 没有回滚这些删除，Parity 回滚了。少数派链在区块 2686516 附近被放弃，约 165 个区块的产出作废；go-ethereum 在 v1.5.3 中修正日志机制以匹配 Parity 的行为，官方说明同时指出 Parity 在“out-of-gas 调用预编译合约”这一更窄的场景里也存在不一致。这次修订给 EIP-161 补上了“状态回滚时空账户删除也应回滚”的说明。客户端代码里还留着另一处同类痕迹：go-ethereum 的状态日志对 RIPEMD160 预编译地址 `0x03` 保留一个显式 touch 标记，源码注释说明它用于复现区块 1714175 的空账户 touch/revert 特例。规范与实现之间的这类硬编码兼容会长期存在，也是多实现一致性需要逐例核对的原因。

行业应对的方法是让规范与测试向量同源。执行层规范以 Python 参考实现（Execution Layer Specification，常称 EELS）的形式维护，测试框架从规范出发生成测试向量（fixture）。execution-spec-tests 原本是生成测试向量的 Python 框架与用例集合，2025 年 11 月整体迁入 ethereum/execution-specs（旧仓库随后归档），官方说明客户端的 fixture 发布位置不变，规格与测试从此同源。

状态测试（state test）的判定方式很直接：给定执行前状态 `pre`、环境 `env`、交易 `transaction` 与按分叉划分的期望结果 `post`，客户端应用该交易后必须匹配 `post` 中记录的状态根 `hash` 与日志摘要 `logs`；对应当失败的交易，则以 `expectException` 描述预期异常类型。区块测试在此基础上覆盖区块级处理。这些向量既能通过各客户端自带的测试入口直接执行，go-ethereum 用 `evm statetest`、Besu 用 `evmtool state-test`、Nethermind 用 `nethtest`、evmone 用 `evmone-statetest` 与 `evmone-blockchaintest`；也能放进 Hive，用 Engine API 向客户端发送区块载荷并校验响应与状态同步，这是最接近生产行为的测试形态。ethpandaops 的 hive-tests 仓库把这类测试做成每日运行的流水线，覆盖 Besu、Erigon、EthereumJS、Ethrex、go-ethereum、Nethermind、Nimbus-EL 与 Reth。

测试的边界同样要说清楚。它只能覆盖已经被写下来的行为，每引入一个分叉，就多出一段尚未被向量固定的窗口；它约束的是可观测语义，不是性能，也不是内部结构。

## 指令分派：跳表、生成式 switch 与内联

解释器的主循环每执行一条指令都要先定位它的处理函数。跳表的做法是查一次 256 项数组，再通过函数指针调用。Go 无法穿过函数值内联，因此每条指令都要付出一次数组加载加一次间接调用。

go-ethereum 在 2026 年提交过把分派改成生成式 switch 的改动（PR #35144 与替代它的 PR #35638）。对操作码字节做密集 switch 会编译成跳转表，编译器可以把热点处理函数内联进循环。改动分三层：fork 间行为稳定的热点操作码单独成 case，常量 gas 与栈上下界直接写成常数；带动态 gas 但 fork 间不变的少数操作码仍走表计费，但按名字调用处理函数；凡是随 fork 变化的操作码（`CALL`、`CREATE`、`SSTORE`、`SLOAD`、日志与复制类）继续通过当前 fork 的表分派。原解释器循环保留为参考实现，测试同时跑两条循环并比对输出、gas、错误、退款、日志与状态根，另有针对任意字节码的差分模糊测试。

PR #35638 内的 A/B 基准给出的数字如下，条件为 2000 个主网区块（区块号 25677501 至 25679500）、`geth-benchmark-1` 机器、go1.27.0、每侧 3 次运行：吞吐从 391.2 MGas/s 升至 424.9 MGas/s，`newPayload` 平均耗时从 71.6 ms 降至 65.1 ms，执行阶段 p50 从 37.47 ms 降至 31.58 ms。截至 2026 年 9 月 18 日，该 PR 在 GitHub 上仍为 open、未被合并，因此这些数字属于 PR 内部基准，不是发行版测量，引用时应按待验证处理。

evmone 走了另一条路。它的默认 baseline 解释器只做最基本的 `JUMPDEST` 分析；可选的 advanced 解释器使用间接调用线索化（indirect call threading），把加载后的 EVM 程序表示为一张指向虚拟指令实现函数的指针表，并按基本块预计算 gas 与栈需求，在执行到块入口时一次校验。代价是执行前的字节码分析更重。

## gas 计量：常量与动态分离，冷热访问按槽计价

解释器必须在执行操作码之前算清费用。go-ethereum 把它拆成常量部分与动态部分：常量 gas 直接扣，动态 gas 由处理函数按栈、内存与状态计算；内存扩展费用随所需字数超线性增长，因此解释器要先算出操作码要求的内存大小再计费。

EIP-2929 之后，gas 计量与状态访问层被绑在了一起。该提案要求每笔交易维护已访问地址与已访问存储槽两个集合，存储槽的元素类型是 `Set[Tuple[Address, Bytes32]]`；首次访问某个槽收 `COLD_SLOAD_COST`，即 2100 gas，重复访问收 `WARM_STORAGE_READ_COST`，即 100 gas，首次访问某个地址收 `COLD_ACCOUNT_ACCESS_COST`，即 2600 gas。访问集合属于交易上下文，物理上由客户端的执行环境持有，解释器每次 `SLOAD` 或调用都要回来查询并更新它。这也是改解释器很难脱离状态层单独完成的原因。

按块预计算 gas 有可观测的副作用。evmone 的文档明确指出：因为整个基本块的需求被提前检查，可能出现在逐条计费下本会执行、提前计费下不会执行的外部可见操作；文档认为这不构成共识问题，因为执行以硬异常终止且所有效果都被回滚，但它可能产生不同的执行追踪，或以不同的异常类型结束。这类差异正好落在测试向量约束的范围内。

## 内存与调用帧：把分配挪出热路径

每一次合约调用都需要栈与内存。EVM 的栈有 1024 项上限，内存按需扩展并计费，返回数据也需要缓冲区。这些对象的生命周期短、创建频率高，因此实现普遍倾向于复用而非每次分配：go-ethereum 在解释器入口创建一份 `Memory` 对象，操作码要求更大内存时按字扩展；revm 的上下文层持有一个池化条目（item-pooling）的 `FrameStack`，按索引复用调用帧。

## 预编译：原生实现与依赖取舍

预编译合约是少数几个固定地址上的原生实现，执行时由宿主直接计算，不解释字节码，解释器只负责路由与 gas 计费，部分预编译的费用还与输入长度相关。

独立库在这一层的取舍值得单独看。evmone 的文档说明了两处妥协：`ecrecover` 由 evmone 自己实现，性能有下降；`expmod` 默认使用桩实现，只对已知输入给出正确响应，要拿到完整实现需要在构建时打开 `EVMONE_PRECOMPILES_GMP=1`，并引入 GMP 作为构建与运行时依赖。这反映了一个通用问题：预编译的正确性要求对任意输入都成立，而把大整数运算、椭圆曲线恢复与 Keccak 哈希等原生代码做到与其它实现逐位一致，本身也是一处容易藏分歧的地方。

## JIT 为什么没有成为主流选择

EVM 客户端里的 JIT（just-in-time compilation，即时编译）只有过一次正式尝试：ethereum/evmjit 基于 LLVM，在运行时把合约代码编译成机器码，用来替换客户端里基于解释器的 EVM。该仓库现已归档，README 写明项目不再维护、不要用于任何重要用途。

停止维护的原因 README 没有说明，从技术条件看有几处障碍。EVM 的跳转目标来自栈上的值，代码在运行时可被读取，程序结构要到执行时才确定，编译器很难像在 JVM 那样在编译期获得稳定的控制流图；单次调用可用的计算量还受区块 gas 上限约束，收益总量存在天花板。相对成熟的替代是把分析放到静态阶段，EOF（EVM Object Format，EIP-7692）正是这个方向的尝试，它包含容器格式、静态相对跳转、栈验证与代码段数据段分离等条目。但这条路线目前并不活跃：EOF 在 2025 年 4 月 28 日被移出 Fusaka 升级范围，EIPs PR #9703 当天合并，只覆盖 Fusaka 阶段的移除；EIP-7692 本身的状态标为 stagnant，Glamsterdam 的官方范围清单 EIP-7773 里也没有它。第三方跟踪站点 eipsinsight 的变更记录显示，EIP-7692 在 2026 年 8 月 27 日的 Glamsterdam 范围整理中被移出候选桶，这一条只有第三方来源。因此短期内通用客户端的优化主战场仍是解释器，编译执行尚无落地空间。

## 什么条件下这些优化不奏效

执行不再是耗时主体时，收益递减。在上面引用的 go-ethereum 基准里，执行阶段 p50 从 37.47 ms 降到 31.58 ms，而区块总时间 p50 从 62.1 ms 降到 56.2 ms：按同一张表的账，状态读取、状态哈希与提交合计约占总时间的三分之一，再算上引擎层开销接近一半，解释器优化的天花板由这个比例决定。负载越偏向状态访问与磁盘 I/O，优化解释器的边际收益越低。

fork 差异让优化无法全局套用。跳表按 fork 生成，生成式 switch 也只能内联 fork 间行为稳定的操作码；元数据随 fork 变化的操作码必须留在表分派路径上，否则就会写出错误的常量。

语义约束划定了优化的边界。优化不得改变日志顺序、追踪钩子顺序、异常类型与 gas 计量，这些都被测试向量固定。evmone 那个基本块级提前检查可能提前终止的例子，正是优化与可观测语义之间需要显式权衡的地方。

一致性成本随实现数量上升。每多一个实现，共识边界上就多一处可能分歧的地方；测试向量能压缩这个面，但不能消除它。多实现带来的冗余价值与这份成本，是同一条权衡的两端。

## 资料来源

- revm 文档，Introduction：https://bluealloy.github.io/revm/
- revm 文档，Architecture：https://bluealloy.github.io/revm/architecture.html
- revm，`Database` 特征：https://docs.rs/revm/latest/revm/context/trait.Database.html
- revm，`Host` 特征：https://docs.rs/revm-interpreter/latest/revm_interpreter/trait.Host.html
- revm 仓库与使用者列表：https://github.com/bluealloy/revm
- evmone 仓库说明（baseline 与 advanced 解释器、预编译取舍）：https://github.com/ethereum/evmone
- evmone，Efficient gas calculation algorithm for EVM：https://github.com/ethereum/evmone/blob/master/docs/efficient_gas_calculation_algorithm.md
- EVMC，Ethereum Client-VM Connector API：https://github.com/ethereum/evmc
- Silkworm 项目起源复盘（EVMC 与 CGo 接口开销）：https://erigon.substack.com/p/staged-sync-and-short-history-of
- go-ethereum 解释器循环：https://github.com/ethereum/go-ethereum/blob/master/core/vm/interpreter.go
- go-ethereum 跳表定义：https://github.com/ethereum/go-ethereum/blob/master/core/vm/jump_table.go
- go-ethereum PR #35638（生成式 switch 与 PGO 内联，含基准条件）：https://github.com/ethereum/go-ethereum/pull/35638
- go-ethereum 仓库与许可说明：https://github.com/ethereum/go-ethereum
- go-ethereum 状态转换层（内在 gas、nonce 校验、退款）：https://github.com/ethereum/go-ethereum/blob/master/core/state_transition.go
- 以太坊黄皮书（内存扩展费用函数）：https://ethereum.github.io/yellowpaper/paper.pdf
- EIP-2929，Gas cost increases for state access opcodes：https://eips.ethereum.org/EIPS/eip-2929
- execution-specs 仓库（规范与测试向量）：https://github.com/ethereum/execution-specs
- The Weld is Complete（execution-spec-tests 合并公告）：https://steel.ethereum.foundation/blog/2025-11-04_weld_final/
- 状态测试格式说明：https://steel.ethereum.foundation/docs/execution-specs/running_tests/test_formats/state_test/
- 直接执行测试向量与各客户端入口：https://steel.ethereum.foundation/docs/execution-specs/running_tests/consume/direct/
- ethpandaops/hive-tests（多客户端每日一致性测试）：https://github.com/ethpandaops/hive-tests
- 以太坊基金会安全公告，2016-11-24 共识缺陷：https://blog.ethereum.org/2016/11/25/security-alert-11242016-consensus-bug-geth-v1-4-19-v1-5-2
- go-ethereum v1.5.3 发行说明：https://github.com/ethereum/go-ethereum/releases/tag/v1.5.3
- ethereum/evmjit 仓库（已归档）：https://github.com/ethereum/evmjit
- EIP-7692，EVM Object Format (EOFv1) Meta：https://eips.ethereum.org/EIPS/eip-7692
- EIP-7607 移除 EOF 的说明（EIPs PR #9703）：https://github.com/ethereum/EIPs/pull/9703
- EIP-7773，Hardfork Meta - Glamsterdam（官方范围清单，不含 EOF）：https://eips.ethereum.org/EIPS/eip-7773
- Optimism 文档，op-geth 结束支持公告：https://docs.optimism.io/notices/op-geth-deprecation
- 第三方跟踪站点 eipsinsight，Glamsterdam 升级范围与变更记录：https://eipsinsight.com/upgrade/glamsterdam

## 延伸阅读

- 相关阅读：[《Bitroot 多引擎并行执行设计：调度、分片与冲突面》](/zh/blog/bitroot-evm)
- 相关阅读：[《Bitroot 并行 EVM 架构概览：共识、执行与状态如何协同》](/zh/blog/bitrootevm)
- 兼容性延伸：[《EVM 兼容意味着什么：字节码、预编译、JSON-RPC 与工具链》](/zh/blog/evm-compatibility-explained)
