EVM 虚拟机内幕
你写过 Kotlin,也许还反编译过 APK 读过 smali 字节码。今天我们对以太坊做同样的事:打开 EVM 字节码,看它一条一条 opcode 怎么在一个 256 位栈机上执行,gas 又是怎么一格一格扣掉的。本章配一台能在你浏览器里真的单步跑起来的 mini-EVM,你可以亲手按"下一步",盯着栈、存储、gas 变化。
EVM 是一台栈机
和你熟悉的寄存器机(ARM)或 Dalvik(基于寄存器)不同,EVM 是基于栈的——它更像 JVM 字节码。没有命名寄存器,几乎所有运算都是"从栈顶弹出操作数、算完把结果压回栈顶"。它有几块工作区:
| 区域 | 特性 | 寿命 |
|---|---|---|
| 栈 Stack | 最多 1024 项,每项 256 位,LIFO | 单次调用内 |
| 内存 Memory | 可寻址字节数组,按 32 字节字读写 | 单次调用内(第 10 章) |
| 存储 Storage | 永久 KV,每格 32 字节,极贵 | 永久(第 10 章) |
| calldata | 只读输入数据 | 单次调用内 |
如果你 hexdump 过 dex、读过 smali,这套完全似曾相识:const/16 v0, 0x3 之于 PUSH1 0x03,add-int 之于 ADD。差别是 Dalvik 用寄存器 v0/v1,EVM 用一个隐式的栈。看 EVM 字节码,就是在读一门只有栈、没有寄存器名的汇编。
opcode:EVM 的指令集
每个 opcode 是 1 字节。字节码就是一长串 opcode(部分带内联数据,比如 PUSH1 后面跟 1 字节)。挑最常见的一批:
| opcode | 字节 | 作用 | gas |
|---|---|---|---|
PUSH1..PUSH32 | 0x60–0x7f | 把 1–32 字节的立即数压栈 | 3 |
ADD / MUL / SUB | 0x01/02/03 | 弹两个、算、压回 | 3–5 |
DUP1..16 / SWAP1..16 | 0x80–0x9f | 复制 / 交换栈项 | 3 |
MLOAD / MSTORE | 0x51/52 | 读写内存 | 3 |
SLOAD | 0x54 | 读存储(冷读) | 2100 |
SSTORE | 0x55 | 写存储 | ~20000 |
JUMP / JUMPI / JUMPDEST | 0x56/57/5b | 跳转 / 条件跳转 / 合法落点 | 8–10 |
RETURN / REVERT | 0xf3/fd | 返回数据 / 回滚 | 0 |
看那个 SSTORE 的 20000——现在你彻底明白第 10 章为什么反复喊"storage 很贵"了:它比一次加法贵约 6700 倍。gas 就是逐条 opcode 累加出来的,读懂字节码等于读懂账单。
一段最小字节码
计算 3 + 4 并压栈,字节码是 6003600401,拆开就是:
60 03 PUSH1 0x03 // 栈: [3] 60 04 PUSH1 0x04 // 栈: [3, 4] 01 ADD // 弹 4、弹 3,压 7 → 栈: [7]
下面这台 mini-EVM 会把这段字节码真的跑起来。点"单步",看 PC 移动、栈变化、gas 累加。它内置了 PUSH/DUP/SWAP、算术、比较、SLOAD/SSTORE、JUMP 等一个教学子集——足够你建立肌肉记忆。
从 Solidity 到字节码的一生
Solidity 源码 │ solc 编译 ▼ EVM 字节码(部署字节码 + 运行时字节码) │ 部署交易写进合约账户的 code ▼ 每次被调用: EVM 取 code,按 calldata 前 4 字节(选择器)跳转到对应函数 │ 逐条 opcode 执行,累加 gas ▼ 改 storage / emit log / return
合约被调用时,开头有一段"选择器分发"逻辑:把 calldata 前 4 字节和每个函数的选择器(第 8 章)比对,匹配上就 JUMP 到那段代码。这就是为什么你在字节码开头总能看到一串 PUSH4 + EQ + JUMPI——那是路由表。
JUMP 只能跳到标了 JUMPDEST 的位置,否则交易 revert——这是防止跳进数据当代码执行的安全设计(试试在 Demo 里让 JUMP 跳到非 JUMPDEST,它会报错)。此外要小心 gas 耗尽(out of gas):如果一个循环写得能被外部输入撑爆(比如遍历一个用户能无限加长的数组),攻击者就能让你的关键函数永远 gas 不够而无法执行——这叫"gas 耗尽型 DoS",第 16 章会再遇到。
EVM = 256 位栈机 + 逐条 opcode 执行 + 每条 opcode 明码标价的 gas。读懂字节码,你就同时读懂了"这段代码在做什么"和"它为什么贵"。省 gas 的本质,就是少用贵指令(尤其 SSTORE/SLOAD),多在便宜的栈和内存里干活。有了这层理解,后面写 ERC-20、做安全审计、读别人的合约,你都能一眼看到骨头。下一章回到 Solidity 层面,看合约之间怎么互相调用、怎么发事件。
本章小结
- EVM 是 256 位栈机(≈ JVM 字节码的栈模型),有栈/内存/存储/calldata 四块工作区。
- opcode 是 1 字节指令,gas 逐条累加;SSTORE(~20000)比 ADD(3)贵数千倍。
- Solidity → 字节码 → 部署进 code;调用时靠选择器分发 JUMP 到对应函数。
- JUMP 必须落在 JUMPDEST;写出可被撑爆的循环会招致 gas 耗尽型 DoS。