以太坊虚拟机(EVM)是如何工作的?对EVM架构和操作码的深入探讨

  • QuickNode
  • 发布于 2024-08-29 14:13
  • 阅读 13

本文深入探讨了以太坊虚拟机(EVM)的架构和操作,解释了EVM的基本组件(如堆栈、内存、存储等),字节码如何被编译为操作码,以及交易的执行过程。通过详细的步骤和示例,读者能够更好地理解EVM的工作原理和智能合约的执行机制。

tip

以太坊 RPC 客户端返回十六进制数据,对于人类来说,这可能难以解读而不进行解码。如果你想更快速地理解你的区块链 RPC 响应,可以查看 QuickNode 市场上的 Translate Transaction 插件,它将 RPC 调用中的十六进制值(如 debug_traceTransaction)翻译为更人类可读的格式。

概述

以太坊每天结算数十亿美元的价值(来源),构建智能合约的开发者需要确保他们的智能合约如预期般运作。随着智能合约复杂性的增加,这可能需要开发者深入到不仅仅是 Solidity 代码,还需要理解 EVM 如何运作。本指南将为你提供对如何执行字节码(操作码)更深入的理解,特别是深入不同的 EVM 组件并逐步展示合约如何被编译成字节码以及如何在 EVM 上执行交易。

让我们开始吧!

你需要什么

对 EVM 和以太坊智能合约的基本理解。推荐阅读:

你将要做什么

  • 了解 EVM 的内部组件(堆栈、内存、存储等)
  • 学习操作码及其如何在 EVM 上执行

什么是虚拟机(VM)?

让我们简要回顾虚拟机(VM)及其与以太坊虚拟机(EVM)的关系。虚拟机是一种模拟物理计算机及其相关硬件规范的软件。虚拟机通常通过像 AWS 和 GCP 等平台访问,软件(例如 Ubuntu、Linux)和硬件(例如 x64、arm)将根据用户的使用情况而有所不同。虚拟机的好处包括用户在全球任意地方都可以模拟物理机器,而无需实际查看或处理物理硬件。

VM 的最低级别的操作是通过机器代码(以 0 和 1 的二进制表示)完成的,这些代码由 操作码ADDPUSHPOP 等表示,执行不同类型的操作(算术、条件等)。如今大多数开发者并不直接使用这些操作码,但使用像 汇编语言 的其他人对此比较熟悉。

什么是 EVM(以太坊虚拟机)?

以太坊虚拟机(EVM)是一个为以太坊区块链设计的虚拟机,执行其 黄皮书(第 28 页)中定义的操作码。许多这些操作码与你在虚拟机上看到的类似,但存在一些以太坊特有的操作码(稍后我们将介绍),它们允许智能合约的环境和运行时。

EVM 包含在以太坊执行客户端中(即,GethNethermind 和 Reth,它们都拥有自己的 EVM 实现),其角色是执行 EVM 代码和交易调用数据,然后更新以太坊的世界状态(Storage,稍后我们将介绍)。EVM 使用一个 基于堆栈 的机器架构,由以下组件组成:

  • 堆栈:一个用于保存智能合约指令输入和输出的 32 字节项列表
  • 内存:在合约执行期间使用的临时数据
  • 存储:将 32 字节槽映射到 32 字节值,包含如 noncebalancecode hashstorage hash 这样的键
  • EVM 代码:存储在代码中的内容,作为合约账户状态字段的一部分是持久的
  • 程序计数器(PC):指向指示 EVM 应该执行的下一个操作码指令的指针
  • Gas:每个操作码都需要支付预定的执行费用,以一定量的 gas(以 wei 为单位)计价

这些组件中的一些是易失性的,仅在交易运行时可用,而其他一些则是非易失性和不可变的,用于存储和跟踪状态变化。下图演示了这种架构。

EVM

EVM 架构(来源:Ethereum.org

以太坊状态转换函数

重要的是要注意,所有开源以太坊客户端软件必须遵循以太坊规范(如黄皮书中定义),确保在每个客户端上每次交易执行(在相同状态下)产生相同的结果。这是 EVM 的确定性输出特性,允许交易的可预测性,并源自以太坊的状态转换函数 (Y(S, T) = S'),其中给定旧状态(S) + 一组新的状态输入(T) = 你得到(S')(新状态)。在现实世界的例子中,这类似于你有一个区块 n(此时是最新区块),然后提议区块 n+1(带有状态变化的交易列表),当区块 n+1 被挖掘(成为最新区块)时,你将得到包括旧状态 + 任何新状态变化的新状态。

在下一部分中,我们将深入了解组成 EVM 的内部组件。

堆栈

堆栈使 EVM 能够执行代码和交易指令,最终更新以太坊的世界状态。堆栈是 EVM 的易失性机器状态的一部分,因此其操作仅在交易执行期间持久。堆栈在操作和计算时也有限制,因此任何超出限制的操作将导致堆栈错误,导致交易回滚。在大小上,堆栈的元素总数限制为 1024 个,使用字(255 位、32 字节的数据块)。堆栈不能存储数组、字符串或映射等值,因此可以使用 内存 等其他组件在交易执行期间进行引用。开发者在处理堆栈时遇到的常见错误是 堆栈溢出/下溢gas 不足。当堆栈上的项数量不正确或没有足够的 gas 来完成计算时,会导致堆栈出现异常。

堆栈设计为先进后出(LIFO)数据格式,通过将值从堆栈弹出、执行字节码并再将其推回堆栈来执行不同的字节码(操作码)。堆栈的基本操作包括操作码指令,如 PUSH1ADDMULPOP(等更多),所有这些都依赖堆栈来执行。堆栈还可以访问虚拟机的其他部分,如内存和存储(即,世界状态),这涉及的操作码如 MSTORESSTORE,后者我们稍后将介绍。

堆栈

来源:Ethereum illustrated

内存

EVM 上的内存是线性和易失性的,这意味着这些数据在交易之间不是持久的,仅在交易运行时有效。虽然运行时存储内存的成本随着大小的增加成平方增长,但仍然比存储便宜,后者会持久保存状态。内存限制为 256 位(32 字节)“字”(也称为一些数据块),可以通过操作码如 MSTOREMLOAD (及其他)进行访问。内存允许你进行超过 32 字节的堆栈操作,但仍然限制在交易可以承受的最大 gas 计算上。如前所述,内存通常用于存储无法存储在堆栈中的值,例如数组和字符串。

内存

来源:Ethereum illustrated

存储

以太坊的世界状态(即存储)是持久的,与堆栈和内存这两种易失性组件不同。存储由 256 位到 256 位的值的密钥存储组成,包含一个账户(智能合约)的不同状态,如 balancenoncestorage hashcode hash。外部拥有帐户(EOA;例如,非托管钱包)仅包含余额和 nonce 的密钥存储。存储使用修改后的 Merkle Patricia Trie 数据结构 来通过哈希存储账户(EOA 和智能合约),将存储的数据减少为一个单一的根哈希。这个哈希表示账户地址和账户状态之间的映射(例如,余额、nonce、存储哈希、代码哈希)。

存储

EVM 的 存储 组件使状态能够持久且可修改,支持更新账户的代币余额等用例。可以通过消息调用访问存储;例如,使用 eth_call 方法,这是以太坊实现,如 Geth 上的 RPC 方法之一。以太坊上存储的费用通过交易执行支付,包括对验证者的费用(这可能会根据网络活动而有所变化)和每个操作码的预定 gas 成本。由于以太坊上的存储可能会变得昂贵,因此其他人会寻找解决方案,如 IPFS 和 AWS 来存储数据(例如,NFT 元数据)。查看我们的 IPFS 指南,了解如何通过界面或 API 在 IPFS 网络上固定数据。

来源:Ethereum illustrated

info

可以部署(存储)到以太坊的最大合约大小为 24,576 字节(24KB);但是,这不应与可以在智能合约上存储的数据限制混淆(这些仅受 gas 而非大小的限制)。

EVM 代码

智能合约是用如 Solidity 和 Vyper 的高级语言编写的,最终被编译成字节码(EVM 代码)。这个字节码(智能合约指令)存储在 存储(在账户状态字段中)并可以被其他智能合约和 EOA 访问以读取或与之互动。

程序计数器(PC)

利用已编译的 EVM 代码中的智能合约指令,程序计数器 跟踪接下来要执行的 EVM 指令(操作码),确保操作按照正确的顺序完成。

例如,如果智能合约的字节码长度为 20 字节,你应该期望有 20 行,每一行都是一个操作码指令。你可以在像 https://evm.codes/ 的网站上直观地查看这看起来如何。

evm.codes 上的程序计数器

Gas

Gas 可以看作是利用 EVM 执行交易所需的Gas。它是一种测量执行操作所需计算努力的单位。每个被 EVM 执行的操作码消耗一定数量的 gas,这是由以太坊黄皮书预定的。Gas 的实施防止了垃圾邮件(DDoS)并确保资源的有效使用。

现在我们已经接触到 EVM 的组件,接下来让我们将这些与操作码结合起来。

什么是 EVM 操作码?

字节码,如在合约部署或交易调用数据中所见,转化为不同的 EVM 操作码,并指示 EVM 组件(如堆栈、内存、存储)要做什么。

例如,以下是你在虚拟机中类似也会看到的一些较常见的操作码,这些操作码也在 EVM 中实现:

  • 算术操作码:ADDMULDIV(等更多) 允许计算算术操作
  • 控制流操作码:JUMPJUMPI(等更多)实现条件和非条件分支
  • 内存和存储管理操作码 MLOADSLOADSSTORE(等更多):根据指令不同,有不同的操作码,例如 SSTORESLOADMSTOREMLOAD
  • 以及其他一些 更多

上面列出的操作码在其他机器(如 x64)中常见,但以太坊引入了针对以太坊环境的特定新操作码:

  • CREATE:该操作码根据提供的代码创建一个新的智能合约。这需要更高的 gas 量,并且会根据所部署的字节码而变化。
  • BLOCKHASH:该操作码是最近 256 个完整区块之一的哈希
  • BALANCE:返回给定账户的余额
  • CALL:由以太坊的 RPC 方法执行的一项系统操作,包括对账户的消息调用(读取操作)
  • 以及其他一些 更多(以太坊操作码的详细列表)

每个操作码的执行都有一些 gas 相关联,且该 gas 的成本根据正在执行的操作而有所不同。例如,写入合约存储的成本(即 SSTORE)高于其他复杂性较低的操作码(即 ADD)。

EVM 操作码的完整列表可以在 以太坊黄皮书 中找到。在下一部分中,我们将介绍如何将高级智能合约编译为字节码。

从源代码到字节码

让我们走过一个 Solidity 智能合约的编译过程,该过程将其编译为字节码,以及如何将该字节码转化为操作码。

例如,假设我们有一个简单的 Solidity 存储合约如下:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract SimpleStorage {
    uint storedData;

    function set(uint x) public {
        storedData = x;
    }

    function get() public view returns (uint) {
        return storedData;
    }
}

为了以 EVM 可读格式编译此代码,编译器从 .sol(Solidity)文件中获取代码,并生成 .bin(二进制)和 abi 文件,这些文件包含编译后的字节码和智能合约接口。以上合约没有构造函数参数,但如果有,这个一次性的初始化字节码将被添加到合约部署交易中的调用数据。这段字节码与存储在合约存储区的字节码(运行时字节码)不同,因为它还包含构造函数字节码。

表示此编译合约的十六进制字节码将如下所示:

6080604052348015600e575f80fd5b506101438061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c806360fe47b1146100385780636d4ce63c14610054575b5f80fd5b610052600480360381019061004d91906100ba565b610072565b005b61005c61007b565b60405161006991906100f4565b60405180910390f35b805f8190555050565b5f8054905090565b5f80fd5b5f819050919050565b61009981610087565b81146100a3575f80fd5b50565b5f813590506100b481610090565b92915050565b5f602082840312156100cf576100ce610083565b5b5f6100dc848285016100a6565b91505092915050565b6100ee81610087565b82525050565b5f6020820190506101075f8301846100e5565b9291505056fea2646970667358221220fe2a712e6758ca6e067fd552b99e33f169a13afa9b0c54fdd2e92518f3aa766764736f6c63430008190033

注意:确切的字节码输出可能会因编译器版本和编译优化设置的不同而略有变化。

这段编译字节码包含所有智能合约逻辑,包括其功能、条件、循环和状态修改。当 EOA 或智能合约与此合约互动时,这段字节码由 EVM 解读为操作码指令;例如,第一个字节(60)转化为第一个操作码指令。你可以在此查找操作码:https://ethervm.io/,发现 60 转化为 PUSH1,它将下一个 1 字节的值推入堆栈(值为 80)。因此,程序计数器(PC)在一个数组/列表中跟踪每个操作码,以便堆栈知道接下来要执行什么。因此,第一个操作码指令转换为堆栈上的 PUSH 80

上述字节码转化为以下 Opcode 指令,表示 EVM 在执行此合约时将执行的操作顺序。

[00]    PUSH1   80 // 将 1 字节的值 80 推入堆栈
[02]    PUSH1   40 // 将 1 字节的值 40 推入堆栈
[04]    MSTORE   // 内存存储
[05]    CALLVALUE   // 获取调用的存入值
[06]    DUP1
[07]    ISZERO  // 条件操作码
[08]    PUSH2   0010 // 推送 2 字节
[0b]    JUMPI   // 跳转到堆栈上的另一个位置
[0c]    PUSH1   00
[0e]    DUP1    // 复制 1 字节
[0f]    REVERT  // 停止执行
[10]    JUMPDEST
...
...
[138]

你可以将以上 Solidity 合约复制/粘贴到 evm.codes 中查看字节码和完整的操作码指令。

在下一部分中,让我们介绍交易生命周期以及如何解释交易调用数据以及 EVM 如何执行它。

理解 EVM 交易执行

交易包括多个字段,如 noncegas pricegas limitto(接收方地址)、value(要发送的以太金额)和 data(调用数据)。调用数据是与智能合约互动的一个关键部分,因为数据经过编码,包含调用的函数及其输入参数。

现在,高层次来看,包括与智能合约交互的交易如下所示:

to: 0x6b175474e89094c44da98b954eedeac495271d0f
from: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
value: 0x0
data: 0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c
gasPrice: 500000
gasLimit: 210000

EVM 执行此交易的过程将遵循以下流程:

  1. EVM 检查交易对象的哈希负载(RLP),解码如接收者、值和负载等值
  2. 然后通过查看交易的 v,r,s 值(即 v 是恢复 ID,rs 是 ECDSA 签名的输出。更多信息 这里),确保 nonce签名 是有效的,确保签名与发送账户所有者相关联
  3. 然后 EVM 创建一个空的内存空间和堆栈上下文以进行堆栈操作
  4. EVM 然后按照程序计数器的指示执行字节码中的每个操作码指令,逐行序列化执行每个操作码指令,然后将结果存储在世界状态中

请注意,EVM 还可以返回日志,这是 EVM 的严格写入空间,输出日志。

让我们深入了解一下这个有效负载是如何格式化的。

当与智能合约互动时,数据的前四个字节指定函数标识符(函数签名的哈希),其余数据表示编码的参数。上述示例值演示了对存储合约的 set 方法的调用,其在十六进制负载中编码如下:

0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c

示例中的前四个字节 60fe47b1(前面附加了 0x)表示 set() 函数的函数选择器。剩余字节码与其参数(uint;无符号整数) 32 字节(淘汰的填充程度) 0000000000000000000000000000000000000000000000000000000000010f2c 相对应,转换为某个 uint 数量。如果你回到合约的完整字节码并搜索函数选择器(60fe47b1),你会在操作码指令中看到它,并对应 EVM 执行 PUSH4(推送 4 字节)操作码。

最后思考

这就是本指南的所有内容,如果你想深入了解更多 EVM 操作码的内容,请在下面留言提出反馈以便进行下一部分!同时请查看资源列表。

Discord 给我们留言,或者在 Twitter 上关注我们,以便随时了解最新信息!

其他资源

查看以下资源,以加强你对以太坊和 EVM 字节码的理解。

我们 ❤️ 反馈!

告诉我们 如果你有任何反馈或新专题请求。我们非常乐意听到你的声音。

  • 原文链接: quicknode.com/guides/eth...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。