;)
EVM 底层逻辑
Solidity 只是皮囊,EVM(以太坊虚拟机)才是灵魂。
你写的每一行 if/else、每一次 mapping 读写,最终都会被编译成 EVM 能听懂的操作码(Opcodes)。不理解 EVM,写合约就像在开自动挡的车——能跑,但遇到陡坡就不知道该怎么换挡了。
这篇不背黄皮书公式,只看 EVM 真正干活的方式。
一、EVM 的“内存模型”
EVM 是一台 256 位的栈式虚拟机。它没有寄存器,所有计算都在栈上完成。
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 里。
- 成本:贵得离谱。
SLOAD800 gas,20,000 gas。SSTORE(写入新值) - 规则:256 bit 键 → 256 bit 值。每个键叫一个“槽(Slot)”。
4. Calldata
- 特性:只读。交易自带的 payload 数据。
- 成本:最便宜。不用拷贝,直接读。
[!tip] 临时存储(Transient Storage)
EIP-1153 引入的TLOAD/TSTORE。类似 Storage 的键值对,但交易结束就清空。Gas 只要 100。适合在复杂调用链中传递临时状态,不用污染永久 Storage。
二、执行流程:一笔交易经历了什么
- 验证签名 & Nonce:节点确认交易合法且顺序正确。
- 预扣 Gas:
gasLimit * gasPrice从发送者账户扣除。 - 初始化环境:清空栈和内存,设置 PC(程序计数器)指向合约代码起点。
- 逐条执行 Opcodes:EVM 像老式打字机一样,一个指令一个指令往下走。
- 状态更新:如果没 REVERT 或 OOG,把 Storage 的改动写入全局状态树。
- Gas 结算:剩余 gas 退还发送者,消耗的支付给验证者。
三、函数调用:EVM 是怎么跳转的
Solidity 的函数调用,底层全是 JUMP 和 CALL 系列指令。
| 指令 | 上下文归属 | 能不能改状态 | Gas 传递 | 典型场景 |
|---|---|---|---|---|
CALL |
目标合约 | ✅ | ✅ | 普通外部调用 |
STATICCALL |
目标合约 | ❌ | ✅ | view / pure 函数 |
DELEGATECALL |
调用方 | ✅ | ✅ | 代理合约、库函数 |
CALLCODE |
调用方 | ✅ | ✅ | 已废弃,别用 |
Solidity 代码对应的 EVM 动作
1 | function double(uint x) public pure returns (uint) { |
底层大概长这样:
1 | CALLDATALOAD ; 从 calldata 捞参数 x |
四、操作码速查(按用途分类)
不用全背,但得知道去哪找。
算术与逻辑(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) ] + [ 元数据哈希 ] |
- 部署代码:只跑一次。负责把运行时代码拷贝到内存,然后
RETURN。跑完就扔。 - 运行时代码:永久存在链上。以后每次调用合约,EVM 执行的都是这段。
- 元数据:编译器版本、源码哈希、ABI 等。存在字节码末尾,不影响执行。
[!example] 为什么合约体积很重要?
EVM 规定运行时代码最大 24,576 字节。超过直接部署失败。这也是为什么需要library、proxy和via-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]
requirevsassertrequire失败走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,就能省掉这笔钱。”
有了这种直觉,你的合约自然会比别人跑得快、省得多。
📚 参考资料
- 以太坊黄皮书 - EVM 的形式化定义
- EVM 操作码参考 - 最全的操作码交互查询站
- 以太坊官方文档 - EVM
- Solidity 文档
- OpenZeppelin 合约库







