在链上转 100 个 USDT(ERC-20 代币)不会新增一条“甲付给乙 100 USDT”的条目。实际发生的是合约里 balanceOf 映射的取值被改写:调用方减少 100,接收方增加 100;这笔改写最终落在哪些存储槽由合约写法与编译器决定,协议只约束 balanceOf 返回什么。同时,一笔事件日志(log)被追加进交易收据,日志是给链下索引器消费的副产品,余额的真相只在存储的当前值里。
账本类比在这里失效。账本记录已经发生的事,读者累加历史得出结论;以太坊保存的是一个会被持续覆盖的“当前世界”,交易只把它推向下一版。要回答“EVM 到底是什么”,先要把这个“当前世界”定义清楚,再谈谁负责改写它。
比特币的账本模型够用,因为它的状态结构极其受限
“账本”对比特币是准确的描述。一笔交易的输入引用此前某笔交易的输出,输出在被后续输入花掉之前,保持为未花费交易输出(unspent transaction output,UTXO)。一个 UTXO 只能被花费一次,钱包显示的余额其实是若干 UTXO 的加总。
比特币并非没有状态,UTXO 集合就是状态。但这个状态的结构非常受限。它是一堆只能整体创建、整体花费的“硬币”,没有通用的键值存储,脚本也不保存跨交易存在的变量。于是“账本加未花费输出集合”足以完整描述整个系统:验证一笔交易只需要检查被引用的输出是否存在、条件脚本是否被满足,不必查询任何“合约内部变量”。
以太坊要支持合约持有一个可任意读写的映射,这个映射就不能再当作实现细节。ERC-20 转账是否有效,取决于合约存储中调用者的余额槽当前是几。状态因此从“验证时顺带维护的集合”升级为协议必须作出承诺的一等对象。
世界状态:把“此刻的一切”压成 32 字节
以太坊黄皮书把世界状态(world state)定义为一个从地址到账户的映射。每个账户是一个四元组:
- nonce:该地址已发出的交易数,或该合约账户已创建的合约数;
- balance:以 wei 计价的余额;
- storageRoot:该账户存储内容的 Merkle Patricia Trie 根哈希;
- codeHash:该账户代码的 Keccak-256 哈希,代码本身按这个哈希存放在状态数据库里。
这里要区分两样东西。storageRoot 承诺的是账户自己的键值空间,合约作者声明的状态变量最终都落在这里;codeHash 指向的是程序,部署之后不会被某笔交易改写。把“数据”与“代码”用两个字段分别承诺,是后面讨论读写集与冲突检测时反复要用的前提(同系列的 0.5《三类存储别搞混:Memory、Storage 与 Transient Storage》会展开这三类存储各自的归属与寿命)。
所有账户再组织成一棵全局的 Merkle Patricia Trie,其根哈希就是 stateRoot,被写进区块头。也就是说,“此刻整个以太坊长什么样”可以被压缩成 32 个字节,全网只需要对这 32 个字节达成一致。
链上保存的是状态根,状态本身留在节点本地
这里有一层常被含糊处理的事实:黄皮书明确写道,世界状态并不存储在区块链上,它由每个节点各自维护,链上只保留 stateRoot 这个承诺。交易与收据会被完整发布,状态不会。
这个分工决定了很多使用上的边界。想知道某个账户此刻的余额,只有两条路:自己重放全部历史交易算出状态,或者向某个节点索要一份可以在 stateRoot 下验证的 Merkle 证明。轻客户端走的是第二条路,它不需要保存全部状态,但需要有人提供证明,而且证明只能说明“在某个 stateRoot 之下这个值是什么”,无法说明这个 stateRoot 是最新的。后者是共识层的职责。
同样由此可以理解归档节点与裁剪节点的差别。历史状态持续累积,保存全部历史状态的节点磁盘成本持续增长;只保留近期状态的节点占用更小、启动更快,代价是无法回答任意历史区块上的状态查询。所谓“链上数据”在工程上要拆成两件事:交易与收据的可用性,状态根的承诺。混在一起谈,后面评估数据可用性与状态膨胀时会失去分辨力。
状态转移函数:以太坊规定的是一段函数
黄皮书用一行公式定义交易级语义,记作:
σ_{t+1} ≡ Υ(σ_t, T)
其中 σ 是世界状态,T 是一笔交易,Υ 是状态转移函数。换成更容易读的写法就是 Y(S, T) → S'。黄皮书紧接着强调,有效状态变更只能通过交易发生,而无效的状态变更远多于有效变更,例如凭空减少某账户余额而不在其他地方等量增加。
读懂这层意思,可以换一个说法:以太坊协议真正规定的内容,是“给定当前状态和一笔交易,下一版状态是什么”;至于“链上存了什么”,那只是这段函数的输入与输出。区块层面同理,黄皮书用 Π 把同一个函数沿交易序列折叠:
Π(σ, B) ≡ Υ(Υ(σ, T₀), T₁)…
状态机这个词到这里才落地:状态是 σ,转移规则是 Υ,区块是转移的批次。
账本模型与状态机模型的验证成本不同
把两个模型放在一起比较,差异会落到验证方式上,而不只是数据组织方式。
UTXO 交易的输入是显式声明的:每个输入都写明引用哪笔交易的哪个输出。验证者只需在 UTXO 集合里查这些具体条目,不必理解任何全局结构。这让比特币的交易验证天然可以局部化,互不相干的交易能被独立处理。声明式账户模型(例如 Solana 的交易消息里预先列出账户地址,指令按下标引用)走的是同一条思路。
账户模型里,交易的读写集合没有写在交易里。合约可以读取任意地址的任意存储槽,读到了什么只有执行完才知道。验证者必须持有一份与执行时相同的状态视图,否则结果无从复现。这个差异是后面整条技术路线的起点:读写集推断、乐观并发控制、冲突检测,处理的都是“不知道一笔交易会碰什么”这件事。
以太坊也试过向声明式靠拢。EIP-2930 引入的访问列表(access list)是新交易类型 0x01 的可选字段,交易可以预先声明将访问的地址与存储槽,把它们加入 accessed_addresses 与 accessed_storage_keys 集合以享受折扣。EIP 正文同时写明,列表之外的访问仍然允许,只是更贵。读写集合因此没有被强制写进交易,上面这条结论不因它改变。
EVM 在协议里的位置:规定“怎么改状态”的那份规范
以太坊虚拟机(Ethereum Virtual Machine,EVM)是 Υ 的具体执行规范,内容包括一套指令语义、一套 gas 计量规则、一套异常与终止语义。合约代码是一串字节,只有在被交易或其它合约以消息调用(message call)触发时才进入执行。
EVM 不等于以太坊协议。共识、数据可用性、结算各自是另一层系统,扩容地图一文对此有专门拆解。EVM 只回答一件事:面对一段字节码和一份输入,如何确定性地算出状态变更与返回值。它也决定这个计算要花多少钱,而 gas 计价规则本身就是共识的一部分,可以被硬分叉修改,EIP-2929 把首次访问账户与存储槽的定价显著抬高就是例子。
还有一处容易混淆的地方:EVM 的机器模型不只有“栈”。每次执行上下文都有栈(stack)、内存(memory)、程序计数器(program counter,PC)与 gas 计数器,合约的持久变量则通过 SLOAD/SSTORE 落到账户存储里。栈、内存与执行循环是 0.4 的主题,三类存储的边界是 0.5 的主题。
全网重放:为什么结果必须逐位一致
以太坊没有中心执行者。每个全节点独立执行同一批交易,各自算出 stateRoot,再通过共识比较这个值。任何执行细节上的分歧都会直接变成共识失败,所以规范必须细到字节。
由此推出几条硬约束。浮点运算没有进入指令集,只有基于 256 位字长整数的算术。墙钟、进程 ID、真实随机数这类外部输入不能出现在共识路径上;合约能读到的时间戳与区块号来自区块头,是被共识固定的值。
计算还必须可计量且必然终止,否则无法在有限区块里安全运行任意代码。gas 是把“停机问题”改写成“花光预算就停”的办法,它的计量常数也因此属于共识参数而不是性能调优项。一笔普通转账的固有成本是 21000 gas,calldata 里每个零字节计 4 gas、非零字节计 16 gas,这些数字由黄皮书的费用表固定。改动它们与改动区块大小属于同一类协议变更,需要硬分叉或验证者信号,不能由某个客户端自行决定。
确定性还有一个容易被忽略的后果:规范不允许存在“未定义行为”。黄皮书写明 DIV 在除数为 0 时返回 0,而不抛异常。这个选择的理由,是让所有客户端在同一份非法输入上得到同一个结果,与语言设计偏好无关。客户端的单元测试与 execution-spec-tests 规范测试套件,就是用来锁住这类边界行为的。多个独立客户端(geth、revm、evmone 等)并存,本身也是确定性的校验手段:一旦某个实现对边缘情形理解不同,链就会分叉,因此分歧必须在测试阶段被发现。
确定性与可预测是两回事
需要区分两个层次的不确定性。执行层是确定的:同样的字节码、同样的输入、同样的状态,必然得到同样的结果。交易能拿到什么结果却不只由执行层决定。同一笔交易放在区块的不同位置,前序状态不同,结果就可能不同。抢跑与三明治的收益来自排序权,执行层本身仍是确定的。
这个区分在评估并行执行方案时很关键。并行改造要守住的是“执行结果与某个串行顺序等价”;至于“结果与交易提交时间无关”,这个目标从来就不成立。
EVM 不是冻结的规格
把 EVM 当成固定标准会带来兼容性误判。规格随硬分叉演进:PUSH0(EIP-3855,Shanghai)为栈机新增一条推入常量 0 的指令;EIP-2929 重定价了冷热访问;交易层引入了单笔交易 16,777,216 gas 的上限(EIP-7825,随 Fusaka 上线);EIP-7935 把客户端默认的区块 gas 上限提到 60M。
因此“兼容 EVM”这句话必须带上分叉高度与具体范围才有意义。字节码、预编译合约、JSON-RPC 与工具链各自兼容到什么程度,属于延伸阅读里那篇的范围。
这套模型在什么条件下会成为负担
以状态为中心的设计买到了通用计算,代价写在三个地方。
全节点必须长期保存一份随时可读的当前状态,其规模随账户与存储条目增长,持续抬高硬件门槛。
新节点要自行算出 stateRoot,就必须重放历史交易或依赖快照同步,验证成本与历史长度挂钩。
由于每笔交易都可能触碰全局状态的任意位置,经典实现只能逐笔串行执行,这是后面所有并行执行讨论要处理的原始约束。
反过来说,账本类比的失效也不是绝对的。审计、索引、对账这类场景仍然按事件流处理数据,因为收据里的日志本来就是为链下消费设计的。问题只在于不要把这种视角当成协议模型。
资料来源
- Ethereum Yellow Paper(Upsilon、Pi、世界状态的存放方式、账户四元组、DIV 语义、gas 费用表):https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org,Ethereum Virtual Machine (EVM):https://ethereum.org/developers/docs/evm/
- ethereum.org,Understanding the Yellow Paper's EVM Specifications:https://ethereum.org/developers/tutorials/yellow-paper-evm/
- Bitcoin Developer Guide,Transactions(UTXO 只能被花费一次、输入引用具体输出):https://developer.bitcoin.org/devguide/transactions.html
- Solana Docs,Transactions(交易消息中预先列出账户地址,指令按下标引用):https://solana.com/docs/core/transactions
- ethereum.org,Nodes and clients(全节点、归档节点与状态裁剪;归档节点才保留全部历史状态):https://ethereum.org/developers/docs/nodes-and-clients/
- EIP-2929,Gas cost increases for state access opcodes:https://eips.ethereum.org/EIPS/eip-2929
- EIP-2930,Optional access lists(类型 0x01 交易的可选字段;列表之外的访问仍然允许,只是更贵):https://eips.ethereum.org/EIPS/eip-2930
- EIP-3855,PUSH0 instruction:https://eips.ethereum.org/EIPS/eip-3855
- EIP-7825,Transaction Gas Limit Cap(16,777,216 gas):https://eips.ethereum.org/EIPS/eip-7825
- EIP-7935,Set default gas limit to 60M:https://eips.ethereum.org/EIPS/eip-7935
- Ethereum Foundation,Fusaka Mainnet Announcement:https://blog.ethereum.org/2025/11/06/fusaka-mainnet-announcement
- go-ethereum,core/vm/instructions.go(opDiv 在除数为 0 时写入 0):https://github.com/ethereum/go-ethereum/blob/master/core/vm/instructions.go
- ethereum/execution-spec-tests(规范一致性测试套件):https://github.com/ethereum/execution-spec-tests
