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

本文是EVM内部原理系列文章的第三部分,主要讲解了区块链开发者应该如何利用EVM的debug工具来调试智能合约,包括如何使用Foundry、Hardhat、Tenderly等工具进行交易的追踪和调试,如何理解debug_traceCall,以及如何通过Foundry脚本来调试交易。通过学习EVM的trace,开发者可以更好地理解合约的执行过程,从而更高效地进行bug查找、gas优化和开发流程管理。

每个区块链开发者应该知道的 EVM 内部原理 — 第 3 部分

这是多部分系列文章:“每个区块链开发者应该知道的 EVM 内部原理”的第三篇也是最后一篇文章。 在这篇文章中,我们将逐步介绍 EVM 执行交易时实际发生的情况。我们将解开函数调用的内部跟踪,探索失败是如何冒泡的,并学习当出现问题时如何解释 EVM 的行为。这篇文章全部关于理解 EVM 在运行时如何处理你的代码,从堆栈移动到存储写入和 Gas 记账。

我们将探索:

1. EVM 开发者工具

2. 理解 debug_traceCall

3. 使用 Foundry 进行本地部署:从设置到合约

4. 跟踪和调试成功的交易

5. 跟踪和调试失败的交易

6. 使用 Foundry 脚本跟踪和调试交易

7. 总结:为什么理解 Traces 能让你成为更好的开发者

在本文结束时,你将知道如何读取和解释原始 EVM trace,包括堆栈移动、存储写入和失败点。这个基础将使你更有能力编写、调试和推理智能合约,达到工具和基础设施的水平。

EVM 开发者工具

在我们深入研究原始 EVM 内部原理之前,值得研究一下实际的开发者工具如何在实践中使用 traces,以及这些 traces 有助于解决哪些问题。

如今,大多数智能合约工具都依赖于 traces,以便在低级别检查交易:它们如何执行,在哪里失败,进行了哪些内部调用,以及状态如何被修改。无论你是在运行测试、调试 reverts,还是在部署后分析合约,你几乎总是依赖于幕后的 trace 数据。

一个 trace 显示:

  • 执行的每个 opcode
  • 每一步的 堆栈内存程序计数器 (PC)
  • 子调用(如外部合约调用)何时发生
  • 读取或写入了哪些 存储
  • revert 发生的位置和原因(如果发生)

让我们看看生态系统中一些最流行的工具如何使用 EVM traces:

  • Foundry 用于开发和测试智能合约的快速、CLI 原生框架。它使用 EVM traces 在测试期间模拟和分析交易,从而可以轻松捕获错误,理解失败并验证合约行为,然后再进行部署。Foundry 在本地为你提供这种可见性,而无需部署或等待链上执行。它旨在让开发者充满信心地构建并有效地进行调试,trace 数据已深度集成到测试工作流程中。网络 fork 和模拟可在本地使用。
  • Hardhat 灵活的开发环境,主要通过插件和内部开发工具使用 EVM traces。虽然默认情况下它不会直接公开 traces,但它会利用它们来增强测试反馈,从而在交易失败时显示错误消息、堆栈 traces 和调用数据。网络 fork 和模拟可在本地使用。
  • Tenderly 用于模拟和调试智能合约交易的可视化平台。它使用 EVM traces 在一个丰富的 UI 中重建执行过程 —— 显示每一步的 opcodes、堆栈值、内存、存储差异和 Gas 成本。Tenderly 支持 网络 forking,允许你在隔离的环境中针对主网或支持的测试网的快照模拟交易。虽然它不提供与 Foundry 或 Hardhat 相同的本地控制,但它通过浏览器或 API 提供了强大的模拟功能 —— 包括 测试更改、模拟账户预览 gas 使用量 的能力,而无需发送真实的交易。它对于检查实时交易行为和调试部署后复杂合约特别有用。
  • Blockscout → 这是一个与以太坊兼容链的区块浏览器。它使用 traces 显示在基本交易列表中不可见的内部合约调用、嵌套执行流程和价值转移。适用于已经挖出的交易,不提供模拟。它的作用是观察性的:帮助开发者和用户了解给定交易在确认后内部发生了什么。

所有这些工具都依赖于 EVM traces,但它们以不同的方式使用它们。

  • FoundryHardhat 将 traces 带入你的本地工作流程,使你可以控制执行、状态和模拟,从而进行快速的迭代测试。
  • Tenderly 提供了一个可视化模拟平台,支持网络 forking、账户模拟和深度 trace 检查 —— 但通过托管的 UI 或 API 而不是本地控制。
  • Blockscout 专注于 trace 可视化,而不是模拟 —— 它非常适合检查已经挖出的交易,但不允许你 fork 或测试假设。

在本文的其余部分中,我们将使用 Foundry,它在本地、可编写脚本的环境中为我们提供了完整的 trace 可见性,非常适合理解 EVM 的实际工作方式。

理解 debug_traceCall

要检查 EVM 实际如何执行交易,我们依赖于 debug 命名空间下的特殊 JSON-RPC 方法。这些方法不是标准以太坊 JSON-RPC API 的一部分,它们通常仅在 完整节点(如 Geth 或 Erigon,将在后面介绍)或本地开发节点(如 Foundry 使用的 Anvil)上可用。

两个最有用的方法是:

  • debug_traceTransaction —— 通过哈希跟踪已挖出的交易
  • debug_traceCall —— 模拟交易而不发送它,并返回完整的 trace

debug_traceCall : 它的作用

此 RPC 调用使我们可以模拟交易在给定状态下的执行方式,并为我们提供每个 EVM 步骤的详细 trace,包括:

  • 在每个程序计数器(pc)处执行的 opcode
  • 堆栈内存存储访问
  • 深度 (对于内部调用)
  • 每个步骤使用的 Gas

这是我们将用来详细跟踪交易的工具,而无需实际挖掘它或修改区块链状态。

为什么 debug_traceCall 对开发者有用

与仅为你提供返回值的 eth_call(将在后面介绍)不同,debug_traceCall 告诉你 返回值是如何计算的 以及 EVM 在内部执行的操作。这使其对于以下方面非常宝贵:

  • 调试 reverts 并准确了解它们发生的位置
  • 在 opcode 级别分析 gas 使用情况
  • 构建需要了解底层行为的 测试框架索引器浏览器
  • 学习 EVM 如何处理 calldata、堆栈操作和存储更改

使用 Foundry 进行本地部署:从设置到合约

现在让我们使用 Foundry 跟踪一个真实的交易,其中包含一个稍微复杂的结构,包括 uint256 和动态 string。这将展示 EVM 不仅处理 calldata 解码和存储写入,还处理动态内存引用和编码偏移量。

pragma solidity ^0.8.12;

contract Storage {
    struct my_storage_struct {
        uint256 number;
        string owner;
    }

    my_storage_struct my_storage;

    function store(my_storage_struct calldata new_storage) public {
        if (new_storage.number > 100) {
            revert("Number too large");
        }
        my_storage = new_storage;
    }

    function retrieve() public view returns (my_storage_struct memory){
        return my_storage;
    }
}

在我们可以跟踪任何内容之前,让我们从零开始设置 Foundry,部署合约并在本地模拟交易。

本演练假定你具有基本的命令行经验并已安装 git

步骤 1:安装 Foundry

Foundry 提供了一个快速的 CLI,用于测试、部署智能合约并与之交互。使用以下命令安装它:

curl -L https://foundry.paradigm.xyz | bash
foundryup

这将安装 forge(测试运行器)、cast(交易 + RPC CLI)和 anvil(本地 EVM 节点)

forge --version
cast --version
anvil --version

步骤 2:初始化一个新项目

mkdir evm-trace-demo
cd evm-trace-demo
forge init

这为你提供了:

  • 用于合约的 src/ 文件夹
  • 用于测试的 test/ 文件夹
  • 用于 solidity 脚本的 script/ 文件夹
  • foundry.toml 配置文件

步骤 3:编写合约

src/Storage.sol 中,粘贴上面的合约。

步骤 4:编译合约

运行 forge build。你应该看不到任何错误,并且会生成一个 out/Storage.sol/Storage.json ABI 工件。

步骤 5:启动本地节点

通过运行以下命令启动 Foundry 的本地 EVM Anvil:

anvil

这将启动一个本地的、与 fork 兼容的链,并为你提供 10 个预先出资的测试帐户,它们的私钥以相同的顺序列出。

注意 记下打印的第一个私钥和地址,我们将使用它们进行部署

并且你会看到我们新 fork 的链正在 http://localhost:8545 上运行

步骤 6:部署合约

使用 forge 部署合约:

forge create src/Storage.sol:Storage \
  --rpc-url http://localhost:8545 \
  --private-key <your-key> \
  --broadcast

输出应如下所示:

[⠊] Compiling...
[⠒] Compiling 1 files with Solc 0.8.30
[⠢] Solc 0.8.30 finished in 37.11ms
Compiler run successful!
Deployer: <your-address>
Deployed to: <the-address-of-the-smart-contract>
Transaction hash: <some-tx-hash>

注意: <your-key> 是上一个终端中打印的密钥之一。

没有 --braodcast forge 将只进行 dry-run,而不会部署在 Anvil 上。

这完成了你的本地设置。此时,你拥有:

  • 已编译并部署的合约
  • 正在运行的本地节点
  • 对交易、存储和跟踪的完全控制

接下来,我们将模拟对 store() 函数的调用,并逐步探索 EVM 如何处理该调用。

跟踪成功的交易

让我们将所有理论付诸实践。在本节中,我们将为我们的 store((uint256,string)) 函数编写一个测试,使用 Foundry 运行它,检查 trace 输出,并了解如何使用 CLI 和原始 trace 数据确认交易成功。

编写测试

我们将从创建一个使用有效输入调用该函数的测试开始。在部署合约并运行本地节点后,让我们模拟一个成功存储数据的交易,并跟踪 EVM 在运行时如何精确地处理它。我们将使用 Foundry 的 cast CLI 对调用进行编码,将其发送到我们的本地节点,并使用 debug_traceCall 检查执行的每个步骤。

让我们将此测试添加到 test/Storage.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;

import "forge-std/Test.sol";
import "../src/Storage.sol";

contract StorageTest is Test {
    Storage public storageContract;

    function setUp() public {
        storageContract = new Storage();
    }

    function testStoreStruct() public {
        Storage.my_storage_struct memory input = Storage.my_storage_struct({
            number: 25,
            owner: "bob"
        });

        storageContract.store(input);
    }
}

注意: setUp() 函数是一种特殊的 Foundry 方法,它在每次测试之抢跑。在这里,它部署了一个新的 Storage 合约实例。

使用以下命令在基本视图中运行测试:

forge test --match-test testStoreStruct -vvvvv

预期的输出将是:

使用 --debug 运行以查看 EVM Trace

这是 EVM 的交互式原始执行流程。它显示:

  • 堆栈和内存指令
  • 输入解码
  • 存储写入
  • 最终退出

应该看起来像这样:

从 Trace 确认成功

如果我们只能访问原始 trace opcodes,我们仍然可以验证调用的成功:

我们的 store() 函数采用一个结构 (uint256 number, string owner)。让我们分别使用值 25"bob" 调用它。

正如我们从之前的系列中记得的那样,我们需要编码我们的合约方法和参数,让我们探索如何使用 Anvil 完成:

 cast calldata "store((uint256,string))" "(25,"bob")"

// Output should be: 0xddd356b30000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000

现在当我们有了编码的数据,我们将模拟智能合约 store 方法:

cast rpc debug_traceCall \
  '{"to":"<your-contract-address>", "data":"0xddd356b30000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000"}' \
  latest | jq '.' > trace.json

注意: 确保你的系统上已安装 jq ,以便漂亮地打印 trace 输出。

你将看到这样的输出:

{
  "failed": false,
  "gas": 52080,
  "returnValue": "",
  ...
}
  1. 检查 failed: false
  2. 查找最终 Opcode:STOPRETURN

这意味着该函数已干净地退出。

跟踪失败的交易

在这个智能合约示例中,大于 100 的数字将导致智能合约失败。因此,如果我们调用 store((101, "too much")),它应该 revert。

添加失败测试

使用另一个测试方法更新测试文件:test/Storage.t.sol

function testStoreStructReverts() public {
    Storage.my_storage_struct memory input = Storage.my_storage_struct({
        number: 101,
        owner: "too much"
    });

    storageContract.store(input);
}

在不期望 Revert 的情况下运行它

forge test --match-test testStoreStructReverts

输出将是:

为了使测试通过(并且仍然跟踪它),请使用 Foundry 的 作弊码

function testStoreStructReverts() public {
    Storage.my_storage_struct memory input = Storage.my_storage_struct({
        number: 101,
        owner: "too much"
    });

    vm.expectRevert("Number too large");
    storageContract.store(input);
}

输出将是:

可以在 debug 模式下运行此程序以逐步查看执行情况

从原始 Trace 确认失败

执行与之前显示的数字 101 相同的过程,并将此文件保存到 trace_fail.json 将输出下一个文件:

{
  "failed": true,
  "gas": 23190,
  "returnValue": "08c379a0..."  // ABI 编码的错误字符串
  ...
}
  1. 检查 failed: true
  2. 查找最终 Opcode:REVERT

这意味着该函数失败了。

使用 Foundry 脚本跟踪和调试交易

在我们之前的测试中,我们使用了 forge test --debug 来跟踪交易。虽然这可以让你逐步执行测试函数,但它不会像我们要检查的 store() 函数那样逐步执行合约逻辑本身

可以通过 forge testforge scriptcast run 访问 Foundry 的调试器。在本节中,我们将重点介绍 forge script,它允许你逐步执行内部合约逻辑,否则这些逻辑将隐藏在测试包装器中。

什么是 Forge 脚本

在 Foundry 中,Forge 脚本 是一种使用 Solidity 本身编写部署或交互逻辑的方式,而不是使用诸如 forge create 之类的 CLI 命令或诸如 Hardhat 之类的外部 JavaScript 工具。

你可以将其视为:

一个小型 Solidity 程序,可让你部署合约、调用函数或模拟交易,所有这些都在 run() 函数中完成。

默认情况下,通过调用名为 run 的函数来执行脚本

它类似于你使用其他语言编写的脚本,但你不是用 JavaScript 或 Python 编写它们,而是直接用 Solidity 编写它们。

调试脚本

当你使用脚本时,Foundry 会像完整交易一样执行你的代码。这意味着每个函数调用(包括你的合约逻辑)都是顶级执行的一部分,你可以按 opcode 逐步执行它。

让我们编写一个脚本并将其保存在 script/DebugStore.s.sol

pragma solidity ^0.8.12;

import "forge-std/Script.sol";
import "../src/Storage.sol";

contract DebugStore is Script {
    function run() external {
        Storage s = new Storage();
        s.store(Storage.my_storage_struct({ number: 101, owner: "bob" }));
    }
}

要调试该脚本,我们将在终端中运行:

forge script script/DebugStore.s.sol:DebugStore \
  --fork-url http://localhost:8545 \
  --debug

注意:确保 anvil fork 在不同的终端中运行并公开 url:http://localhost:8545

你现在将进入一个交互式 opcode 调试器,你可以在其中:

  • 使用 ↑ 和 ↓ 逐步执行每个 EVM 指令
  • 准确了解 store() 在后台的工作方式
  • 观看 calldata 解析 ( CALLDATALOAD)、条件检查 ( GT) 和存储写入 ( SSTORE)

示例:

在这里,我们可以看到内部合约将 revert,因为我们传递的数字 > 100

注意:我们可以在调用 store 方法之前像之前在测试中所做的那样添加 vm.expectRevert(...)

总结:为什么理解 Traces 能让你成为更好的开发者

学习如何读取 EVM traces 可能感觉很底层,但它会很快带来回报。

  • Bug 搜寻:Traces 准确地显示了哪个条件失败、revert 发生在何处以及存储是否已更改。
  • Gas 优化:你可以查明哪些操作会消耗 gas(SSTORECALLDATACOPY 等),并了解编译器在幕后生成的内容。
  • 工具和开发工作流程:Traces 帮助你了解合约在测试、部署和交互过程中的行为,从而使调试和验证成为开发循环的自然组成部分。

掌握 EVM traces 可以为你提供巨大的优势。你不再猜测你的合约做了什么,而是像 EVM 一样,逐条指令地观察它的展开。

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

0 条评论

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