Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

EVM 与内部状态

EVM(Ethereum Virtual Machine)是以太坊的执行引擎。这里记录它的架构、它眼中的“状态“长什么样、以及一笔交易如何驱动状态迁移。账户如何进入这棵状态树,见 账户模型与私钥。

架构总览

  • 基于栈的虚拟机:不是寄存器机。操作数与中间结果都压在栈上,指令从栈顶取参。
  • 栈深度上限 1024,栈项固定 256 bit——与 keccak256、SSTORE 等原生操作的字宽一致,也是 uint256 成为默认类型的根本原因。
  • 确定性:同样的输入(状态 + 交易数据)在任何节点上必然得到同样的输出,没有随机数指令、没有系统时间。时间戳与 PREVRANDAO 等来自区块上下文,不能直接当作不可操纵的安全随机数。
  • 沙箱:EVM 无法访问网络、文件系统、进程,能触碰的只有自己的状态与上下文消息。

数据位置

位置生命周期大小访问成本
Stack单次调用1024 项 × 256 bit最便宜(部分指令免费)
Memory单次调用字节寻址,按需扩展,扩展有二次方 gas便宜
Storage永久,随账户写入状态树每 slot 32 字节昂贵(EIP-2929 下 SLOAD 冷读 2100 gas,写入另行计费)
Transient (EIP-1153)单笔交易内同 storage 的 slot 模型介于 memory 与 storage 之间
Calldata单次调用,只读交易附带的字节数组读取另有指令成本;交易数据费用见 Gas 优化
Log永久,但合约不可读事件数据仅 LOG 系列指令写入
  • Transient storage(Cancun 升级,2024)适合跨调用临时记账(如重入锁),语言层对应 transient 关键字,使用与 storage 类似的槽位编号,但拥有独立的存储空间;同编号的持久槽与瞬态槽互不覆盖。瞬态状态按所属合约隔离,DELEGATECALL 使用调用者的空间。
  • Log 只能由链下索引读取,合约自己也无法读取——事件不是存储。

Memory 扩展的二次方成本

Memory 以 32 字节(word)为单位扩展,访问超出已分配范围的地址会触发扩展并收费:

$$C(a) = 3a + \left\lfloor \frac{a^2}{512} \right\rfloor$$

其中 \(a\) 是扩展后所需的 word 数。二次方项让海量内存的成本急剧上升——这是对“恶意合约撑爆节点内存“的定价防御,也是大数组应尽量留在 calldata 的原因。

冷/热访问:Access Set(EIP-2929)

Berlin 起,EVM 为每笔交易维护 accessed_addresses 与 accessed_storage_keys 两个集合:

  • 冷读:首次访问未入集合的地址或 storage slot,昂贵——SLOAD 冷读 2100 gas。
  • 热读:已入集合后的再次访问,便宜——SLOAD 热读仅 100 gas。

同一交易内对同一 slot 的反复访问(如重入场景)从第二次起都走热读价格。

状态模型

以太坊执行层使用 Merkle Patricia Trie 组织账户、交易和回执。区块头直接记录 stateRoot、transactionsRoot 与 receiptsRoot;各账户的存储根通过账户状态间接承诺:

  • World State Trie:地址 → 账户 的映射。账户节点即账户状态四元组。

  • Storage Trie:每个合约账户一棵,逻辑上是 slot → 32 字节值 的稀疏映射,Trie Key 取 keccak256(slot),根哈希就是账户的 storageRoot。slot 空间名义上 2²⁵⁶,实际是稀疏的——没有写过的 slot 根本不存在。协议与物理层的完整拆解见持久键值存储。

  • Transactions Trie:区块内交易列表。

  • Receipts Trie:每笔交易的执行结果(状态码、gas 消耗、Log)。

  • 状态的“修改“实际上是新建节点、自底向上重算哈希,旧节点在短期内仍可被引用——这就是历史状态查询(如 eth_getStorageAt 指定旧区块)的实现基础。

回滚的实现:Journal(撤销日志)

“revert 状态回滚“在节点内部并不靠重建或拷贝 Trie:

  • 执行期间,节点(如 Geth)在内存中维护一个 Journal:每次 SSTORE、余额变更、账户创建都追加一条逆向操作(“把 slot A 改回旧值 V”)。
  • REVERT 或异常时,从 Journal 倒序弹出即可恢复状态,复杂度 \(O(k)\)(\(k\) 为本次变更条数),与 Trie 规模无关。
  • 交易执行结果先进入客户端的状态缓存;状态根计算与数据库持久化由客户端在区块处理和提交过程中完成。具体存储实现不属于 EVM 指令语义。

状态膨胀与 Trie 演进

  • World State 节点已达数十亿级,MPT 深度变长带来 I/O 放大,witness(状态证明)也随之膨胀。
  • 演进方向是 Statelessness:用 Verkle Tree(多项式承诺)或 Binary Trie 替换 MPT,把 witness 压缩到无状态节点可验证的量级——节点只需验证证明,不再存储全量状态。

一笔交易的执行流程

  1. 交易进入内存池(交易池与网络层),节点验证签名(ecrecover),nonce、余额、gas 合法性检查。
  2. 打包进区块后按序执行:从发送者扣 gas 预付款,nonce +1。
  3. to 为空时走顶层合约创建,地址由发送者与 nonce 决定;否则加载目标账户代码,在新的调用帧中执行。CREATE2 是合约内部执行的部署指令。
  4. 执行过程中 SSTORE/SLOAD 读写 storage trie,产生 Log,可能内部调用其他合约(同一交易内)。
  5. 正常结束:剩余 gas 按协议退款;revert:撤销执行产生的状态修改与日志,但发送者的 nonce 增量和已消耗 gas 的费用仍保留。普通 REVERT 会退还未消耗的 gas,不能与耗尽 gas 的异常混为一谈。
  6. 结果写入 Receipts Trie,所有 Log 与状态变更最终汇集为新的 World State Root。

调用指令的上下文矩阵

第 4 步的“内部调用“由不同的调用指令驱动。每条指令都会压入一个新的调用帧(Call Frame),对上下文的继承各不相同:

指令执行的代码address(this)msg.senderStoragemsg.value
CALL目标合约目标地址调用者目标合约显式传递
CALLCODE(已废弃)目标合约调用者调用者调用者显式传递
DELEGATECALL目标合约调用者沿用上一调用帧的 sender调用者沿用上层
STATICCALL目标合约目标地址调用者目标合约强制为 0

STATICCALL(EIP-214)会置起 read-only 标志:此状态下触发任何状态变更指令(SSTORE、CREATE、LOG0-LOG4、SELFDESTRUCT、TSTORE)都会直接异常终止。

Opcodes 概览

  • 分类:算术(ADD/MULMOD…)、比较与位运算、栈与内存(PUSH/DUP/MLOAD)、存储(SSTORE/SLOAD/TSTORE)、环境(CALLER/GASPRICE)、调用(CALL/DELEGATECALL/STATICCALL)、终止(RETURN/REVERT/SELFDESTRUCT)。
  • 完整清单与各 EIP 版本的 gas 对照:evm.codes
  • DELEGATECALL 的上下文共享语义是重入攻击与代理合约(见 USDC 代理合约)共同的基础。

字节码的演进:EOF(EVM Object Format)

Legacy 字节码是一串无格式的操作码流:PUSH 携带的数据与可执行指令难以区分,栈深检查只能留到运行时。EOF(EIP-3540 系列)曾计划用结构化容器解决:

  • 字节码划分为 Header、Code Section(纯指令)与 Data Section(只读数据,取代 CODECOPY 夹带部署数据的惯例)。
  • 禁止 JUMP 跳到任意字节偏移,只允许跳到指令边界。
  • 部署期即可静态验证最大栈深,消除运行时 1024 栈检查的部分开销。

不过 EOF 的落地进程一再推迟,2025 年 4 月已从 Fusaka 升级中移除,主网至今仍执行 legacy 字节码——其设计思想仍是理解 EVM 演进方向的参照。

参考资料