Ethereum · 08.02 / EVM Runtime

EVM 如何一步步执行?Stack · Memory · Storage · Opcode

一笔交易进入合约后,并不是“整段 Solidity 一次跑完”。EVM 会按程序计数器逐条取出 Opcode,用 Stack 传递操作数、用 Memory 整理临时数据、用 Storage 读写持久状态, 并为每一步计算 Gas。

从零起步 约 88 分钟 3 个交互实验 6 题自测
01 · EVM 是栈式机器

Opcode 大多从 256-bit Stack 顶部取操作数,再把结果压回去;变量名在运行时早已消失。

02 · 数据有不同寿命

Memory 属于当前调用帧,Storage 属于合约并跨交易保留;把两者混为一谈,会同时误解 Gas 与回滚。

03 · 执行是一条状态转换

每条指令都先通过 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

一个字节通常决定一种操作;PUSH1PUSH32 还会读取紧随其后的立即数。指令集会随硬分叉演进。

正常情况下,PC 前进到下一条指令;JUMP/JUMPI 改变 PC; STOPRETURNREVERT 等让当前帧停止。 如果 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,未来升级可调整状态访问价格。

60 02 60 03 01 ADD 5f 52 5f 51 5f 55 00
PC0x00
Opcode待执行
本步 Gas0
剩余 Gas30,000
状态running

Stack · top ↑

空栈

Memory

尚未扩展全部按 0 读取

Storage

slot 00x00尚无待提交改动

关键的三次搬运

  1. ADD 从 Stack 弹出 3 和 2,以模 2²⁵⁶ 的算术得到 5,再压回栈顶。
  2. MSTORE 把栈上的 5 写入本帧 Memory 的 0x00..0x1fMLOAD 又把它读回 Stack。
  3. SSTORE 把 5 写入当前存储上下文的 slot 0,但它先是交易执行中的暂存状态;只有外层最终成功才提交。
版本边界

PUSH0 是上海升级加入的指令。若目标链或旧协议版本不支持它,应使用 PUSH1 00。Opcode 是否有效、Gas 常数是多少,都由执行时生效的硬分叉规则决定。 [14]

04 · Stack

Stack 是操作数通道,不是变量仓库

每项固定 256 bit,最多 1024 项;大多数 Opcode 的输入与输出都从这里经过。

EVM 是一台栈式机器。Stack 采用 LIFO:最后压入的值最先弹出。它不认识 balanceowner 这样的源码变量名,只看一个个 256-bit word。 [3]

为什么是 256 bit?

一个栈项可表示从 02²⁵⁶-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..DUP16SWAP1..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 建立直觉。

最高触及字节31
激活 words1
扩展 Gas3
0001 0203 0405 0607 0809 0a0b 0c0d 0e0f

Memory 不等于 Calldata,也不等于 Returndata

当前调用帧中几类字节区域的边界
区域可写?主要指令寿命与用途
Calldata只读CALLDATALOADCALLDATACOPY本次调用输入;复制到 Memory 后才能改
Memory可写MLOADMSTOREMCOPY当前帧临时编码、计算与返回数据
Returndata只读缓冲RETURNDATASIZERETURNDATACOPY保存最近一次外部调用返回的 bytes,下一次调用会替换
Code执行中不可改CODESIZECODECOPY当前执行字节码,与 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,并把它加入已访问集合。

执行前slot 0 = 0x05
执行后stack ← 0x05
访问温度cold → warm
执行收费2,100 Gas
退款计数变化0
交易结束后值不变

“写进 Storage”不等于已经不可回滚

执行客户端会把状态修改放在可回滚的日志/检查点体系中。后续同一交易里的代码能够读到新值, 但如果当前帧或外层帧最终 REVERT,对应范围内的 Storage 写入、余额变化和日志都会撤销。 顶层成功后,才把最终差异纳入新的世界状态。

Transient Storage 是现代 EVM 的第四种临时状态

Dencun/Cancun 引入 TLOADTSTORE。它像 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 = ADD0x51 = MLOAD0x55 = SSTOREPUSH1..PUSH32 会把后续 1–32 个代码字节视作立即数, 因此 PC 不是每次都只加 1。完整表还会随硬分叉增加新指令或调整价格。 [4]

算术与位运算

只对 Stack word 运算,通常固定且较低成本。

ADDMULDIVANDSHRCLZ

环境与区块

读取当前调用、交易或区块提供的上下文。

CALLERCALLVALUECHAINIDTIMESTAMPBASEFEE

数据搬运

在 Stack 与各类字节区域之间复制或装载。

CALLDATALOADCODECOPYMLOADMSTOREMCOPY

状态访问

读取或修改账户、持久 Storage 与瞬态 Storage。

BALANCESLOADSSTORETLOADTSTORE

控制流

改变 PC 或结束当前调用帧。

JUMPJUMPISTOPRETURNREVERT

调用与创建

创建新帧、转移调用上下文或部署代码。

CALLSTATICCALLDELEGATECALLCREATECREATE2

一条指令的 Gas 可能由多部分组成

  • 固定基础价:例如简单算术、栈操作。
  • 按输入规模:复制、哈希、指数运算会随长度或指数大小增加。
  • Memory 扩展:触及新的最高范围时额外收费。
  • 状态冷热:账户或 slot 本交易首次访问更贵,再次访问更便宜。
  • 状态转变:SSTORE 还区分 0→非 0、修改、清零、恢复原值等。
  • 子调用预算:CALL 类指令还要处理转发 Gas、value 与新账户等因素。
2026 版本提醒

主网当前已包含 PUSH0TLOAD/TSTOREMCOPYCLZ 等较新能力;CLZ 已随 2025-12-03 的 Fusaka 升级激活。 [13]计划中的 Glamsterdam 仍可能重定价状态访问, 所以应把 Opcode 语义与某个时点的 Gas 常数分开记忆。[12]

08 · Call frames

合约一调用,机器就长出一层新帧

新的 Stack、Memory、PC 与 Calldata,并不意味着所有上下文都换成被调用者。

外部消息调用会形成嵌套帧。父帧暂停,子帧执行;子帧结束后,父帧获得成功标志与返回数据, 再继续自己的 PC。调用深度有协议上限,Gas 转发也受到规则约束。

CALL、DELEGATECALL 与 STATICCALL 的关键上下文
方式执行代码当前地址 / Storagemsg.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]

成功停止

STOPRETURN 正常结束;保留当前帧成功路径的状态改动,未用 Gas 返回调用者。

受控回滚

REVERT 撤销当前帧及其子帧的状态效果,可返回错误数据,并保留尚未消耗的 Gas。

异常停止

Out of gas、非法 Opcode、非法跳转、栈错误等撤销当前帧效果,并耗尽分配给该帧的剩余 Gas。

“回滚状态”与“退回费用”是两回事

节点已经为失败路径执行了指令,所以已消耗的 Gas 仍收费。REVERT 能保留 当前帧尚未使用的 Gas;顶层 out-of-gas 通常会耗尽交易提供的执行预算。无论哪一种, 被回滚的余额、Storage 和日志不进入最终状态,但发送者 nonce 与交易费的顶层处理不会因此消失。

确定性真正要求什么

合约不能直接读取网页、随机访问系统时间或调用任意外部 API。它只能使用交易输入、可见链上状态、 当前区块提供的受协议约束字段和其他合约返回的数据;否则不同节点无法独立复算同一结果。

六个最常见的认知错误

“Memory 是便宜版 Storage。”

错。首先是寿命与作用域不同,便宜只是这种差异在 Gas 上的结果。

“SSTORE 一执行就永久写入。”

错。后续代码可见新值,但外层失败仍能把它回滚。

“每条 Opcode 都有一个固定价格。”

错。复制、Memory、Storage、CALL 等都有动态部分。

“CALL 会共享双方 Memory。”

错。子帧有独立 Memory,输入输出通过指定区域复制。

“EVM 执行 Solidity 源码。”

错。它执行目标协议版本认可的字节码与 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. [1]
    LOREWORD ·《从0到深入理解以太坊》课程目录

    本课对应第八章第二项“运行机制:Stack、Memory、Storage、Opcode”。

  2. [2]
    ethereum.org · Ethereum Virtual Machine

    状态转换、1024 深度 256-bit Stack、Memory、Storage、Transient Storage 与 Opcode 总览。

  3. [3]
    Ethereum Yellow Paper · Sections 9 and Appendix H

    EVM 机器状态、执行环境、异常停止与 Memory Gas 的形式化定义;具体协议版本还应结合执行规范。

  4. [4]
    ethereum.org · Opcodes for the EVM

    Opcode 编码、Stack 输入输出、Memory/Storage 效果与 Gas 参考表。

  5. [5]
    EIP-2929 · Gas cost increases for state access opcodes

    账户与 storage key 的 cold/warm 访问集合,以及 SLOAD、SSTORE 和 CALL 类重定价。

  6. [6]
    EIP-2200 · Structured Definitions for Net Gas Metering

    SSTORE 依据 original、current 与 new value 计价和调整退款计数的基础规则。

  7. [7]
    EIP-3529 · Reduction in refunds

    Storage 清理退款与交易 Gas refund 上限的现行基础。

  8. [8]
    EIP-1153 · Transient storage opcodes

    TLOAD/TSTORE 的交易级寿命、调用与 DELEGATECALL 所有权、回滚和 Gas 语义。

  9. [9]
    EIP-5656 · MCOPY - Memory copying instruction

    现代 EVM 中 Memory 到 Memory 的高效复制指令及重叠复制语义。

  10. [10]
    EIP-140 · REVERT instruction

    受控回滚、返回错误数据与保留未消耗 Gas 的语义。

  11. [11]
    Ethereum Execution Layer Specifications

    由 Ethereum Foundation STEEL 团队维护的可执行共识级参考规范与硬分叉历史。

  12. [12]
    ethereum.org · Glamsterdam

    截至 2026-07-25 仍计划于 2026 下半年进行的升级,以及状态访问重定价的版本边界。

  13. [13]
    EIP-7939 · Count leading zeros (CLZ) opcode

    Fusaka 加入的 CLZ 指令:对 256-bit word 计算前导零位数;输入为零时返回 256。

  14. [14]
    EIP-3855 · PUSH0 instruction

    上海升级加入的零常量压栈指令,以及其 2 Gas 价格与字节码编码。

Lesson complete · 08.02

EVM 没有魔法:只有一条条可复算的状态变化。

下一课将继续追踪 Solidity 源码如何经编译变成 Bytecode,以及高级语言结构怎样落到今天学过的 Opcode 与数据区域。