理解黄皮书的 EVM 规范
黄皮书 (opens in a new tab)是以太坊的正式规范。除了被 EIP 流程修改的部分外,它包含了对所有事物运作方式的精确描述。它是作为一篇数学论文编写的,其中包含程序员可能不熟悉的术语。在本文中,你将学习如何阅读它,并以此类推阅读其他相关的数学论文。
哪个版本的黄皮书?
就像以太坊中的几乎所有其他事物一样,黄皮书也会随着时间的推移而演变。为了能够引用特定版本,我上传了撰写本文时的当前版本。我使用的章节、页码和公式编号都将引用该版本。在阅读本文档时,最好在另一个窗口中打开它。
为什么选择 EVM?
最初的黄皮书是在以太坊开发之初编写的。它描述了最初用于保护网络安全的基于工作量证明 (PoW) 的共识机制。然而,以太坊在 2022 年 9 月关闭了工作量证明,并开始使用基于权益证明 (PoS) 的共识。本教程将重点关注黄皮书中定义以太坊虚拟机的部分。EVM 在向权益证明过渡的过程中没有发生变化(除了 DIFFICULTY 操作码的返回值)。
9 执行模型
本节(第 12-14 页)包含了 EVM 的大部分定义。
术语_系统状态_包含了运行系统所需了解的关于系统的一切信息。在典型的计算机中,这意味着内存、寄存器的内容等。
图灵机 (opens in a new tab)是一种计算模型。本质上,它是计算机的简化版本,被证明具有与普通计算机相同的运行计算的能力(计算机能计算的一切,图灵机也能计算,反之亦然)。这个模型使得证明关于什么是可计算的、什么是不可计算的各种定理变得更加容易。
术语图灵完备 (opens in a new tab)指的是能够运行与图灵机相同计算的计算机。图灵机可能会陷入无限循环,而 EVM 不会,因为它会耗尽 Gas,所以它只是准图灵完备的。
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 还有一个称为内存的易失性空间,用于在执行期间存储数据。该内存被组织成 32 字节的字。所有内存位置都被初始化为零。如果你执行这段 Yul (opens in a new tab) 代码将一个字添加到内存中,它将通过用零填充字中的空白空间来填满 32 字节的内存,即它创建了一个字——在位置 0-29 填充零,在 30 填充 0x60,在 31 填充 0xA7。
mstore(0, 0x60A7)
mstore 是 EVM 提供的用于与内存交互的三个操作码之一——它将一个字加载到内存中。另外两个是 mstore8(将单个字节加载到内存中)和 mload(将一个字从内存移动到堆栈)。
EVM 还有一个独立的非易失性存储模型,作为系统状态的一部分进行维护——这种内存被组织成字数组(与堆栈中按字寻址的字节数组相反)。这种存储是合约保存持久数据的地方——合约只能与其自己的存储进行交互。存储以键值映射的形式组织。
虽然黄皮书的这一节没有提到,但了解还有第四种类型的内存也是很有用的。调用数据 (Calldata) 是按字节寻址的只读内存,用于存储随交易的 data 参数传递的值。EVM 有专门用于管理 calldata 的操作码。calldatasize 返回数据的大小。calldataload 将数据加载到堆栈中。calldatacopy 将数据复制到内存中。
标准的冯·诺依曼架构 (opens in a new tab)将代码和数据存储在同一内存中。出于安全原因,EVM 没有遵循这一标准——共享易失性内存使得更改程序代码成为可能。相反,代码被保存到存储中。
只有在两种情况下代码是从内存中执行的:
- 当一个合约创建另一个合约时(使用
CREATE(opens in a new tab) 或CREATE2(opens in a new tab)),合约构造函数的代码来自内存。 - 在创建_任何_合约期间,构造函数代码运行,然后返回实际合约的代码,同样来自内存。
术语异常执行指的是导致当前合约执行停止的异常。
9.2 费用概述
本节解释了 Gas 费用的计算方式。包含三种成本:
操作码成本
特定操作码的固有成本。要获取此值,请在附录 H(第 28 页,公式 (327) 下方)中找到操作码的成本组,并在公式 (324) 中找到该成本组。这会为你提供一个成本函数,在大多数情况下,该函数使用附录 G(第 27 页)中的参数。
例如,操作码 CALLDATACOPY (opens in a new tab) 是 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 节(约定),μ 是机器状态。机器状态在第 13 页的第 9.4.1 节中定义。根据该节,机器状态参数之一是代表堆栈的 s。综合起来看,μs[2] 似乎是堆栈中的位置 #2。查看该操作码 (opens in a new tab),堆栈中的位置 #2 是以字节为单位的数据大小。查看 Wcopy 组中的其他操作码 CODECOPY (opens in a new tab) 和 RETURNDATACOPY (opens in a new tab),它们在相同位置也有一个数据大小。因此,⌈μs[2]÷32⌉ 是存储被复制数据所需的 32 字节字的个数。综上所述,CALLDATACOPY (opens in a new tab) 的固有成本是 3 Gas,加上每复制一个字的数据额外收取 3 Gas。
运行成本
运行我们调用的代码的成本。
- 在
CREATE(opens in a new tab) 和CREATE2(opens in a new tab) 的情况下,是新合约的构造函数。 - 在
CALL(opens in a new tab)、CALLCODE(opens in a new tab)、STATICCALL(opens in a new tab) 或DELEGATECALL(opens in a new tab) 的情况下,是我们调用的合约。
扩展内存成本
扩展内存的成本(如果需要)。
在公式 324 中,该值被写为 Cmem(μi')-Cmem(μi)。再次查看第 9.4.1 节,我们看到 μi 是内存中的字数。因此,μi 是操作码执行前内存中的字数,而 μi' 是操作码执行后内存中的字数。
函数 Cmem 在公式 326 中定义:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋。⌊x⌋ 是向下取整函数,该函数在给定一个值时,返回不大于该值的最大整数。例如,⌊2.5⌋ = ⌊2⌋ = 2。当 a < √512 时,a2 < 512,向下取整函数的结果为零。因此,对于前 22 个字(704 字节),成本随所需内存字数呈线性增长。超过该点后,⌊a2 ÷ 512⌋ 为正数。当所需的内存足够高时,Gas 成本与内存量的平方成正比。
注意,这些因素仅影响_固有_的 Gas 成本——它没有考虑决定最终用户需要支付多少费用的费用市场或给验证者的提示——这只是在 EVM 上运行特定操作的原始成本。
9.3 执行环境
执行环境是一个元组 I,其中包含不属于区块链状态或 EVM 的信息。
| 参数 | 访问数据的操作码 | 访问数据的 Solidity 代码 |
|---|---|---|
| Ia | ADDRESS (opens in a new tab) | address(this) |
| Io | ORIGIN (opens in a new tab) | tx.origin |
| Ip | GASPRICE (opens in a new tab) | tx.gasprice |
| Id | CALLDATALOAD (opens in a new tab) 等 | msg.data |
| Is | CALLER (opens in a new tab) | msg.sender |
| Iv | CALLVALUE (opens in a new tab) | msg.value |
| Ib | CODECOPY (opens in a new tab) | address(this).code |
| IH | 区块头字段,例如 NUMBER (opens in a new tab) 和 DIFFICULTY (opens in a new tab) | block.number、block.difficulty 等 |
| Ie | 合约间调用(包括合约创建)的调用堆栈深度 | |
| Iw | EVM 是否允许更改状态,或者它是否在静态运行 |
要理解第 9 节的其余部分,还需要了解其他几个参数:
| 参数 | 定义所在章节 | 含义 |
|---|---|---|
| σ | 2(第 2 页,公式 1) | 区块链的状态 |
| g | 9.3(第 13 页) | 剩余 Gas |
| A | 6.1(第 8 页) | 累积子状态(计划在交易结束时发生的更改) |
| o | 9.3(第 13 页) | 输出——在内部交易(当一个合约调用另一个合约时)和调用视图函数(当你只是请求信息,因此不需要等待交易时)的情况下返回的结果 |
9.4 执行概述
现在我们已经掌握了所有的预备知识,终于可以开始研究 EVM 是如何工作的了。
公式 137-142 给出了运行 EVM 的初始条件:
| 符号 | 初始值 | 含义 |
|---|---|---|
| μg | g | 剩余 Gas |
| μpc | 0 | 程序计数器,下一条要执行的指令的地址 |
| μm | (0, 0, ...) | 内存,全部初始化为零 |
| μi | 0 | 使用的最高内存位置 |
| μs | () | 堆栈,初始为空 |
| μo | ∅ | 输出,为空集,直到且除非我们以带有返回数据(RETURN (opens in a new tab) 或 REVERT (opens in a new tab))或不带返回数据(STOP (opens in a new tab) 或 SELFDESTRUCT (opens in a new tab))的方式停止。 |
公式 143 告诉我们,在执行期间的每个时间点都有四种可能的情况,以及如何处理它们:
Z(σ,μ,A,I)。Z 代表一个测试操作是否创建无效状态转换的函数(参见异常停止)。如果其计算结果为 True,则新状态与旧状态相同(除了 Gas 被消耗),因为更改尚未实施。- 如果正在执行的操作码是
REVERT(opens in a new tab),则新状态与旧状态相同,会损失一些 Gas。 - 如果操作序列已完成(由
RETURN(opens in a new tab) 表示),则状态将更新为新状态。 - 如果我们不处于结束条件 1-3 之一,则继续运行。
9.4.1 机器状态
本节更详细地解释了机器状态。它指定 w 为当前操作码。如果 μpc 小于代码长度 ||Ib||,则该字节 (Ib[μpc]) 就是操作码。否则,操作码被定义为 STOP (opens 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 是指定 Gas 成本的函数。没有足够的剩余 Gas 来支付下一个操作码。
-
δw=∅ 如果为操作码弹出的项数未定义,则操作码本身未定义。
-
|| μs || < δw 堆栈下溢,堆栈中没有足够的项供当前操作码使用。
-
w = JUMP ∧ μs[0]∉D(Ib) 操作码是
JUMP(opens in a new tab),并且地址不是JUMPDEST(opens in a new tab)。跳转_仅_在目标是JUMPDEST(opens in a new tab) 时有效。 -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作码是
JUMPI(opens in a new tab),条件为真(非零)因此应该发生跳转,并且地址不是JUMPDEST(opens in a new tab)。跳转_仅_在目标是JUMPDEST(opens in a new tab) 时有效。 -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || 操作码是
RETURNDATACOPY(opens 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)的 Gas,否则你不能运行
SSTORE(opens in a new tab)。
9.4.3 跳转目标有效性
在这里,我们正式定义什么是 JUMPDEST (opens in a new tab) 操作码。我们不能仅仅寻找字节值 0x5B,因为它可能在 PUSH 内部(因此是数据而不是操作码)。
在公式 (153) 中,我们定义了一个函数 N(i,w)。第一个参数 i 是操作码的位置。第二个参数 w 是操作码本身。如果 w∈[PUSH1, PUSH32],这意味着操作码是一个 PUSH(方括号定义了包含端点的范围)。在这种情况下,下一个操作码位于 i+2+(w−PUSH1)。对于 PUSH1 (opens in a new tab),我们需要前进两个字节(PUSH 本身和一个字节的值),对于 PUSH2 (opens 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。
- 如果我们有一个不产生输出的停止操作码(
STOP(opens in a new tab) 或SELFDESTRUCT(opens in a new tab)),则返回一个大小为零字节的序列作为返回值。请注意,这与空集非常不同。此值意味着 EVM 确实停止了,只是没有可读取的返回数据。 - 如果我们有一个确实产生输出的停止操作码(
RETURN(opens in a new tab) 或REVERT(opens in a new tab)),则返回由该操作码指定的字节序列。此序列取自内存,堆栈顶部的值 (μs[0]) 是第一个字节,其后的值 (μs[1]) 是长度。
H.2 指令集
在进入 EVM 的最后一个小节 9.5 之前,让我们先看看指令本身。它们在从第 29 页开始的附录 H.2 中定义。任何未指定随该特定操作码更改的内容都应保持不变。确实发生更改的变量被指定为 <something>′。
例如,让我们看看 ADD (opens in a new tab) 操作码。
| 值 | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | 加法运算。 |
| μ′s[0] ≡ μs[0] + μs[1] |
δ 是我们从堆栈中弹出的值的数量。在这种情况下是两个,因为我们要将最上面的两个值相加。
α 是我们推回的值的数量。在这种情况下是一个,即总和。
因此,新的堆栈顶部 (μ′s[0]) 是旧的堆栈顶部 (μs[0]) 和其下方的旧值 (μs[1]) 的总和。
本文没有用“令人眼花缭乱的列表”来遍历所有操作码,而是仅解释那些引入了新内容的操作码。
| 值 | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 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 函数来执行此操作。
| 值 | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | 获取给定账户的余额。 |
| ... |
我们需要查找其余额的地址是 μs[0] mod 2160。堆栈顶部是地址,但由于地址只有 160 位,我们计算该值对 2160 取模 (opens in a new tab)。
如果 σ[μ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-2929 (opens in a new tab) 中阅读有关此主题的更多信息。
| 值 | 助记符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | 复制第 16 个堆栈项。 |
| μ′s[0] ≡ μs[15] |
请注意,要使用任何堆栈项,我们需要将其弹出,这意味着我们还需要弹出其上方的所有堆栈项。在 DUP<n> (opens in a new tab) 和 SWAP<n> (opens in a new tab) 的情况下,这意味着必须弹出然后推入多达十六个值。
9.5 执行周期
现在我们已经掌握了所有部分,终于可以理解 EVM 的执行周期是如何记录的了。
公式 (155) 指出,给定状态:
- σ(全局区块链状态)
- μ(EVM 状态)
- A(子状态,交易结束时发生的更改)
- I(执行环境)
新状态为 (σ', μ', A', I')。
公式 (156)-(158) 定义了堆栈以及由于操作码 (μs) 引起的堆栈变化。公式 (159) 是 Gas 的变化 (μg)。公式 (160) 是程序计数器的变化 (μpc)。最后,公式 (161)-(164) 指定其他参数保持不变,除非被操作码显式更改。
至此,EVM 已被完全定义。
结论
数学符号是精确的,它使得黄皮书能够指定以太坊的每一个细节。然而,它确实有一些缺点:
- 它只能被人类理解,这意味着合规性测试 (opens in a new tab)必须手动编写。
- 程序员理解计算机代码。 他们可能理解也可能不理解数学符号。
也许正是由于这些原因,较新的共识层规范 (opens in a new tab)是用 Python 编写的。有用 Python 编写的执行层规范 (opens in a new tab),但它们并不完整。直到且除非整个黄皮书也被翻译成 Python 或类似的语言,否则黄皮书将继续发挥作用,能够阅读它是有帮助的。