每个区块链开发者都应该了解的EVM内部原理 — 第1部分

登链社区

这是多部分系列文章的第一篇:“每个区块链开发者应该了解的EVM内部原理”。

本系列的目标是帮助开发者超越框架和Solidity语法,真正理解以太坊虚拟机(EVM)如何执行智能合约。我们将涵盖EVM的核心组件,gas如何在操作码级别被消耗,函数调用和回滚期间会发生什么,以及如何调试工具通常隐藏的底层问题。

在这第一篇文章中,我们将分解EVM的架构和执行环境:

1.以太坊中的gas是什么

2. 什么是智能合约

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

4.EVM架构:堆栈、内存、存储和Calldata解释

5. 例子:从源代码到字节码

以太坊中的gas是什么?

Gas是用于衡量以太坊虚拟机(EVM)中一个操作需要多少计算工作的单位。要在以太坊上执行一个交易,你必须支付一个叫做 gas 的费用。 Gas 涵盖两件事:

  • 执行交易的计算成本(例如写入存储,调用合约)
  • 将你的交易纳入区块的包含成本

EVM中的每个操作,从简单数学运算到存储数据,都会消耗特定数量的gas。 Gas 费用较高的交易会被验证者优先处理,这意味着你愿意支付的越多,你的交易就越有可能被包含在一个区块中。

Gas 费用通常以区块链的原生代币(以太坊上的 ETH)支付,并确保计算资源的价格公平,防止垃圾邮件,并保持网络安全。

下表取自以太坊黄皮书,提供了特定操作码消耗多少gas的大致概念。

image-20240930222847819.png

Gas 费用是用户为补偿以太坊网络完成的计算工作而支付的费用。就像Gas驱动汽车一样,gas驱动智能合约执行和交易。交易越复杂,需要的gas就越多。

Gas 费用还可以保护网络免受垃圾邮件和滥用,并激励开发者编写高效、优化的代码。

Gas 以 Gwei 衡量,Gwei 是 ETH 的一个很小的单位

(1 ETH = 10 亿 (1,000,000,000) Gwei)。

什么是智能合约?

智能合约是存储在区块链上的自执行程序。它在满足预定义条件时自动运行,无需中介,无需手动批准。可以将其视为强制执行各方之间协议的数字逻辑。

智能合约通过依赖代码而不是中央机构,实现参与者之间无需信任、透明的交易。在以太坊上,大多数智能合约都是用 Solidity 编写的,Solidity 是一种专门为 EVM 设计的高级语言。

EVM 确保每个合约在每个节点上以相同的方式运行,从而在整个网络中维护单一的全局状态。它定义了从一个有效的区块链状态移动到下一个状态的规则,一次一个区块。

EVM 在沙盒环境中运行智能合约,这意味着字节码在完全隔离的情况下执行,无法访问主机网络,文件系统或进程。这确保了整个网络的安全性和确定性。

合约中的每个指令都有一个预定义的 gas 成本,并且当 EVM 处理每个指令时,它会跟踪总消耗的gas。要运行这些指令,用户必须附加足够的原生货币(ETH)来支付作为 gas 费 支付的执行成本。

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

以太坊虚拟机(EVM) 是以太坊执行智能合约的核心组件。它运行称为 操作码 的低级指令,这些指令在以太坊的正式规范(黄皮书)中定义。虽然其中许多类似于传统的虚拟机指令,但以太坊还包括自定义操作码,这些操作码可以实现链上逻辑,存储访问和智能合约行为。

每个以太坊执行客户端,如 GethNethermind 等…(这些将在后面介绍)都包含其自己的 EVM 实现,但都遵循相同的核心规则。EVM 的工作是获取交易calldata和合约字节码,逐步执行它,并相应地更新以太坊的 世界状态

EVM 在以太坊中扮演两个关键角色:区块处理 和 交易执行

  • 在区块创建期间,EVM 帮助管理以太坊的全局状态,即所有账户、余额和合约存储的完整记录。
  • 在交易执行期间,EVM 通过解释称为 操作码 的低级指令来运行智能合约。这些指令被编码为 字节码,这是编译用高级语言(如 Solidity)编写的智能合约后得到的结果。

(更多例子在后面)

当用户发送交易以与合约交互时(如调用函数),EVM 会读取字节码,逐步执行指令,并在有足够的 gas 来支付计算的情况下更新状态。Gas 限制确保合约不会永远运行,并且每个操作都有成本。

EVM 作为一个 基于堆栈的虚拟机 运行,由几个重要的组件组成:

  • 堆栈:一个后进先出(LIFO)的 256 位值列表,用于在指令之间传递输入和输出。
  • 内存:在执行期间使用的临时线性内存。它在每次调用结束时重置。
  • 存储:持久的、每个合约的键值存储。每个 32 字节的槽存储诸如余额或合约状态之类的值。
  • 代码:与合约一起存储的不可变的字节码,EVM 在执行期间逐步执行它。
  • 程序计数器(PC):跟踪当前正在执行的指令。
  • Gas:每个指令都有定义的 gas 成本。这限制了可以执行的计算量,并确保网络保持安全。

其中一些组件(如堆栈和内存)仅在执行期间存在,而其他组件(如存储和代码)则跨区块持久存在。它们共同构成了以太坊上每个智能合约运行的隔离的、确定性的环境。

image-20240930222847819.png

EVM 架构(来源:EVM Illustrated

EVM架构:堆栈、内存、存储和Calldata解释

堆栈

堆栈 是 EVM 执行模型的核心部分。它是一个 易失性的,后进先出(LIFO)的结构,用于在交易执行期间在操作码之间传递数据。每个堆栈项是 256 位(32 字节),并且堆栈最多可以容纳 1024 项

它用于诸如 PUSHADDPOP 等基本操作。如果你尝试使用超过允许的堆栈空间,或者在执行后留下错误数量的项目,则交易将因堆栈错误而 回滚

堆栈无法存储诸如数组或映射之类的复杂类型,因此 内存 用于此目的。并且像 EVM 中的所有其他内容一样,堆栈操作会消耗gas

image-20240930222847819.png

EVM 架构(来源:EVM Illustrated

内存:

EVM 中的 内存 是 临时的 和 线性的。它仅在单个交易期间存在,并在之后清除。它用于存储不适合堆栈的数据,例如数组,字符串或复杂计算中的中间值。

内存以 32 字节  运行,可以使用诸如 MSTORE 和 MLOAD 之类的操作码进行访问。虽然内存成本随大小而增加,但它仍然比 存储便宜,使其成为执行期间短时间数据的首选。

image-20240930222847819.png

EVM 架构(来源:EVM Illustrated

存储:

存储 是以太坊的 持久状态。它保存着在单个交易之外存在的合约数据。它将 256 位密钥映射到 256 位值,并存储诸如余额,变量和合约状态之类的内容。

与内存和堆栈不同,存储是 非易失性的。它存储在一种称为 Merkle Patricia Trie 的特殊结构中,该结构允许以太坊有效地跟踪和验证全局状态。

存储写入的 gas 成本很高,因此仅在需要长期保存数据时才使用。

image-20240930222847819.png

EVM 架构(来源:EVM Illustrated

Calldata:

Calldata 是 EVM 中的一个 只读 数据位置,其中包含外部函数调用的输入参数。它随交易一起传递,并且在执行期间无法修改。

当交易发送到智能合约时,交易中的 data字段 告诉合约要运行哪个函数以及使用什么参数。

data 字段的前 4 个字节 称为 函数选择器。此值告诉 EVM 要调用合约中的哪个函数。

(你可以在4byte-directory中搜索著名的选择器)

如何计算函数选择器:

函数选择器是函数签名的 Keccak-256 哈希的前 4 个字节

例子:

function set(uint256 x)
  • 函数签名(作为字符串):"set(uint256)"
  • 哈希:

keccak256("set(uint256)")

→ 0x60fe47b1...

  • 前 4 个字节(8 个十六进制字符):0x60fe47b1

→ 这是函数选择器

因此,如果你在 calldata 的开头看到 0x60fe47b1,你就知道这是对 set(uint256) 的调用。

示例交易 Calldata:

假设你使用值 69420 调用此函数。

set(69420)

Calldata 看起来像:

0x60fe47b1
0000000000000000000000000000000000000000000000000000000000010f2c

分解:

  • 0x60fe47b1 → 函数选择器(set(uint256)
  • 接下来的 32 个字节:0x...010f2c → 69420,编码为带填充的 uint256

通用 Calldata 布局:

对于任何函数调用:

<4 bytes>    函数选择器(keccak256 的前 4 个字节)
<32 bytes>   参数 1(已填充)
<32 bytes>   参数 2(已填充)
...

需要了解的陷阱

  • 选择器必须_完全_匹配,否则调用将使用 fallback() 或 receive() 回滚。
  • Solidity 使用 ABI 编码,它是标准化的——多种工具(例如,ethers.js,Foundry)可以解码/编码它。
  • 当手动与合约交互时(例如,通过 eth_sendTransaction),你必须自己构建此 calldata。

Foundry 示例

cast calldata "set(uint256)" 69420
## Output:
## 0x60fe47b1000000000000000000000000000000000000000000000000000000010f2c

内存、存储和Calldata 总结示例:

contract CalldataExample {
    string public storedName;

    function setName(string calldata _name) external {
        // _name 是只读的,位于 calldata 中
        string memory tempName = _name;  // 复制到内存以便在需要时进行操作
        storedName = tempName;           // 保存到持久存储
    }
}

这里发生了什么

  • _name 通过 calldata 传入,它是只读的,无法直接更改。
  • 使用 string memory tempName = _name; 将其复制到 内存 中,以便我们可以使用它。
  • 最后,使用 storedName = tempName; 将其存储在合约的 存储 中。

例子:从源代码到字节码

当你用 Solidity 编写智能合约时,你真正创建的是一个高级蓝图,以太坊虚拟机(EVM)最终会将其作为称为 操作码 的低级指令执行。

让我们逐步了解 Solidity 代码是如何编译的,以及 EVM 如何处理它的。

pragma solidity >=0.4.16 <0.9.0;

contract MiniExample {
    uint data;

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

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

这很容易阅读,但 EVM 不理解 Solidity。它需要由 Solidity 编译器(solc)生成的 字节码。编译过程产生:

  • 包含原始字节码的 .bin 文件
  • 定义与合约交互的接口的 ABI 文件

如果合约具有构造函数,则该逻辑将捆绑到 部署字节码 中,该字节码仅运行一次。部署后,链上存储的内容称为 运行时字节码

字节码是什么样的?

上面合约的编译字节码像这样开始

6080604052348015600e575f5ffd5b506101298061001c5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c806360fe47b11460345780636d4ce63c14604c575b5f5ffd5b604a60048036038101906046919060a9565b6066565b005b6052606f565b604051605d919060dc565b60405180910390f35b805f8190555050565b5f5f54905090565b5f5ffd5b5f819050919050565b608b81607b565b81146094575f5ffd5b50565b5f8135905060a3816084565b92915050565b5f6020828403121560bb5760ba6077565b5b5f60c6848285016097565b91505092915050565b60d681607b565b82525050565b5f60208201905060ed5f83018460cf565b9291505056fea2646970667358221220ec163686bf86159ebb242a8ca38f68fe4e9bf9be12def4ec8af94737310b0c6364736f6c634300081e0033

这个十六进制字符串是合约逻辑的直接表示。EVM 将其解释为操作码列表。例如:

  • 60 → PUSH1
  • 80 → 将值 0x80 推送到堆栈上
  • 52 → MSTORE(将值存储在内存中)

所以前几个操作是:

[00]    PUSH1   80 // 将 1 字节的值 80 推送到堆栈上
[02]    PUSH1   40 // 将 1 字节的值 40 推送到堆栈上
[04]    MSTORE   // 内存存储
[05]    CALLVALUE   // 从调用中获取已存入的值
[06]    DUP1
[07]    ISZERO  // 一个条件操作码
[08]    PUSH1   0xR // 推送 2 字节
[0b]    JUMPI   // 跳转到堆栈上的另一个位置
...
...
[138]

每个操作码都是由 EVM 执行的简单指令,并且 程序计数器(PC) 逐个遍历列表。

当你发送交易时会发生什么:

智能合约调用是通过 交易 进行的,该交易包括以下字段:

{
  "to": "0x8a19ba...",
  "from": "0xf9db21...",
  "value": "0x0",
  "gasPrice": 700000,
  "gasLimit": 210000,
  "data": "0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c"
}

以下是 EVM 内部发生的事情:

  • 接收并解码交易。
  • 使用 vr 和 s 值验证签名
  • EVM 设置一个隔离的环境,其中包含新的 堆栈 和 内存 上下文。
  • 它逐条指令地执行字节码,更新堆栈、内存,并可能更新存储。
  • 如果执行成功完成,它会返回结果或更新状态。如果没有,它会回滚。

Calldata 在此流程中如何工作:

让我们看一下交易中的 data 字段:

0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c
  • 前 4 个字节:0x60fe47b1

→ 这是 函数选择器set(uint256) 的哈希

  • 剩余的 32 字节:

→ 编码后的输入:0x...010f2c,等于十进制的 69420

此数据告诉 EVM:“使用值 69420 调用 set 函数。”

在合约的字节码中,函数选择器被匹配,并且执行从正确的代码段开始。

  • 原文链接:https://medium.com/@andrey_obruchkov/what-every-blockchain-developer-should-know-about-evm-internals-part-1-83a93c618257
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~


登链社区始于 2017 年,通过构建高质量的技术内容平台和线下空间,助力开发者成为更好的 Web3 Builder。

登链社区

  • 登链社区网站 : learnblockchain.cn

  • 开发者技能认证 : decert.me

  • B站 : space.bilibili.com/581611011

  • YouTube : www.youtube.com/@upchain
登链社区