跳转到主要内容

理解黄皮书的 EVM 规范

evm
中级
qbzzt
2022年5月15日
26 分钟阅读

黄皮书 (在新标签页中打开)是以太坊的正式规范。除了被 EIP 流程修改的部分外,它包含了对所有事物运作方式的精确描述。它是作为一篇数学论文编写的,其中包含程序员可能不熟悉的术语。在本文中,你将学习如何阅读它,并以此类推阅读其他相关的数学论文。

哪个版本的黄皮书?

就像以太坊中的几乎所有其他事物一样,黄皮书也会随着时间的推移而演变。为了能够引用特定版本,我上传了撰写本文时的当前版本 (在新标签页中打开)。我使用的章节、页码和公式编号都将引用该版本。在阅读本文档时,最好在另一个窗口中打开它。

为什么选择 EVM?

最初的黄皮书是在以太坊开发之初编写的。它描述了最初用于保护网络安全的基于工作量证明 (PoW) 的共识机制。然而,以太坊在 2022 年 9 月关闭了工作量证明,并开始使用基于权益证明 (PoS) 的共识。本教程将重点关注黄皮书中定义以太坊虚拟机的部分。EVM 在向权益证明过渡的过程中没有发生变化(除了 DIFFICULTY 操作码的返回值)。

9 执行模型

本节(第 14-16 页)包含了 EVM 的大部分定义。

术语_系统状态_包含了运行系统所需了解的一切信息。在典型的计算机中,这意味着内存、寄存器的内容等。

图灵机 (在新标签页中打开)是一种计算模型。本质上,它是计算机的简化版本,已被证明具有与普通计算机相同的运行计算的能力(计算机能计算的一切,图灵机也能计算,反之亦然)。这种模型使得证明关于什么是可计算的、什么是不可计算的各种定理变得更加容易。

术语图灵完备 (在新标签页中打开)指的是一台能够运行与图灵机相同计算的计算机。图灵机可能会陷入无限循环,而 EVM 不会,因为它会耗尽 Gas,所以它只是准图灵完备的。

9.1 基础知识

本节介绍了 EVM 的基础知识以及它与其他计算模型的比较。

堆栈机 (在新标签页中打开)是一种不将中间数据存储在寄存器中,而是存储在堆栈 (在新标签页中打开)中的计算机。这是虚拟机的首选架构,因为它易于实现,这意味着出现错误和安全漏洞的可能性要小得多。堆栈中的内存被划分为 256 位的字。选择这种设计是因为它方便了以太坊的核心密码学操作,例如 Keccak-256 哈希处理和椭圆曲线计算。堆栈的最大大小为 1024 个项(1024 x 256 位)。当操作码执行时,它们通常从堆栈中获取参数。有一些专门用于重新组织堆栈中元素的操作码,例如 POP(从堆栈顶部移除项)、DUP_N(复制堆栈中的第 N 个项)等。

EVM 还有一个称为内存的易失性空间,用于在执行期间存储数据。该内存被组织成 32 字节的字。所有内存位置都被初始化为零。如果你执行这段 Yul (在新标签页中打开) 代码将一个字添加到内存中,它将通过用零填充字中的空白空间来填满 32 字节的内存,即它创建了一个字——在位置 0-29 填充零,在 30 填充 0x60,在 31 填充 0xA7。

mstore(0, 0x60A7)

mstore 是 EVM 提供的用于与内存交互的三个操作码之一——它将一个字加载到内存中。另外两个是 mstore8(将单个字节加载到内存中)和 mload(将一个字从内存移动到堆栈)。

EVM 还有一个独立的非易失性存储模型,作为系统状态的一部分进行维护——这种内存被组织成字数组(与堆栈中按字寻址的字节数组相反)。这种存储是合约保存持久数据的地方——合约只能与其自己的存储进行交互。存储以键值映射的形式组织。

虽然黄皮书的这一节没有提到,但了解还有第四种类型的内存也是很有用的。调用数据 (Calldata) 是按字节寻址的只读内存,用于存储随交易的 data 参数传递的值。EVM 有专门用于管理 calldata 的操作码。calldatasize 返回数据的大小。calldataload 将数据加载到堆栈中。calldatacopy 将数据复制到内存中。

标准的冯·诺依曼架构 (在新标签页中打开)将代码和数据存储在同一内存中。出于安全原因,EVM 没有遵循这一标准——共享易失性内存使得更改程序代码成为可能。相反,代码被保存到存储中。

只有在两种情况下代码是从内存中执行的:

术语异常执行指的是导致当前合约执行停止的异常。

9.2 费用概述

本节解释了 Gas 费用的计算方式。包含三种成本:

操作码成本

特定操作码的固有成本。要获取此值,请在附录 H(第 29 页,公式 (329) 下方)中找到操作码的成本组,并在公式 (326) 中找到该成本组。这会为你提供一个成本函数,在大多数情况下,该函数使用附录 G(第 28 页)中的参数。

例如,操作码 CALLDATACOPY (在新标签页中打开)Wcopy 组的成员。该组的操作码成本为 Gverylow+Gcopy×⌈μs[2]÷32⌉。查看附录 G,我们看到这两个常量都是 3,这给出了 3+3×⌈μs[2]÷32⌉

我们仍然需要破译表达式 ⌈μs[2]÷32⌉。最外层部分 ⌈ <value> ⌉ 是向上取整函数,该函数给定一个值,返回不小于该值的最小整数。例如,⌈2.5⌉ = ⌈3⌉ = 3。内部部分是 μs[2]÷32。查看第 3 页的第 3 节(约定),μ 是机器状态。机器状态在第 15 页的第 9.4.1 节中定义。根据该节,机器状态参数之一是代表堆栈的 s。综合起来,μs[2] 似乎是堆栈中的位置 #2。查看该操作码 (在新标签页中打开),堆栈中的位置 #2 是以字节为单位的数据大小。查看 Wcopy 组中的其他操作码 CODECOPY (在新标签页中打开)RETURNDATACOPY (在新标签页中打开),它们在相同位置也有数据大小。因此,⌈μs[2]÷32⌉ 是存储正在复制的数据所需的 32 字节字的个数。综合起来,CALLDATACOPY (在新标签页中打开) 的固有成本是 3 Gas 加上每复制一个数据字 3 Gas。

运行成本

运行我们调用的代码的成本。

扩展内存成本

扩展内存的成本(如果需要)。

在公式 326 中,此值写为 Cmemi')-Cmemi)。再次查看第 9.4.1 节,我们看到 μi 是内存中的字数。因此,μi 是操作码执行前内存中的字数,而 μi' 是操作码执行后内存中的字数。

函数 Cmem 在公式 328 中定义:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋⌊x⌋ 是向下取整函数,该函数给定一个值,返回不大于该值的最大整数。例如,⌊2.5⌋ = ⌊2⌋ = 2。当 a < √512 时,a2 < 512,向下取整函数的结果为零。因此,对于前 22 个字(704 字节),成本随所需内存字数呈线性增长。超过该点后,⌊a2 ÷ 512⌋ 为正。当所需内存足够高时,Gas 成本与内存量的平方成正比。

注意,这些因素仅影响_固有_的 Gas 成本——它没有考虑决定最终用户需要支付多少费用的费用市场或给验证者的提示——这只是在 EVM 上运行特定操作的原始成本。

了解有关 Gas 的更多信息

9.3 执行环境

执行环境是一个元组 I,其中包含不属于区块链状态或 EVM 的信息。

参数访问数据的操作码访问数据的 Solidity 代码
IaADDRESS (在新标签页中打开)address(this)
IoORIGIN (在新标签页中打开)tx.origin
IpGASPRICE (在新标签页中打开)tx.gasprice
IdCALLDATALOAD (在新标签页中打开)msg.data
IsCALLER (在新标签页中打开)msg.sender
IvCALLVALUE (在新标签页中打开)msg.value
IbCODECOPY (在新标签页中打开)address(this).code
IH区块头字段,例如 NUMBER (在新标签页中打开)DIFFICULTY (在新标签页中打开)block.numberblock.difficulty
Ie合约间调用(包括合约创建)的调用堆栈深度
IwEVM 是否允许更改状态,或者是否静态运行

要理解第 9 节的其余部分,还需要了解其他几个参数:

参数定义所在节含义
σ2(第 2 页,公式 1)区块链的状态
g9.3(第 14 页)剩余 Gas
A6.1(第 9 页)累积子状态(计划在交易结束时发生的更改)
o9.3(第 14 页)输出——在内部交易(当一个合约调用另一个合约时)和调用视图函数(当你只是请求信息,因此无需等待交易时)情况下的返回结果

9.4 执行概述

现在我们已经掌握了所有的预备知识,终于可以开始研究 EVM 的工作原理了。

公式 146-151 给出了运行 EVM 的初始条件:

符号初始值含义
μgg剩余 Gas
μpc0程序计数器,要执行的下一条指令的地址
μm(0, 0, ...)内存,初始化为全零
μi0使用的最高内存位置
μs()堆栈,最初为空
μo输出,为空集,直到且除非我们以带有返回数据(RETURN (在新标签页中打开)REVERT (在新标签页中打开))或不带返回数据(STOP (在新标签页中打开)SELFDESTRUCT (在新标签页中打开))的方式停止。

公式 152 告诉我们,在执行期间的每个时间点都有四种可能的情况,以及如何处理它们:

  1. Z(σ,μ,A,I)。Z 代表一个测试操作是否创建无效状态转换的函数(参见异常停止)。如果其计算结果为 True,则新状态与旧状态相同(除了 Gas 被消耗),因为更改尚未实施。
  2. 如果正在执行的操作码是 REVERT (在新标签页中打开),则新状态与旧状态相同,会损失一些 Gas。
  3. 如果操作序列已完成(由 RETURN (在新标签页中打开) 表示),则状态将更新为新状态。
  4. 如果我们不处于结束条件 1-3 之一,则继续运行。

9.4.1 机器状态

本节更详细地解释了机器状态。它指定 w 为当前操作码。如果 μpc 小于代码长度 ||Ib||,则该字节 (Ibpc]) 就是操作码。否则,操作码被定义为 STOP (在新标签页中打开)

由于这是一个堆栈机 (在新标签页中打开),我们需要跟踪每个操作码弹出 (δ) 和推入 (α) 的项数。

9.4.2 异常停止

本节定义了 Z 函数,该函数指定了我们何时发生异常终止。这是一个布尔 (在新标签页中打开)函数,因此它使用 表示逻辑或 (在新标签页中打开),使用 表示逻辑与 (在新标签页中打开)

如果以下任何条件为真,我们就会发生异常停止:

  • μg < C(σ,μ,A,I) 正如我们在第 9.2 节中看到的,C 是指定 Gas 成本的函数。没有足够的剩余 Gas 来支付下一个操作码。

  • δw=∅ 如果操作码弹出的项数未定义,则操作码本身未定义。

  • || μs || < δw 堆栈下溢,堆栈中没有足够的项供当前操作码使用。

  • w = JUMP ∧ μs[0]∉D(Ib) 操作码是 JUMP (在新标签页中打开),并且地址不是 JUMPDEST (在新标签页中打开)。跳转_仅_在目标是 JUMPDEST (在新标签页中打开) 时有效。

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作码是 JUMPI (在新标签页中打开),条件为真(非零),因此应该发生跳转,并且地址不是 JUMPDEST (在新标签页中打开)。跳转_仅_在目标是 JUMPDEST (在新标签页中打开) 时有效。

  • w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || 操作码是 RETURNDATACOPY (在新标签页中打开)。在此操作码中,堆栈元素 μs[1] 是在返回数据缓冲区中读取的偏移量,堆栈元素 μs[2] 是数据长度。当你尝试读取超出返回数据缓冲区末尾的内容时,就会发生此条件。请注意,对于调用数据或代码本身,没有类似的条件。当你尝试读取超出这些缓冲区末尾的内容时,你只会得到零。

  • || μs || - δw + αw > 1024

    堆栈溢出。如果运行操作码将导致堆栈超过 1024 项,则中止。

  • ¬Iw ∧ W(w,μ) 我们是否在静态运行(¬ 是否定 (在新标签页中打开),当允许我们更改区块链状态时 Iw 为真)?如果是,并且我们正在尝试更改状态的操作,则该操作无法发生。

    函数 W(w,μ) 在后面的公式 159 中定义。如果以下条件之一为真,则 W(w,μ) 为真:

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} 这些操作码通过创建新合约、存储值或销毁当前合约来更改状态。

    • LOG0≤w ∧ w≤LOG4 如果我们被静态调用,则无法发出日志条目。 日志操作码都在 LOG0 (A0) (在新标签页中打开)LOG4 (A4) (在新标签页中打开) 之间的范围内。 日志操作码后面的数字指定日志条目包含多少个主题。

    • w=CALL ∧ μs[2]≠0 你可以在静态时调用另一个合约,但如果这样做,你不能向其转账 ETH。

  • w = SSTORE ∧ μg ≤ Gcallstipend 除非你有超过 Gcallstipend(在附录 G 中定义为 2300)的 Gas,否则无法运行 SSTORE (在新标签页中打开)

9.4.3 跳转目标有效性

在这里,我们正式定义什么是 JUMPDEST (在新标签页中打开) 操作码。我们不能仅仅寻找字节值 0x5B,因为它可能在 PUSH 内部(因此是数据而不是操作码)。

在公式 (162) 中,我们定义了一个函数 N(i,w)。第一个参数 i 是操作码的位置。第二个参数 w 是操作码本身。如果 w∈[PUSH1, PUSH32],这意味着操作码是 PUSH(方括号定义了包含端点的范围)。在这种情况下,下一个操作码位于 i+2+(w−PUSH1)。对于 PUSH1 (在新标签页中打开),我们需要前进两个字节(PUSH 本身和一个字节的值),对于 PUSH2 (在新标签页中打开),我们需要前进三个字节,因为它是一个两字节的值,依此类推。所有其他 EVM 操作码都只有一个字节长,因此在所有其他情况下 N(i,w)=i+1

此函数在公式 (161) 中用于定义 DJ(c,i),它是代码 c 中从操作码位置 i 开始的所有有效跳转目标的集合 (在新标签页中打开)。此函数是递归定义的。如果 i≥||c||,这意味着我们处于代码末尾或之后。我们不会再找到任何跳转目标,因此只需返回空集。

在所有其他情况下,我们通过转到下一个操作码并获取从它开始的集合来查看代码的其余部分。c[i] 是当前操作码,因此 N(i,c[i]) 是下一个操作码的位置。因此,DJ(c,N(i,c[i])) 是从下一个操作码开始的有效跳转目标的集合。如果当前操作码不是 JUMPDEST,只需返回该集合。如果是 JUMPDEST,则将其包含在结果集中并返回。

9.4.4 正常停止

停止函数 H 可以返回三种类型的值。

H.2 指令集

在进入 EVM 的最后一个小节 9.5 之前,让我们先看看指令本身。它们在从第 30 页开始的附录 H.2 中定义。任何未指定随该特定操作码更改的内容都应保持不变。发生更改的变量用 <something>′ 指定。

例如,让我们看看 ADD (在新标签页中打开) 操作码。

助记符δα描述
0x01ADD21加法运算。
μ′s[0] ≡ μs[0] + μs[1]

δ 是我们从堆栈中弹出的值的数量。在这种情况下是两个,因为我们要将最上面的两个值相加。

α 是我们推回的值的数量。在这种情况下是一个,即总和。

因此,新的堆栈顶部 (μ′s[0]) 是旧的堆栈顶部 (μs[0]) 和它下面的旧值 (μs[1]) 的总和。

本文没有用“令人眼花缭乱的列表”来遍历所有操作码,而是仅解释那些引入新内容的操作码。

助记符δα描述
0x20KECCAK25621计算 Keccak-256 哈希。
μ′s[0] ≡ KEC(μms[0] . . . (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0],μs[1])

这是第一个访问内存的操作码(在这种情况下,是只读的)。但是,它可能会扩展超出内存的当前限制,因此我们需要更新 μi。我们使用第 30 页公式 330 中定义的 M 函数来执行此操作。

助记符δα描述
0x31BALANCE11获取给定账户的余额。
...

我们需要查找其余额的地址是 μs[0] mod 2160。堆栈的顶部是地址,但由于地址只有 160 位,我们计算该值对 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 是交易先前访问过的地址列表,因此访问这些地址应该更便宜,如第 9 页的第 6.1 节所定义。你可以在 EIP-2929 (在新标签页中打开) 中阅读有关此主题的更多信息。

助记符δα描述
0x8FDUP161617复制第 16 个堆栈项。
μ′s[0] ≡ μs[15]

请注意,要使用任何堆栈项,我们需要将其弹出,这意味着我们还需要弹出其上的所有堆栈项。在 DUP<n> (在新标签页中打开)SWAP<n> (在新标签页中打开) 的情况下,这意味着必须弹出然后推入多达 16 个值。

9.5 执行周期

现在我们已经掌握了所有部分,终于可以理解 EVM 的执行周期是如何记录的了。

公式 (164) 指出,给定状态:

  • σ(全局区块链状态)
  • μ(EVM 状态)
  • A(子状态,交易结束时将发生的更改)
  • I(执行环境)

新状态为 (σ', μ', A', I')

公式 (165)-(167) 定义了堆栈以及由于操作码 (μs) 引起的堆栈变化。公式 (168) 是 Gas 的变化 (μg)。公式 (169) 是程序计数器的变化 (μpc)。最后,公式 (170)-(173) 指定其他参数保持不变,除非被操作码显式更改。

至此,EVM 已被完全定义。

结论

数学符号是精确的,它使得黄皮书能够指定以太坊的每一个细节。然而,它确实有一些缺点:

也许正是由于这些原因,较新的共识层规范 (在新标签页中打开)是用 Python 编写的。有用 Python 编写的执行层规范 (在新标签页中打开),但它们并不完整。直到且除非整个黄皮书也被翻译成 Python 或类似的语言,否则黄皮书将继续发挥作用,能够阅读它是有帮助的。