理解黄皮书中的 EVM 规范
《黄皮书》opens in a new tab是以太坊的正式规范。 除经 EIP 流程修订之外,它包含了关于一切如何运作的准确描述。 它以数学论文的形式写就,其中包含程序员可能不熟悉的术语。 在本文中,你将学会如何阅读它,并由此学会阅读其他相关的数学论文。
哪一篇黄皮书?
与以太坊中的几乎所有其他事物一样,黄皮书也会随着时间的推移而演变。 为了能够引用特定的版本,我上传了撰写本文时的当前版本。 本文中使用的章节、页码和方程式编号均指该版本。 在阅读本文档时,最好在另一个窗口中打开它。
为什么是 EVM?
最初的黄皮书是在以太坊开发之初编写的。 它描述了最初用于保障网络安全的、基于工作量证明的共识机制。 然而,以太坊于 2022 年 9 月弃用工作量证明,并开始使用基于权益证明的共识机制。 本教程将重点介绍黄皮书中定义以太坊虚拟机的部分。 向权益证明的过渡并未改变 EVM(DIFFICULTY 操作码的返回值除外)。
9 执行模型
本节(第 12-14 页)包含了 EVM 的大部分定义。
术语_系统状态_包含运行系统所需了解的一切信息。 在典型的计算机中,这指的是内存、寄存器内容等。
图灵机opens in a new tab是一种计算模型。 从本质上讲,它是计算机的简化版本,经证明,它拥有与普通计算机相同的计算能力(即计算机可以计算的任何东西,图灵机都可以计算,反之亦然)。 该模型让我们能更轻松地证明关于哪些是可计算的、哪些是不可计算的各种定理。
术语图灵完备opens in a new tab指的是能运行与图灵机相同计算的计算机。 图灵机可能陷入无限循环,但 EVM 不会,因为它会耗尽燃料,所以它只是准图灵完备的。
9.1 基础知识
本节介绍了 EVM 的基础知识,以及它与其他计算模型的比较。
堆栈机opens in a new tab是一种将中间数据存储在堆栈opens in a new tab而非寄存器中的计算机。 这是虚拟机的首选架构,因为它易于实现,这意味着出现程序错误和安全漏洞的可能性要小得多。 堆栈中的内存被划分为 256 位的字。 选择这个大小是因为它便于以太坊的核心加密操作,例如 Keccak-256 哈希和椭圆曲线计算。 堆栈的最大大小为 1024 项(1024 x 256 位)。 执行操作码时,它们通常从堆栈中获取参数。 有一些专门用于重组堆栈中元素的操作码,例如 POP(从堆栈顶部移除项目)、DUP_N(复制堆栈中第 N 个项目)等。
EVM 还有一个称为内存 (memory) 的易失性空间,用于在执行期间存储数据。 该内存被组织成 32 字节的字。 所有内存位置都初始化为零。 如果你执行这段 Yulopens in a new tab 代码向内存添加一个字,它将通过用零填充该字的空白空间来填满 32 字节的内存,也就是说,它创建了一个字——在位置 0-29 处为零,在位置 30 处为 0x60,在位置 31 处为 0xA7。
1mstore(0, 0x60A7)mstore 是 EVM 提供的用于与内存交互的三个操作码之一——它将一个字加载到内存中。 另外两个是 mstore8(将单个字节加载到内存中)和 mload(将一个字从内存移动到堆栈)。
EVM 还有一个独立的非易失性存储 (storage) 模型,作为系统状态的一部分进行维护——该内存被组织成字数组(而不是堆栈中的可按字寻址的字节数组)。 此存储是合约保存持久数据的地方——合约只能与其自己的存储交互。 存储以键值映射的形式组织。
尽管黄皮书的这一节没有提到,但了解还有第四种类型的内存也很有用。 Calldata 是一种可按字节寻址的只读内存,用于存储随交易的 data 参数传递的值。 EVM 有专门用于管理 calldata 的操作码。 calldatasize 返回数据的大小。 calldataload 将数据加载到堆栈中。 calldatacopy 将数据复制到内存中。
标准的冯·诺依曼结构opens in a new tab将代码和数据存储在同一内存中。 出于安全原因,EVM 并未遵循此标准——共享易失性内存使得更改程序代码成为可能。 取而代之的是,代码被保存在存储中。
只有两种情况下代码会从内存中执行:
- 当一个合约创建另一个合约时(使用
CREATEopens in a new tab 或CREATE2opens in a new tab),合约构造函数代码来自内存。 - 在创建_任何_合约期间,构造函数代码运行,然后返回实际合约的代码,该代码也来自内存。
术语“异常执行”指的是导致当前合约执行停止的异常。
9.2 费用概述
本节解释了燃料费用的计算方式。 有三种成本:
操作码成本
特定操作码的内在成本。 要获得此值,请在附录 H(第 28 页,方程式 (327) 下)中找到操作码的成本组,并在方程式 (324) 中找到该成本组。 这样你就能得到一个成本函数,在大多数情况下,该函数使用附录 G(第 27 页)中的参数。
例如,操作码 CALLDATACOPYopens in a new tab 是 Wcopy 组的成员。 该组的操作码成本是 Gverylow+Gcopy×⌈μs[2]÷32⌉。 查看附录 G,我们看到两个常数都是 3,这给了我们 3+3×⌈μs[2]÷32⌉。
我们还需要解释表达式 ⌈μs[2]÷32⌉。 最外面的部分 ⌈ <value> ⌉ 是 ceiling 函数(向上取整函数),该函数给定一个值,返回不小于该值的最小整数。 例如,⌈2.5⌉ = ⌈3⌉ = 3。 内部部分是 μs[2]÷32。 查看第 3 页的第 3 节(约定),μ 是机器状态。 机器状态在第 13 页的 9.4.1 节中定义。 根据该节,机器状态参数之一是 s,代表堆栈。 综上所述,μs[2] 似乎是堆栈中的位置 #2。 查看该操作码opens in a new tab,堆栈中的位置 #2 是数据的字节大小。 查看 Wcopy 组中的其他操作码,CODECOPYopens in a new tab 和 RETURNDATACOPYopens in a new tab,它们也在同一位置有数据大小。 所以 ⌈μs[2]÷32⌉ 是存储被复制数据所需的 32 字节字的数目。 综上所述,CALLDATACOPYopens in a new tab 的内在成本是 3 燃料,外加每个被复制的数据字 3 燃料。
运行成本
运行我们正在调用的代码的成本。
- 对于
CREATEopens in a new tab 和CREATE2opens in a new tab,指的是新合约的构造函数。 - 对于
CALLopens in a new tab、CALLCODEopens in a new tab、STATICCALLopens in a new tab 或DELEGATECALLopens in a new tab,指的是我们调用的合约。
扩展内存成本
扩展内存的成本(如果需要)。
在方程式 324 中,该值写作 Cmem(μi')-Cmem(μi)。 再次查看 9.4.1 节,我们看到 μi 是内存中的字数。 因此,μi 是操作码之前的内存字数,而 μi' 是操作码之后的内存字数。
函数 Cmem 在方程式 326 中定义:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋。 ⌊x⌋ 是 floor 函数(向下取整函数),该函数给定一个值,返回不大于该值的最大整数。 例如,⌊2.5⌋ = ⌊2⌋ = 2. 当 a < √512 时,a2 < 512,向下取整函数的结果为零。 因此,对于前 22 个字(704 字节),成本随着所需内存字数的增加而线性上升。 超过该点,⌊a2 ÷ 512⌋ 为正。 当所需内存足够高时,燃料成本与内存量的平方成正比。
注意,这些因素只影响_固有_燃料成本——它不考虑费用市场或给验证者的小费,这些因素决定了终端用户需要支付多少费用——这只是在 EVM 上运行特定操作的原始成本。
9.3 执行环境
执行环境是一个元组 I,它包含不属于区块链状态或 EVM 的信息。
| 参数 | 用于访问数据的操作码 | 用于访问数据的 Solidity 代码 |
|---|---|---|
| Ia | ADDRESSopens in a new tab | address(this) |
| Io | ORIGINopens in a new tab | tx.origin |
| Ip | GASPRICEopens in a new tab | tx.gasprice |
| Id | CALLDATALOADopens in a new tab 等 | msg.data |
| Is | CALLERopens in a new tab | 发送者 |
| Iv | CALLVALUEopens in a new tab | msg.value |
| Ib | CODECOPYopens in a new tab | address(this).code |
| IH | 区块头字段,例如 NUMBERopens in a new tab 和 DIFFICULTYopens in a new tab | block.number、block.difficulty 等 |
| Ie | 合约之间调用的调用堆栈深度(包括合约创建) | |
| Iw | EVM 是否被允许改变状态,或者它是否在静态运行 |
要理解第 9 节的其余部分,还需要了解其他几个参数:
| 参数 | 定义于章节 | 含义 |
|---|---|---|
| σ | 2(第 2 页,方程式 1) | 区块链的状态 |
| g | 9.3(第 13 页) | 剩余燃料 |
| A | 6.1(第 8 页) | 累积的子状态(计划在交易结束时进行的更改) |
| o | 9.3(第 13 页) | 输出——在内部交易(当一个合约调用另一个合约时)和调用视图函数(当您只是请求信息,因此无需等待交易时)的情况下的返回结果 |
9.4 执行概述
现在我们已经了解了所有的准备工作,我们终于可以开始研究 EVM 的工作原理了。
方程式 137-142 给出了运行 EVM 的初始条件:
| 符号 | 初始值 | 含义 |
|---|---|---|
| μg | g | 剩余燃料 |
| μpc | 0 | 程序计数器,下一个要执行的指令的地址 |
| μm | (0, 0, ...) | 内存,初始化为全零 |
| μi | 0 | 已使用的最高内存位置 |
| μs | () | 堆栈,初始为空 |
| μo | ∅ | 输出,在有返回数据(RETURNopens in a new tab 或 REVERTopens in a new tab)或没有返回数据(STOPopens in a new tab 或 SELFDESTRUCTopens in a new tab)的情况下停止之前为空集。 |
方程式 143 告诉我们,在执行过程中的每个时间点都有四种可能的情况,以及如何处理它们:
Z(σ,μ,A,I)。 Z 代表一个函数,用于测试操作是否会产生无效的状态转换(请参阅异常终止)。 如果其计算结果为 True,则新状态与旧状态相同(除了燃料被消耗),因为更改尚未实施。- 如果正在执行的操作码是
REVERTopens in a new tab,则新状态与旧状态相同,但会损失一些燃料。 - 如果操作序列已完成(由
RETURNopens in a new tab 表示),状态将更新为新状态。 - 如果我们不处于结束条件 1-3 之一,则继续运行。
9.4.1 机器状态
本节更详细地解释了机器状态。 它指定 w 是当前操作码。 如果 μpc 小于 ||Ib||(代码的长度),则该字节(Ib[μpc])就是操作码。 否则,操作码被定义为 STOPopens in a new tab。
由于这是一个堆栈机opens in a new tab,我们需要跟踪每个操作码弹出(δ)和压入(α)的项目数量。
9.4.2 异常终止
本节定义了 Z 函数,该函数指定了何时出现异常终止。 这是一个布尔opens in a new tab函数,因此它使用 ∨ 表示逻辑或opens in a new tab,使用 ∧ 表示逻辑与opens in a new tab。
如果以下任何一个条件为真,我们就会出现异常终止:
-
μg < C(σ,μ,A,I) 如我们在 9.2 节所见,C 是指定燃料成本的函数。 没有足够的剩余燃料来支付下一个操作码。
-
δw=∅ 如果一个操作码弹出的项目数量未定义,那么该操作码本身就是未定义的。
-
|| μs || < δw 堆栈下溢,堆栈中没有足够的项目供当前操作码使用。
-
w = JUMP ∧ μs[0]∉D(Ib) 操作码是
JUMPopens in a new tab,且地址不是JUMPDESTopens in a new tab。 只有当目标是JUMPDESTopens in a new tab 时,跳转才是_有效_的。 -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作码是
JUMPIopens in a new tab,条件为真(非零),因此应该发生跳转,但地址不是JUMPDESTopens in a new tab。 只有当目标是JUMPDESTopens in a new tab 时,跳转才是_有效_的。 -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || 操作码是
RETURNDATACOPYopens in a new tab。 在此操作码中,堆栈元素 μs[1] 是从返回数据缓冲区中读取的偏移量,堆栈元素 μs[2] 是数据的长度。 当您尝试读取超出返回数据缓冲区末尾时,会出现此情况。 请注意,对于调用数据或代码本身,没有类似的条件。 当您尝试读取超出这些缓冲区末尾时,您只会得到零。 -
|| μs || - δw + αw > 1024
堆栈溢出。 如果运行操作码将导致堆栈超过 1024 个项目,则中止。
-
¬Iw ∧ W(w,μ) 我们是否在静态运行(¬ 是否定opens in a new tab 且当我们被允许改变区块链状态时 Iw 为真)? 如果是,并且我们正在尝试一个改变状态的操作,那么它不能发生。
函数 W(w,μ) 在后面的方程式 150 中定义。 如果以下条件之一为真,则 W(w,μ) 为真:
-
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} 这些操作码会改变状态,通过创建新合约、存储值或销毁当前合约。
-
LOG0≤w ∧ w≤LOG4 如果我们被静态调用,我们不能发出日志条目。 日志操作码都在
LOG0(A0)opens in a new tab 和LOG4(A4)opens in a new tab 之间的范围内。 日志操作码后面的数字指定了日志条目包含的主题数量。 -
w=CALL ∧ μs[2]≠0 您可以在静态时调用另一个合约,但如果您这样做,您不能向其转移 ETH。
-
-
w = SSTORE ∧ μg ≤ Gcallstipend 除非您的燃料超过 Gcallstipend(在附录 G 中定义为 2300),否则您不能运行
SSTOREopens in a new tab。
9.4.3 跳转目标有效性
在这里,我们正式定义了什么是 JUMPDESTopens in a new tab 操作码。 我们不能只查找字节值 0x5B,因为它可能在 PUSH 内部(因此是数据而不是操作码)。
在方程式 (153) 中,我们定义了一个函数 N(i,w)。 第一个参数 i 是操作码的位置。 第二个参数 w 是操作码本身。 如果 w∈[PUSH1, PUSH32],这意味着操作码是一个 PUSH(方括号定义了一个包含端点的范围)。 在这种情况下,下一个操作码位于 i+2+(w−PUSH1)。 对于 PUSH1opens in a new tab,我们需要前进两个字节(PUSH 本身和一个字节的值),对于 PUSH2opens in a new tab,我们需要前进三个字节,因为它是一个两个字节的值,以此类推。 所有其他 EVM 操作码都只有一个字节长,因此在所有其他情况下 N(i,w)=i+1。
此函数在方程式 (152) 中用于定义 DJ(c,i),它是在代码 c 中从操作码位置 i 开始的所有有效跳转目标的集合opens in a new tab。 此函数是递归定义的。 如果 i≥||c||,这意味着我们处于或超过了代码的末尾。 我们不会再找到任何跳转目标,所以只需返回空集。
在所有其他情况下,我们通过转到下一个操作码并从它开始获取集合来查看代码的其余部分。 c[i] 是当前操作码,因此 N(i,c[i]) 是下一个操作码的位置。 因此 DJ(c,N(i,c[i])) 是从下一个操作码开始的有效跳转目标的集合。 如果当前操作码不是 JUMPDEST,则只返回该集合。 如果它是 JUMPDEST,则将其包含在结果集中并返回。
9.4.4 正常终止
终止函数 H 可以返回三种类型的值。
- 如果我们不在一个终止操作码中,返回 ∅,即空集。 按照惯例,这个值被解释为布尔值 false。
- 如果我们有一个不产生输出的终止操作码(
STOPopens in a new tab 或SELFDESTRUCTopens in a new tab),则返回一个大小为零字节的序列作为返回值。 请注意,这与空集非常不同。 这个值意味着 EVM 确实已经停止,只是没有返回数据可供读取。 - 如果我们有一个确实产生输出的终止操作码(
RETURNopens in a new tab 或REVERTopens in a new tab),则返回该操作码指定的字节序列。 此序列从内存中获取,堆栈顶部的值(μs[0])是第一个字节,其后的值(μs[1])是长度。
H.2 指令集
在我们进入 EVM 的最后一个小节 9.5 之前,让我们先看看指令本身。 它们在附录 H.2 中定义,从第 29 页开始。 任何未被指定为随该特定操作码而改变的东西都应保持不变。 确实发生变化的变量用 <something>’ 指定。
例如,让我们看看 ADDopens in a new tab 操作码。
| Value | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | 加法运算。 |
| μ′s[0] ≡ μs[0] + μs[1] |
δ 是我们从堆栈中弹出的值的数量。 在这种情况下是两个,因为我们正在将顶部的两个值相加。
α 是我们压回的值的数量。 在这种情况下是一个,即和。
所以新的堆栈顶部(μ′s[0])是旧的堆栈顶部(μs[0])和它下面的旧值(μs[1])的和。
本文不会用“令人眼花缭乱的列表”来逐一介绍所有操作码,而只解释那些引入新内容的操作码。
| Value | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x20 | KECCAK256 | 2 | 1 | 计算 Keccak-256 哈希。 |
| μ′s[0] ≡ KEC(μm[μs[0] . 。 。 (μs[0] + μs[1] − 1)]) | ||||
| μ′i ≡ M(μi,μs[0],μs[1]) |
这是第一个访问内存的操作码(在这种情况下是只读的)。 但是,它可能会超出内存的当前限制,所以我们需要更新 μi。 我们使用第 29 页方程式 328 中定义的 M 函数来做到这一点。
| Value | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | 获取给定帐户的余额。 |
| ... |
我们需要查找其余额的地址是 μs[0] mod 2160。 堆栈的顶部是地址,但因为地址只有 160 位,我们计算该值模opens in a new tab 2160。
如果 σ[μs[0] mod 2160] ≠ ∅,则意味着有关于此地址的信息。 在这种情况下,σ[μs[0] mod 2160]b 是该地址的余额。 如果 σ[μs[0] mod 2160] = ∅,则意味着此地址未初始化,余额为零。 您可以在第 4 页的 4.1 节中看到帐户信息字段的列表。
第二个方程式 A'a ≡ Aa ∪ {μs[0] mod 2160} 与访问热存储(最近被访问过并且很可能被缓存的存储)和冷存储(尚未被访问过并且可能在较慢的存储中,检索成本更高)之间的成本差异有关。 Aa 是交易先前访问过的地址列表,因此访问成本应该更便宜,如第 8 页的 6.1 节所定义。 您可以在 EIP-2929opens in a new tab 中阅读有关此主题的更多信息。
| Value | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | 复制第 16 个堆栈项。 |
| μ′s[0] ≡ μs[15] |
请注意,要使用任何堆栈项,我们需要弹出它,这意味着我们还需要弹出它上面的所有堆栈项。 在 DUP<n>opens in a new tab 和 SWAP<n>opens in a new tab 的情况下,这意味着需要弹出然后压入多达 16 个值。
9.5 执行周期
现在我们已经了解了所有的部分,我们终于可以理解 EVM 的执行周期是如何被记录的了。
方程式 (155) 表示,给定状态:
- σ(全局区块链状态)
- μ(EVM 状态)
- A(子状态,交易结束时发生的更改)
- I(执行环境)
新状态是 (σ', μ', A', I')。
方程式 (156)-(158) 定义了堆栈及其因操作码而发生的变化(μs)。 方程式 (159) 是燃料的变化(μg)。 方程式 (160) 是程序计数器的变化(μpc)。 最后,方程式 (161)-(164) 指定其他参数保持不变,除非被操作码明确更改。
至此,EVM 已被完全定义。
结论
数学符号是精确的,并使黄皮书能够详细说明以太坊的每一个细节。 然而,它确实有一些缺点:
- 它只能被人类理解,这意味着合规性测试opens in a new tab必须手动编写。
- 程序员理解计算机代码。 他们可能理解也可能不理解数学符号。
也许是出于这些原因,较新的共识层规范opens in a new tab是用 Python 编写的。 Python 中有执行层规范opens in a new tab,但它们并不完整。 除非并且直到整个黄皮书也被翻译成 Python 或类似的语言,否则黄皮书将继续使用,能够阅读它是有帮助的。
页面最后更新: 2025年10月21日