Ethereum: 智能合约是怎么在EVM中执行的?

本文通过一个具体的智能合约示例,详细讲解EVM(以太坊虚拟机)的完整执行流程,从字节码层面深入分析每个指令的执行过程。我们将以一个简单的存储合约为例,完整展示从合约调用到执行完成的每一个步骤,包括函数选择器的匹配机制、参数的解析过程、存储操作的Gas计算、内存管理的动态扩展、以及错误处理时的状态回滚等关键环节。通过这个深入的分析,你将能够理解EVM是如何将高级的Solidity代码转换为底层的虚拟机指令,每个指令如何影响栈、内存和存储的状态变化,以及EVM如何通过精密的Gas计量机制和状态管理系统,在保证安全性和确定性的同时,为智能合约提供高效可靠的执行环境。这不仅有助于开发者编写更优化的智能合约,也为理解区块链虚拟机的设计原理提供了宝贵的实践案例。

1. 示例合约代码

我们以一个简单的存储合约为例:

csharp 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleStorage {
    uint256 private storedNumber;
    
    function store(uint256 _number) public {
        storedNumber = _number;
    }
    
    function retrieve() public view returns (uint256) {
        return storedNumber;
    }
}

2. 合约字节码分析

当Solidity编译器处理我们的智能合约时,会生成两种不同的字节码:创建字节码和运行时字节码。创建字节码负责部署合约并初始化状态,而运行时字节码则是合约部署后实际执行的代码。

编译后的字节码(简化版):

go 复制代码
// 合约创建字节码
608060405234801561001057600080fd5b50610150806100206000396000f3fe

// 运行时字节码
608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100a1565b60405180910390f35b6100736004803603810190610068919061008d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6100a081610099565b82525050565b60006020820190506100bb60008301846100a7565b92915050565b6000602082840312156100d357600080fd5b60006100e184828501610088565b91505092915050565b6100f3816100f9565b82525050565b6000819050919050565b61010c816100f9565b811461011757600080fd5b50565b```

3. 函数选择器分析

函数选择器是EVM中函数调用路由的核心机制。当外部调用智能合约时,EVM需要知道要执行哪个函数。这个过程通过函数选择器来实现 - 它是函数签名的Keccak256哈希值的前4字节,作为函数的唯一标识符。

4. store函数执行流程详解

4.1 调用数据准备

假设我们调用 store(42)

diff 复制代码
调用数据: 0x6057361d000000000000000000000000000000000000000000000000000000000000002a
- 0x6057361d: 函数选择器
- 000...002a: 参数42的十六进制表示(32字节对齐)

4.2 EVM执行环境初始化

在执行任何智能合约代码之前,EVM需要建立一个完整的执行环境。这个过程包括创建EVM实例、初始化解释器、分配栈和内存空间、加载合约代码等步骤。每个组件都有其特定的职责,共同构成了一个安全、高效的执行环境。

4.3 详细指令执行过程

现在我们深入到字节码层面,逐条分析每个指令的执行过程。这个过程展示了EVM如何将高级的Solidity代码转换为底层的虚拟机指令,以及每个指令如何影响栈、内存和存储的状态。

让我们逐步分析store函数的字节码执行:

步骤1: 函数选择器检查

这是合约执行的第一个关键步骤。EVM需要确定调用者想要执行哪个函数,这个过程通过解析调用数据中的函数选择器来完成。同时,还需要进行一些基础的安全检查,比如验证是否发送了以太币(对于非payable函数)。

makefile 复制代码
字节码: 60 80 60 40 52 34 80 15 61 00 10 57 60 00 80 fd

指令序列分析:

  1. PUSH1 0x80 (PC=0)

    makefile 复制代码
    操作: 将0x80压入栈
    栈状态: [0x80]
    Gas消耗: 3
  2. PUSH1 0x40 (PC=2)

    makefile 复制代码
    操作: 将0x40压入栈
    栈状态: [0x40, 0x80]
    Gas消耗: 3
  3. MSTORE (PC=4)

    makefile 复制代码
    操作: 将0x80存储到内存地址0x40
    栈状态: []
    内存: 0x40位置存储0x80(自由内存指针)
    Gas消耗: 3 + 内存扩展成本
  4. CALLVALUE (PC=5)

    makefile 复制代码
    操作: 获取交易发送的以太币数量
    栈状态: [msg.value]
    Gas消耗: 2
  5. DUP1 (PC=6)

    makefile 复制代码
    操作: 复制栈顶元素
    栈状态: [msg.value, msg.value]
    Gas消耗: 3
  6. ISZERO (PC=7)

    makefile 复制代码
    操作: 检查栈顶是否为0
    栈状态: [msg.value == 0, msg.value]
    Gas消耗: 3
  7. PUSH2 0x0010 (PC=8)

    makefile 复制代码
    操作: 将跳转目标地址压入栈
    栈状态: [0x0010, msg.value == 0, msg.value]
    Gas消耗: 3
  8. JUMPI (PC=11)

    ini 复制代码
    操作: 如果msg.value == 0则跳转到0x0010
    栈状态: [msg.value]
    Gas消耗: 10

步骤2: 函数选择器匹配

在这个步骤中,EVM会从调用数据中提取函数选择器,并与合约中定义的函数进行匹配。这个过程涉及复杂的字节码操作,包括数据加载、位运算和条件跳转。理解这个过程有助于优化函数调用的Gas成本。

makefile 复制代码
字节码: 60 04 36 10 61 00 36 57 60 00 35 60 e0 1c 80 63 2e 64 ce c1 14 61 00 3b 57 80 63 60 57 36 1d 14 61 00 59 57

关键指令分析:

  1. PUSH1 0x04 + CALLDATASIZE + LT

    复制代码
    检查调用数据长度是否至少4字节(函数选择器)
  2. PUSH1 0x00 + CALLDATALOAD

    makefile 复制代码
    从调用数据偏移0处加载32字节
    结果: 0x6057361d000000000000000000000000000000000000000000000000000000000000002a
  3. PUSH1 0xe0 + SHR

    makefile 复制代码
    右移224位(28字节),提取前4字节函数选择器
    结果: 0x6057361d
  4. DUP1 + PUSH4 0x6057361d + EQ

    复制代码
    比较提取的选择器与store函数选择器
    匹配成功!

步骤3: 参数解析和存储

这是函数执行的核心步骤。EVM需要从调用数据中解析出函数参数,然后执行实际的存储操作。SSTORE指令是整个过程中最昂贵的操作,其Gas成本取决于存储状态的变化类型。这里展示了EVM如何精确计算和收取Gas费用。

详细指令执行:

  1. 参数加载

    ini 复制代码
    PUSH1 0x04        ; 参数偏移量
    CALLDATALOAD      ; 加载32字节参数
    栈状态: [42]      ; 十进制42
  2. 存储位置准备

    ini 复制代码
    PUSH1 0x00        ; 存储槽0(storedNumber变量)
    栈状态: [0, 42]
  3. 执行存储操作

    ini 复制代码
    SSTORE            ; 将42存储到槽位0
    栈状态: []
    Gas消耗: 20000 (首次存储) 或 5000 (更新存储)

4.4 Gas计算详解

Gas消耗详细分解

基础Gas成本构成

成本类别 描述 Gas消耗 备注
基础交易成本 每笔交易的固定成本 21,000 所有交易都需要支付
调用数据成本 传输调用数据的成本 368 基于数据大小计算
指令执行成本 EVM指令执行成本 ~200 根据具体指令变化
内存扩展成本 内存动态扩展成本 ~50 二次成本模型
存储操作成本 SSTORE操作成本 5,000-20,000 根据存储状态变化

调用数据成本计算 (68字节示例)

数据类型 字节数 单价 (Gas/字节) 总成本 计算公式
非零字节 8个 16 128 8 × 16 = 128
零字节 60个 4 240 60 × 4 = 240
调用数据总成本 68个 - 368 128 + 240 = 368

SSTORE操作Gas成本

存储状态变化 Gas成本 退款 净成本 使用场景
零值 → 非零值 20,000 0 20,000 首次存储数据
非零值 → 非零值 5,000 0 5,000 更新现有数据
非零值 → 零值 5,000 15,000 -10,000 删除数据(获得退款)
零值 → 零值 800 0 800 重复设置零值

总Gas消耗计算示例

makefile 复制代码
store(42) 函数调用的完整Gas计算:

基础成本:           21,000 Gas
调用数据成本:          368 Gas
指令执行成本:          200 Gas
内存扩展成本:           50 Gas
SSTORE成本:         20,000 Gas (首次存储)
─────────────────────────────────
总计:               41,618 Gas

如果是更新操作:
SSTORE成本:          5,000 Gas (更新存储)
─────────────────────────────────
总计:               26,618 Gas

Gas优化建议

优化策略 节省效果 实现方法
减少调用数据 中等 使用更短的函数名,压缩参数
批量存储操作 一次交易处理多个存储操作
存储槽打包 极高 将多个变量打包到一个存储槽
使用事件替代存储 极高 用事件记录非关键数据
删除无用数据 删除数据可获得退款

总Gas消耗计算:

makefile 复制代码
基础成本:     21000
调用数据:       368
指令执行:      ~200
内存扩展:       ~50
SSTORE:      20000 (首次) 或 5000 (更新)
------------------------
总计:        ~41618 (首次) 或 ~26618 (更新)

5. retrieve函数执行流程

与store函数不同,retrieve函数是一个view函数,它只读取数据而不修改合约状态。这种函数的执行成本相对较低,因为它不需要进行昂贵的存储写入操作,也不会触发状态变更。让我们看看它是如何工作的。

5.1 调用数据

diff 复制代码
调用数据: 0x2e64cec1
- 只有函数选择器,无参数

5.2 执行流程

5.3 关键指令执行

  1. 加载存储值

    ini 复制代码
    PUSH1 0x00       ; 存储槽0
    SLOAD            ; 加载存储值
    栈状态: [storedNumber的值]
    Gas消耗: 2100 (冷访问) 或 100 (热访问)
  2. 准备返回数据

    ini 复制代码
    PUSH1 0x40       ; 获取自由内存指针
    MLOAD            ; 加载内存指针值
    DUP1             ; 复制指针
    DUP3             ; 复制返回值
    MSTORE           ; 存储返回值到内存
  3. 返回结果

    ini 复制代码
    PUSH1 0x20       ; 返回数据长度32字节
    SWAP1            ; 交换栈顶两元素
    RETURN           ; 返回数据

6. 状态变化追踪

在智能合约执行过程中,状态的变化是核心关注点。EVM通过精确的状态管理机制,确保每次状态变更都是可追踪、可回滚的。这不仅保证了系统的一致性,也为调试和审计提供了重要支持。

6.1 存储状态变化

6.2 内存布局变化

makefile 复制代码
执行前内存布局:
0x00-0x3F: 空
0x40-0x5F: 0x80 (自由内存指针)
0x60-0x7F: 空

执行后内存布局:
0x00-0x3F: 空
0x40-0x5F: 0x80 (自由内存指针)
0x60-0x7F: 空
0x80-0x9F: 返回数据 (仅在retrieve函数中)

7. 错误处理示例

EVM的错误处理机制是其安全性和可靠性的重要保障。当执行过程中遇到异常情况时,EVM会采取相应的错误处理策略,包括状态回滚、Gas消耗、错误信息返回等。理解这些机制对于编写健壮的智能合约至关重要。

7.1 状态快照与回滚机制

在深入具体的错误处理示例之前,我们需要理解EVM的状态快照(Snapshot)机制。这是EVM实现原子性操作的核心技术,确保了"要么全部成功,要么全部失败"的执行语义。

7.1.1 快照机制的工作原理

当EVM开始执行一个合约调用时,会首先创建一个状态快照。这个快照记录了当前状态数据库的"检查点",包括所有账户的余额、存储、代码等信息的当前状态。

go 复制代码
// 在Go-Ethereum中的实现
func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) (ret []byte, leftOverGas uint64, err error) {
    // 创建状态快照
    snapshot := evm.StateDB.Snapshot()

    // 执行合约代码
    ret, err = evm.interpreter.Run(contract, input, false)

    if err != nil {
        // 发生错误时回滚到快照点
        evm.StateDB.RevertToSnapshot(snapshot)
    }

    return ret, gas, err
}

7.1.2 快照的数据结构

EVM使用日志式的快照机制,记录每个状态变更操作:

go 复制代码
type journal struct {
    entries []journalEntry    // 状态变更日志
    dirties map[common.Address]int  // 脏数据索引
}

type journalEntry interface {
    revert(*StateDB)  // 回滚操作
    dirtied() *common.Address  // 获取影响的地址
}

每种状态变更都有对应的日志条目:

  • balanceChange - 余额变更
  • storageChange - 存储变更
  • nonceChange - Nonce变更
  • codeChange - 代码变更
  • suicideChange - 合约销毁

7.1.3 回滚过程详解

当需要回滚时,EVM会按照以下步骤执行:

  1. 定位快照点:根据快照ID找到对应的日志索引位置
  2. 逆序回滚:从最新的变更开始,逆序执行回滚操作
  3. 恢复状态 :每个日志条目的revert()方法会将状态恢复到变更前
  4. 清理日志:删除快照点之后的所有日志条目
scss 复制代码
func (s *StateDB) RevertToSnapshot(revid int) {
    // 找到快照对应的日志索引
    idx := s.validRevisions[revid]

    // 逆序回滚所有变更
    for i := len(s.journal.entries) - 1; i >= idx; i-- {
        s.journal.entries[i].revert(s)
    }

    // 清理无效的日志条目
    s.journal.entries = s.journal.entries[:idx]
    s.validRevisions = s.validRevisions[:revid]
}

7.2 Gas不足错误

Gas不足是智能合约执行中最常见的错误之一。当合约尝试执行一个操作但没有足够的Gas来支付其成本时,EVM会立即停止执行并回滚所有状态变更。这种机制确保了网络的安全性,防止了无限循环和资源滥用。

7.3 调用数据不足错误

当调用数据的长度不足以包含完整的函数参数时,EVM会采用特定的处理策略。对于缺失的数据,EVM会用零值填充,这可能导致意外的行为。理解这种机制对于编写健壮的合约输入验证逻辑很重要。

markdown 复制代码
调用数据: 0x6057361d00  (缺少参数数据)

执行流程:
1. 函数选择器匹配成功
2. CALLDATALOAD 0x04 尝试加载参数
3. 调用数据长度不足,返回0值
4. 将0存储到storedNumber

8. 总结

通过这个详细的执行示例,可以对EVM有了更直观的认识。EVM最大的特点就是结果可预测,同样的代码跑出来的结果肯定一样,而且花费很透明,每个操作要花多少Gas都算得清清楚楚。它对数据管理很严格,合约的状态都存在固定的槽位里,内存用完就丢,临时数据处理完就清理掉,出错了还能回滚,把状态恢复到执行前。从性能角度来说,存储最烧钱,写数据到链上是最贵的操作,内存越用越贵,用得多了成本会飞速上涨,所以传输数据要省着点,调用时少传点数据能省Gas,不过常用数据便宜,经常访问的数据Gas费用更低。写代码的时候要记住几个要点:存储要精打细算,合理设计能省一大笔Gas;错误要提前想到,各种异常情况都要考虑;安全漏洞要防范,重入攻击这些坑要避开;代码要写得清楚,自己和别人都容易看懂。总的来说,EVM就是个既安全又高效的智能合约运行环境,掌握了它的脾气就能写出更好的合约。

相关推荐
链上罗主任3 天前
以太坊十年:智能合约与去中心化的崛起
web3·区块链·智能合约·以太坊
嘻嘻仙人13 天前
区块链之以太坊合约开发工具——Metamask钱包和Remix IDE
区块链·智能合约·以太坊·钱包
boyedu20 天前
以太坊应用开发基础:从理论到实战的完整指南
区块链·以太坊
boyedu24 天前
区块链平台以太坊核心原理
区块链·以太坊·以太坊核心原理
Cosimac25 天前
Web3.js 实战:基于 Hardhat 的以太坊投票 DApp 开发全流程
web3·以太坊
Verin2 个月前
Next.js+Wagmi+rainbowkit构建以太坊合约交互模版
前端·web3·以太坊
JackieDYH2 个月前
solidity智能合约-知识点
区块链·智能合约·以太坊
CertiK3 个月前
CertiK荣获以太坊基金会两项资助,领跑zkEVM形式化验证
web3·区块链·以太坊
倒霉男孩5 个月前
挖矿------获取以太坊测试币
区块链·以太坊·挖矿·测试币·水龙头