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 压缩到无状态节点可验证的量级——节点只需验证证明,不再存储全量状态。
一笔交易的执行流程
- 交易进入内存池(交易池与网络层),节点验证签名(ecrecover),nonce、余额、gas 合法性检查。
- 打包进区块后按序执行:从发送者扣 gas 预付款,nonce +1。
to为空时走顶层合约创建,地址由发送者与 nonce 决定;否则加载目标账户代码,在新的调用帧中执行。CREATE2是合约内部执行的部署指令。- 执行过程中
SSTORE/SLOAD读写 storage trie,产生 Log,可能内部调用其他合约(同一交易内)。 - 正常结束:剩余 gas 按协议退款;revert:撤销执行产生的状态修改与日志,但发送者的 nonce 增量和已消耗 gas 的费用仍保留。普通
REVERT会退还未消耗的 gas,不能与耗尽 gas 的异常混为一谈。 - 结果写入 Receipts Trie,所有 Log 与状态变更最终汇集为新的 World State Root。
调用指令的上下文矩阵
第 4 步的“内部调用“由不同的调用指令驱动。每条指令都会压入一个新的调用帧(Call Frame),对上下文的继承各不相同:
| 指令 | 执行的代码 | address(this) | msg.sender | Storage | msg.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 演进方向的参照。