EVM 底层逻辑

Solidity 只是皮囊,EVM(以太坊虚拟机)才是灵魂。

你写的每一行 if/else、每一次 mapping 读写,最终都会被编译成 EVM 能听懂的操作码(Opcodes)。不理解 EVM,写合约就像在开自动挡的车——能跑,但遇到陡坡就不知道该怎么换挡了。

这篇不背黄皮书公式,只看 EVM 真正干活的方式。


一、EVM 的“内存模型”

EVM 是一台 256 位的栈式虚拟机。它没有寄存器,所有计算都在栈上完成。
alt text

1. 栈(Stack)

  • 深度:最多 1024 层。
  • 宽度:每层固定 256 bit(32 字节)。
  • 规则:后进先出(LIFO)。PUSH1 0x05 把 5 压栈,ADD 弹出栈顶两个数相加,结果再压回去。
  • 成本:极便宜(3 gas)。但深度超限直接 Stack Overflow。

2. 内存(Memory)

  • 特性:易失性。交易结束就清空。
  • 寻址:按字节寻址,但读写必须按 32 字节对齐(MLOAD / MSTORE)。
  • 成本:动态计价。用得越多,扩展内存的 gas 呈二次方增长。

3. 存储(Storage)

  • 特性:永久持久化。存在 Merkle Patricia Trie 里。
  • 成本:贵得离谱。SLOAD 800 gas,SSTORE(写入新值)20,000 gas。
  • 规则:256 bit 键 → 256 bit 值。每个键叫一个“槽(Slot)”。

4. Calldata

  • 特性:只读。交易自带的 payload 数据。
  • 成本:最便宜。不用拷贝,直接读。

[!tip] 临时存储(Transient Storage)
EIP-1153 引入的 TLOAD / TSTORE。类似 Storage 的键值对,但交易结束就清空。Gas 只要 100。适合在复杂调用链中传递临时状态,不用污染永久 Storage。


二、执行流程:一笔交易经历了什么

  1. 验证签名 & Nonce:节点确认交易合法且顺序正确。
  2. 预扣 GasgasLimit * gasPrice 从发送者账户扣除。
  3. 初始化环境:清空栈和内存,设置 PC(程序计数器)指向合约代码起点。
  4. 逐条执行 Opcodes:EVM 像老式打字机一样,一个指令一个指令往下走。
  5. 状态更新:如果没 REVERT 或 OOG,把 Storage 的改动写入全局状态树。
  6. Gas 结算:剩余 gas 退还发送者,消耗的支付给验证者。

三、函数调用:EVM 是怎么跳转的

Solidity 的函数调用,底层全是 JUMPCALL 系列指令。

指令 上下文归属 能不能改状态 Gas 传递 典型场景
CALL 目标合约 普通外部调用
STATICCALL 目标合约 view / pure 函数
DELEGATECALL 调用方 代理合约、库函数
CALLCODE 调用方 已废弃,别用

Solidity 代码对应的 EVM 动作

1
2
3
function double(uint x) public pure returns (uint) {
return x * 2;
}

底层大概长这样:

1
2
3
4
5
6
7
8
CALLDATALOAD  ; 从 calldata 捞参数 x
DUP1 ; 复制一份
ADD ; x + x (相当于乘2)
PUSH1 0x00 ; 内存偏移 0
MSTORE ; 结果存进内存
PUSH1 0x20 ; 返回长度 32 字节
PUSH1 0x00 ; 返回偏移 0
RETURN ; 把内存数据吐出去

四、操作码速查(按用途分类)

不用全背,但得知道去哪找。

算术与逻辑(3~5 gas)

ADD, SUB, MUL, DIV, MOD, EXP, LT, GT, EQ, AND, OR, XOR, SHL, SHR

栈与内存控制(2~3 gas)

PUSH1~32(压常量), POP(丢弃), DUP1~16(复制), SWAP1~16(交换)
MLOAD, MSTORE, MSIZE

存储操作(贵)

SLOAD (800), SSTORE (20000/5000/退款)
TLOAD (100), TSTORE (100)

控制流

JUMP (8), JUMPI (10), JUMPDEST (1)

EVM 没有 if/for 概念,只有“如果栈顶非零,跳到标记位”。

系统与环境

CALLER (2), ADDRESS (2), BALANCE (100), TIMESTAMP (2), NUMBER (2), CHAINID (2)
KECCAK256 (30 + 动态), CREATE / CREATE2 (32000)
RETURN (0), REVERT (0), SELFDESTRUCT (5000)


五、字节码结构:合约部署后剩下什么

你看到的 Solidity 源码,编译后会变成两坨字节码:

1
[ 部署代码 (Creation Code) ] + [ 运行时代码 (Runtime Code) ] + [ 元数据哈希 ]
  1. 部署代码:只跑一次。负责把运行时代码拷贝到内存,然后 RETURN。跑完就扔。
  2. 运行时代码:永久存在链上。以后每次调用合约,EVM 执行的都是这段。
  3. 元数据:编译器版本、源码哈希、ABI 等。存在字节码末尾,不影响执行。

[!example] 为什么合约体积很重要?
EVM 规定运行时代码最大 24,576 字节。超过直接部署失败。这也是为什么需要 libraryproxyvia-ir 优化。


六、Gas 机制:为什么这么贵

Gas 不是以太坊为了收钱发明的,它是防止死循环和 DoS 攻击的保险丝

成本分级

  • 计算(ADD/MUL):3~5 gas。几乎不要钱。
  • 内存扩展:线性+二次方。用一点没事,用爆了直接破产。
  • 存储(SSTORE):20,000 gas(清零旧值退 4,800,设置新值收 20,000)。贵是因为所有节点都要永久存这份数据。

优化直觉

  • 能放 memory 就别碰 storage
  • 能打包的变量塞一起(32 字节槽位别浪费)。
  • 循环里别做 SSTORE,攒到最后一次性写。
  • unchecked { ... } 关掉溢出检查,能省几十 gas(确认不会溢出再用)。

七、异常处理:EVM 怎么报错

错误类型 触发条件 Gas 处理
REVERT require / revert 退还剩余 Gas,返回错误数据
INVALID (0xFE) 编译器插入的断言失败 消耗所有 Gas
Out of Gas Gas 耗尽 消耗所有 Gas,状态回滚
Stack Underflow 弹出空栈 立即终止

[!tip] require vs assert
require 失败走 REVERT(退 Gas),用于输入校验。
assert 失败走 INVALID(烧光 Gas),用于捕获内部逻辑 bug。生产环境基本只用 require


八、主流 EVM 实现

以太坊客户端不止一个,它们用不同语言实现了同一套 EVM 规范:

  • Geth (Go):最主流,参考实现。
  • Nethermind (C#):高性能,企业级。
  • Reth (Rust):模块化,速度极快。
  • revm (Rust):Foundry 的底层,轻量且快。

不管底层怎么写,只要输入一样,输出必须绝对一致。这就是确定性,是智能合约能信任的根基。


写在最后

理解 EVM 不是为了让你去写汇编,而是为了建立成本直觉

当你再写下 uint256 public data; 时,脑子里应该能浮现出:

“这行代码会在链上占一个 32 字节的槽,每次读花 800 gas,写花 2 万 gas。如果改成 immutable,就能省掉这笔钱。”

有了这种直觉,你的合约自然会比别人跑得快、省得多。


📚 参考资料

  1. 以太坊黄皮书 - EVM 的形式化定义
  2. EVM 操作码参考 - 最全的操作码交互查询站
  3. 以太坊官方文档 - EVM
  4. Solidity 文档
  5. OpenZeppelin 合约库