上链ON-CHAIN BLOCK 11 / 26
卷三 · 合约工程BLOCK 11 · 旗舰

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只读输入数据单次调用内
Android 类比

如果你 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..PUSH320x60–0x7f把 1–32 字节的立即数压栈3
ADD / MUL / SUB0x01/02/03弹两个、算、压回3–5
DUP1..16 / SWAP1..160x80–0x9f复制 / 交换栈项3
MLOAD / MSTORE0x51/52读写内存3
SLOAD0x54读存储(冷读)2100
SSTORE0x55写存储~20000
JUMP / JUMPI / JUMPDEST0x56/57/5b跳转 / 条件跳转 / 合法落点8–10
RETURN / REVERT0xf3/fd返回数据 / 回滚0

看那个 SSTORE 的 20000——现在你彻底明白第 10 章为什么反复喊"storage 很贵"了:它比一次加法贵约 6700 倍。gas 就是逐条 opcode 累加出来的,读懂字节码等于读懂账单。

一段最小字节码

计算 3 + 4 并压栈,字节码是 6003600401,拆开就是:

EVM 字节码 · 反汇编
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 等一个教学子集——足够你建立肌肉记忆。

mini-EVM 单步执行器DEMO · 旗舰
字节码 · PC 高亮
栈 Stack(顶在上)
状态
选个预设或输入字节码,点"载入",再点"单步"。
这是一台真实实现(BigInt 精确到 256 位)的 EVM 教学子集,跑在你本地(assets/web3-lib.js)。它涵盖 PUSH/DUP/SWAP、算术比较、内存、存储、跳转,足以理解真实 EVM 的执行模型。

从 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 层面,看合约之间怎么互相调用、怎么发事件。

本章小结

◆ 返回目录