Opcode 大多从 256-bit Stack 顶部取操作数,再把结果压回去;变量名在运行时早已消失。
Ethereum · 08.02 / EVM Runtime
EVM 如何一步步执行?Stack · Memory · Storage · Opcode
一笔交易进入合约后,并不是“整段 Solidity 一次跑完”。EVM 会按程序计数器逐条取出 Opcode,用 Stack 传递操作数、用 Memory 整理临时数据、用 Storage 读写持久状态, 并为每一步计算 Gas。
Memory 属于当前调用帧,Storage 属于合约并跨交易保留;把两者混为一谈,会同时误解 Gas 与回滚。
每条指令都先通过 Gas 和合法性检查;只有整条成功路径提交,暂存的持久状态才成为新的世界状态。
01 · Orientation
先把“运行一个合约”拆成一只调用帧
你看到的是一个函数;EVM 看到的是代码字节、输入字节、机器状态与剩余 Gas。
EVM 可以被理解为一套确定的状态转换规则。给定旧世界状态 S、一组按顺序执行的有效交易
T,所有遵循同一协议版本的执行客户端都应得到同一个新状态
S′。[2]
EVM transition : Y(S, T) = S′
“确定性”不是说合约不读取状态,而是说相同前状态、输入、区块上下文和协议规则必须导出相同结果。当交易调用一个合约,执行客户端会建立顶层执行环境;合约再调用另一个合约时,又会建立新的 调用帧(call frame)。每个帧至少要回答:执行谁的代码?谁是当前地址? 输入数据是什么?还能用多少 Gas?程序执行到哪一个字节?Stack 和 Memory 现在是什么?
因此,Stack、Memory、Storage 并不是三种容量不同的“硬盘”。它们首先是三种 作用域与寿命不同的数据空间。Opcode 则是唯一能推动机器状态前进的动作。
Stack 负责“现在算什么”,Memory 负责“这一帧暂存什么”,Storage 负责“这个合约长期记住什么”。
02 · Four parts
四个部件不是并列仓库,而是一条执行链
Opcode 读取或改变机器状态;Stack 是主操作数通道,Memory 与 Storage 通过专门指令接入。
Stack
256-bit × 1024 max后进先出。大多数指令从栈顶弹出参数,再把结果压回;栈属于当前调用帧,结束即消失。
Memory
byte-addressed · volatile线性字节数组,按字节偏移访问、按 32-byte word 扩展计价。每个调用帧各有一份,初始读取为零。
Storage
slot → 32-byte word以合约地址为边界的稀疏键值空间。写入昂贵,因为成功后所有维护状态的节点都要承认这个变化。
Opcode
one-byte instruction一个字节通常决定一种操作;PUSH1 到 PUSH32 还会读取紧随其后的立即数。指令集会随硬分叉演进。
正常情况下,PC 前进到下一条指令;JUMP/JUMPI 改变 PC;
STOP、RETURN、REVERT 等让当前帧停止。
如果 Gas 不够、栈下溢、跳转目标非法或在静态上下文写状态,则当前帧异常停止。
03 · Step through
让一段 Bytecode 在你面前走完
它计算 2 + 3,把结果放进 Memory,再读回并写到 Storage slot 0。
下面这段 12 字节代码包含 10 条指令。读法不是“执行一行高级语言”,而是不断重复: 取出一个 Opcode → 检查 Gas 和栈 → 改变机器状态 → 移动 PC。
Bytecode = 60 02 · 60 03 · 01 · 5f · 52 · 5f · 51 · 5f · 55 · 00
Mnemonic = PUSH1 02 · PUSH1 03 · ADD · PUSH0 · MSTORE · PUSH0 · MLOAD · PUSH0 · SSTORE · STOP
Interactive lab
单步运行:从两个常量到持久状态
用“下一步”观察 PC、Stack、Memory 和 Storage。栈顶始终画在最上方。
尚未执行。PC 指向第一条 PUSH1,三个数据区域都处于初始状态。
Gas 只计算这段 EVM 代码自身。示例按 2026-07-25 主网、冷的全新 slot 估算; 不含交易固有 Gas,未来升级可调整状态访问价格。
Stack · top ↑
Memory
Storage
关键的三次搬运
ADD从 Stack 弹出 3 和 2,以模2²⁵⁶的算术得到 5,再压回栈顶。MSTORE把栈上的 5 写入本帧 Memory 的0x00..0x1f;MLOAD又把它读回 Stack。SSTORE把 5 写入当前存储上下文的 slot 0,但它先是交易执行中的暂存状态;只有外层最终成功才提交。
PUSH0 是上海升级加入的指令。若目标链或旧协议版本不支持它,应使用
PUSH1 00。Opcode 是否有效、Gas 常数是多少,都由执行时生效的硬分叉规则决定。
[14]
04 · Stack
Stack 是操作数通道,不是变量仓库
每项固定 256 bit,最多 1024 项;大多数 Opcode 的输入与输出都从这里经过。
EVM 是一台栈式机器。Stack 采用 LIFO:最后压入的值最先弹出。它不认识
balance、owner 这样的源码变量名,只看一个个 256-bit word。
[3]
为什么是 256 bit?
一个栈项可表示从 0 到 2²⁵⁶-1 的无符号整数,也可以按补码解释为有符号整数,
还可容纳 32-byte 哈希或经过左/右填充的地址。固定字长让 Keccak-256、椭圆曲线相关数值和
状态 slot 的处理更直接,但它不意味着每个 Solidity 变量都在 Storage 独占 32 bytes。
| Opcode | 执行前 | 执行后 | 含义 |
|---|---|---|---|
PUSH1 0x2a | [] | [0x2a] | 把 1-byte 立即数补成 256-bit 后压栈 |
DUP1 | [a, …] | [a, a, …] | 复制栈顶 |
SWAP1 | [a, b, …] | [b, a, …] | 交换顶部两项 |
ADD | [a, b, …] | [a+b mod 2²⁵⁶, …] | 弹出两项,压回一项 |
POP | [a, …] | […] | 丢弃栈顶 |
Stack 的硬边界会直接停止当前帧
- 下溢:执行
ADD却不足两个操作数,属于异常执行。 - 上溢:试图超过 1024 项,同样异常停止。
- 可直接访问深度有限:经典指令提供
DUP1..DUP16与SWAP1..SWAP16;编译器会重排或把值临时放进 Memory。 - 类型不随值同行:EVM word 本身不携带“这是 address 还是 uint256”的标签;正确解释来自编译后的操作序列。
先写出“每条指令弹出几项、压回几项”,再解释语义。把栈顶方向写清楚,能避免绝大多数手算错误。
05 · Memory
Memory 是当前调用帧的可扩展草稿纸
按字节偏移寻址,按 32-byte word 扩展计价;调用返回后整张草稿纸被丢弃。
Memory 是线性字节数组。MLOAD/MSTORE 每次处理 32 bytes,
MSTORE8 只写一个字节;复制 calldata、code、returndata 或另一段 Memory 时,
也会把目标落到这里。未写入区域按 0 读取。[3]
Scope
每帧独立
合约 A 调用 B 时,B 得到新的 Memory。A 通过调用参数和返回区域复制数据,而不是把同一块 Memory 借给 B。
Address
偏移按字节
MSTORE(1, value) 可以从偏移 1 开始写 32 bytes;不要求地址按 32 对齐。
Lifetime
帧结束即丢弃
无论成功或回滚,Memory 都不会成为世界状态;但使用它仍消耗计算资源与扩展 Gas。
为什么“跳到很远的地址写一次”会更贵?
EVM 按本帧触及过的最高 Memory 范围收费。总成本由线性项和二次项组成;某条指令需要扩展时,
支付的是新总成本减旧总成本。这里的 a 是已激活的 32-byte word 数。
Cmemory(a) = 3 × a + floor(a² ÷ 512)
本次扩展 Gas = Cmemory(newWords) - Cmemory(oldWords)
这只是 Memory 扩展部分;MSTORE、复制或哈希等 Opcode 自身还有基础/按字数成本。
Interactive lab
把写入位置推远,观察扩展成本
选择写入宽度,再拖动 offset。页面计算从空 Memory 扩展到足以覆盖这次写入的总成本。
从 offset 0 写 32 bytes,需要激活 1 个 word。
真实执行只支付相对于当前 Memory 大小的增量;本实验以 oldWords = 0 建立直觉。
Memory 不等于 Calldata,也不等于 Returndata
| 区域 | 可写? | 主要指令 | 寿命与用途 |
|---|---|---|---|
| Calldata | 只读 | CALLDATALOAD、CALLDATACOPY | 本次调用输入;复制到 Memory 后才能改 |
| Memory | 可写 | MLOAD、MSTORE、MCOPY | 当前帧临时编码、计算与返回数据 |
| Returndata | 只读缓冲 | RETURNDATASIZE、RETURNDATACOPY | 保存最近一次外部调用返回的 bytes,下一次调用会替换 |
| Code | 执行中不可改 | CODESIZE、CODECOPY | 当前执行字节码,与 Memory 分离 |
06 · Storage
Storage 是合约的长期记忆,也是最昂贵的边界
概念上是 2²⁵⁶ 个 slot 的稀疏映射;每个 key 与 value 都是 256 bit,默认值为零。
SLOAD 读取当前存储上下文的 slot,SSTORE 修改它。Storage
不是把完整数组预先分配出来,而是只为非零或被访问的键维护状态。成功提交后,变化反映到合约账户的
storage root,成为下一笔交易的前状态。[3]
一次写入为什么不能只标一个固定 Gas?
SSTORE 的价格取决于至少四件事:交易开始时的原值、当前帧执行到此处时的现值、
准备写入的新值,以及这个 (address, slot) 本交易中是否已经访问过。
首次访问为 cold,之后为 warm;回滚还会恢复访问集合与状态日志到进入该帧前的检查点。
[5]
Interactive lab
同一个 slot,为什么价格完全不同?
切换典型场景,区分首次访问、再次访问、持久写入和交易级瞬态写入。
数值按 2026-07-25、Glamsterdam 激活前的主网规则作教学示意。 硬分叉可重定价;清零退款计数还受整笔交易 20% 上限约束。
本交易第一次读取该 slot:cold SLOAD 收取 2,100 Gas,并把它加入已访问集合。
“写进 Storage”不等于已经不可回滚
执行客户端会把状态修改放在可回滚的日志/检查点体系中。后续同一交易里的代码能够读到新值,
但如果当前帧或外层帧最终 REVERT,对应范围内的 Storage 写入、余额变化和日志都会撤销。
顶层成功后,才把最终差异纳入新的世界状态。
Transient Storage 是现代 EVM 的第四种临时状态
Dencun/Cancun 引入 TLOAD 与 TSTORE。它像 Storage 一样按
256-bit key/value 寻址、以合约为所有者、能跨同一交易中的多个调用帧共享并遵守回滚;
但它不会写入持久状态 trie,整笔交易结束时全部清空。当前二者各为 100 Gas。
[8]
Memory
帧级
每次外部调用新建;线性字节数组;适合 ABI 编码、哈希输入和返回数据。
Transient
交易级
同一拥有者跨帧共享;交易末清空;适合重入锁等只需维持一笔交易的状态。
Storage
状态级
跨交易保留;纳入全网验证状态;适合余额、权限、配置与业务状态。
Transient Storage 不会自动让代码安全。重入锁必须在所有退出路径正确解锁;
DELEGATECALL 下它与持久 Storage 一样使用调用者的存储上下文。
07 · Opcodes
Opcode 是 EVM 唯一真正执行的语言
Solidity、Vyper 或其他语言最终都要变成字节码;源码结构不会原样进入 EVM。
一个 Opcode 通常由单个字节标识,例如 0x01 = ADD、0x51 = MLOAD、
0x55 = SSTORE。PUSH1..PUSH32 会把后续 1–32 个代码字节视作立即数,
因此 PC 不是每次都只加 1。完整表还会随硬分叉增加新指令或调整价格。
[4]
算术与位运算
只对 Stack word 运算,通常固定且较低成本。
环境与区块
读取当前调用、交易或区块提供的上下文。
数据搬运
在 Stack 与各类字节区域之间复制或装载。
状态访问
读取或修改账户、持久 Storage 与瞬态 Storage。
控制流
改变 PC 或结束当前调用帧。
调用与创建
创建新帧、转移调用上下文或部署代码。
一条指令的 Gas 可能由多部分组成
- 固定基础价:例如简单算术、栈操作。
- 按输入规模:复制、哈希、指数运算会随长度或指数大小增加。
- Memory 扩展:触及新的最高范围时额外收费。
- 状态冷热:账户或 slot 本交易首次访问更贵,再次访问更便宜。
- 状态转变:
SSTORE还区分 0→非 0、修改、清零、恢复原值等。 - 子调用预算:
CALL类指令还要处理转发 Gas、value 与新账户等因素。
08 · Call frames
合约一调用,机器就长出一层新帧
新的 Stack、Memory、PC 与 Calldata,并不意味着所有上下文都换成被调用者。
外部消息调用会形成嵌套帧。父帧暂停,子帧执行;子帧结束后,父帧获得成功标志与返回数据, 再继续自己的 PC。调用深度有协议上限,Gas 转发也受到规则约束。
| 方式 | 执行代码 | 当前地址 / Storage | msg.sender 与 value | 写状态 |
|---|---|---|---|---|
CALL |
目标合约代码 | 目标合约 | sender = 调用者;可传新 value | 允许 |
DELEGATECALL |
目标合约代码 | 仍是调用者地址与 Storage | 保留当前帧 sender 与 value | 允许,写的是调用者状态 |
STATICCALL |
目标合约代码 | 目标合约 | sender = 调用者;value 为 0 | 禁止,写操作异常 |
子调用失败,为什么父调用不一定失败?
低级 CALL 会把成功标志压回父帧 Stack。子帧 REVERT 或异常停止时,
子帧范围内的状态修改回滚,但父帧可以检查返回的 0、读取 returndata,再选择继续、补救或自己
REVERT。高级语言通常会自动把某些失败冒泡,但那是编译出的控制逻辑,不是
EVM 强制所有父帧一起失败。
DELEGATECALL 的危险与能力来自同一件事:借用别人的代码,在自己的 Storage
上执行。实现合约与代理的 slot 布局不兼容,就可能把一个变量当成另一个变量覆盖。
09 · Gas & halting
每一步都先过 Gas 闸门,再谈结果
Gas 把“理论上能无限计算”的机器变成一次执行中必然有限的机器。
在真正施加一条 Opcode 的效果之前,EVM 要确认指令有效、Stack 高度足够且不会溢出、 Memory 与动态成本可支付、当前上下文允许这项动作。如果剩余 Gas 不够,效果不会执行, 当前帧直接进入 out-of-gas 异常。[3]
STOP 或 RETURN 正常结束;保留当前帧成功路径的状态改动,未用 Gas 返回调用者。
REVERT 撤销当前帧及其子帧的状态效果,可返回错误数据,并保留尚未消耗的 Gas。
Out of gas、非法 Opcode、非法跳转、栈错误等撤销当前帧效果,并耗尽分配给该帧的剩余 Gas。
“回滚状态”与“退回费用”是两回事
节点已经为失败路径执行了指令,所以已消耗的 Gas 仍收费。REVERT 能保留
当前帧尚未使用的 Gas;顶层 out-of-gas 通常会耗尽交易提供的执行预算。无论哪一种,
被回滚的余额、Storage 和日志不进入最终状态,但发送者 nonce 与交易费的顶层处理不会因此消失。
合约不能直接读取网页、随机访问系统时间或调用任意外部 API。它只能使用交易输入、可见链上状态、 当前区块提供的受协议约束字段和其他合约返回的数据;否则不同节点无法独立复算同一结果。
六个最常见的认知错误
错。首先是寿命与作用域不同,便宜只是这种差异在 Gas 上的结果。
错。后续代码可见新值,但外层失败仍能把它回滚。
错。复制、Memory、Storage、CALL 等都有动态部分。
错。子帧有独立 Memory,输入输出通过指定区域复制。
错。它执行目标协议版本认可的字节码与 Opcode。
业务状态回滚,但交易费与发送者 nonce 仍按顶层规则处理。
10 · Synthesis
把整台机器压缩成五个动作
以后看到任何合约执行,都先沿这条因果链定位,而不是从源码名词开始猜。
- Stack:当前帧的 256-bit 操作数栈,最多 1024 项。
- Memory:当前帧的线性临时字节数组,按最高触及范围扩展计价。
- Storage:当前存储上下文的持久 256-bit 键值空间,成功后进入世界状态。
- Transient Storage:交易级、合约私有、回滚感知,交易结束清空。
- Opcode:改变机器状态的原子指令;有效性和 Gas 规则由硬分叉版本决定。
- Call frame:每次调用都有独立 PC、Stack、Memory、Calldata 与 Gas;调用类型决定 Storage 上下文。
六题自测
01. 关于 EVM Stack,哪一项正确?
02. 一次 CALL 创建子帧后,子合约怎样使用 Memory?
03. 为什么两次 SLOAD 同一个 slot 的 Gas 可能不同?
04. 代理 P 用 DELEGATECALL 执行实现 I 的代码,SSTORE 写到哪里?
05. REVERT 与 out-of-gas 的关键差异是什么?
06. 哪一句最准确地描述 Transient Storage?
术语与一手资料
-
[1]
LOREWORD ·《从0到深入理解以太坊》课程目录
本课对应第八章第二项“运行机制:Stack、Memory、Storage、Opcode”。
-
[2]
ethereum.org · Ethereum Virtual Machine
状态转换、1024 深度 256-bit Stack、Memory、Storage、Transient Storage 与 Opcode 总览。
-
[3]
Ethereum Yellow Paper · Sections 9 and Appendix H
EVM 机器状态、执行环境、异常停止与 Memory Gas 的形式化定义;具体协议版本还应结合执行规范。
-
[4]
ethereum.org · Opcodes for the EVM
Opcode 编码、Stack 输入输出、Memory/Storage 效果与 Gas 参考表。
-
[5]
EIP-2929 · Gas cost increases for state access opcodes
账户与 storage key 的 cold/warm 访问集合,以及 SLOAD、SSTORE 和 CALL 类重定价。
-
[6]
EIP-2200 · Structured Definitions for Net Gas Metering
SSTORE 依据 original、current 与 new value 计价和调整退款计数的基础规则。
-
[7]
EIP-3529 · Reduction in refunds
Storage 清理退款与交易 Gas refund 上限的现行基础。
-
[8]
EIP-1153 · Transient storage opcodes
TLOAD/TSTORE 的交易级寿命、调用与 DELEGATECALL 所有权、回滚和 Gas 语义。
-
[9]
EIP-5656 · MCOPY - Memory copying instruction
现代 EVM 中 Memory 到 Memory 的高效复制指令及重叠复制语义。
-
[10]
EIP-140 · REVERT instruction
受控回滚、返回错误数据与保留未消耗 Gas 的语义。
-
[11]
Ethereum Execution Layer Specifications
由 Ethereum Foundation STEEL 团队维护的可执行共识级参考规范与硬分叉历史。
-
[12]
ethereum.org · Glamsterdam
截至 2026-07-25 仍计划于 2026 下半年进行的升级,以及状态访问重定价的版本边界。
-
[13]
EIP-7939 · Count leading zeros (CLZ) opcode
Fusaka 加入的 CLZ 指令:对 256-bit word 计算前导零位数;输入为零时返回 256。
-
[14]
EIP-3855 · PUSH0 instruction
上海升级加入的零常量压栈指令,以及其 2 Gas 价格与字节码编码。
Lesson complete · 08.02
EVM 没有魔法:只有一条条可复算的状态变化。
下一课将继续追踪 Solidity 源码如何经编译变成 Bytecode,以及高级语言结构怎样落到今天学过的 Opcode 与数据区域。