EVM 如何工作?逐步解码以太坊虚拟机

1. 总览

EVM(以太坊虚拟机)是运行以太坊上所有智能合约的“CPU”。理解 EVM 如何执行字节码可以帮助你读懂交易追踪、调试 out of gas 错误,以及编写更节省 gas 的 Solidity 合约。本文从账户模型、基于栈的架构、到 gas 计算方式,附带 Solidity 示例和 Java 代码(Web3j),让你看到从字节码到执行结果的完整流程。

2. EVM 是什么

EVM 是一个基于栈的、确定性的(相同输入总是产生相同输出)虚拟机,运行在以太坊的每个节点上。每个节点都会重新执行一个区块中的所有交易,以就最终状态达成共识。

与 JVM 或普通 CPU 最大的区别是:EVM 没有线程概念、没有 I/O、无法调用外部 API。它需要的一切(数据、余额、其他合约的代码)都在世界状态中——一个存储所有账户的 Merkle Patricia Trie。

3. 账户模型

以太坊有两种账户:

  • EOA(外部拥有账户):由私钥控制,没有代码。
  • 合约账户:有 code(EVM 字节码)和 storage 存储。
Account {
  nonce:   已发送的交易数量(EOA)或创建的合约数量
  balance: ETH 余额(单位为 wei)
  storage: Merkle Trie key-value 256位(仅合约账户)
  codeHash: 字节码的哈希(仅合约账户)
}

当你发送交易到一个有代码的地址时,EVM 会加载该 code 并开始执行——这正是“虚拟机”发挥作用的时候。

4. 虚拟机架构:栈、内存、存储、调用数据

EVM 有四个主要的数据区域,每个在 gas 成本和存活时间上都不同。

4.1. 栈(Stack)

栈包含 256 位字,最多 1024 个元素。所有操作(ADD、MUL、SUB…)都从栈中取操作数,并将结果推回栈。

PUSH1 0x05   ; 栈: [5]
PUSH1 0x03   ; 栈: [5, 3]
ADD          ; 栈: [8]

上面三行操作码执行 5 + 3:两个 PUSH1 将数字 5 和 3 推入栈,ADD 取栈顶两个元素,相加,将结果 8 推回栈。

4.2. 内存(Memory)

内存是一个临时的字节数组区域,仅在一次调用的执行期间存在。用于存储大型数据,如结构体、数组,在返回前或写入存储前使用。

4.3. 存储(Storage)

存储是键值对 (uint256 -> uint256) 持久化的区域,与每个合约绑定,写入区块链的状态树。这是 gas 成本最高的区域,因为必须达成共识并永久保存。

4.4. 调用数据(Calldata)

调用数据是交易的输入数据——只读的,不像内存那样需要“分配”gas。

注意:混淆 Solidity 中的 memory 和 storage 是新手最常见的错误——不必要地声明 storage 变量会导致 gas 成本大幅增加。

5. 字节码和操作码

Solidity 编译出字节码,一个十六进制字符串。每个字节(或一个字节加操作数)对应一个操作码——EVM 直接理解的低级指令。

一个简单的合约例子,将两个数相加并保存结果:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public count;

    function increment() external {
        count += 1;
    }
}

用 solc 编译:

solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE

这段操作码从存储中读取 count(SLOAD),加 1(ADD),然后写回(SSTORE)——正确实现了 count += 1 的逻辑。

为了看清 EVM 如何将字节码解码为操作码,我写了一个简单的 Java 解码器(不需要真实节点,只是展示机制):

public class OpcodeDecoder {

    private static final Map<Integer, String> OPCODES = Map.of(
        0x00, "STOP",
        0x01, "ADD",
        0x54, "SLOAD",
        0x55, "SSTORE",
        0x60, "PUSH1"
    );

    public List<String> decode(String hexBytecode) {
        byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
        List<String> ops = new ArrayList<>();
        int i = 0;
        while (i < code.length) {
            int opcode = code[i] & 0xFF;
            String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
            ops.add(name);
            // PUSH1 后面跟 1 个字节的操作数,需要跳过
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES 将十六进制代码映射到操作码名称(完整表有 140+ 个代码)。
  • decode 逐字节读取,查找操作码名称;如果是 PUSH1 则多跳 1 个字节,因为该操作码带有操作数。
  • 结果是按 EVM 执行顺序的操作码列表。

用 JUnit 5 + AssertJ 测试:

@Test
void decode_shouldReturnPushAddSstoreSequence() {
    OpcodeDecoder decoder = new OpcodeDecoder();

    List<String> ops = decoder.decode("0x600101550054");

    assertThat(ops).containsExactly(
        "PUSH1", "ADD", "SSTORE", "SLOAD"
    );
}

这个测试证实解码器正确从原始字节码中读取操作码顺序——这正是 EVM 在运行每条指令前的第一步。

6. Gas:执行成本的度量单位

每个操作码都有一个固定的 gas 成本(根据当前 EIP),反映节点 CPU/存储的负载。交易必须带有 gasLimit;如果超过该限制,EVM 会回滚所有状态更改但仍保留已消费的 gas(防止垃圾交易)。

ADD      -> 3 gas
SLOAD    -> 2100 gas(冷) / 100 gas(热)
SSTORE   -> 20000 gas(创建) / 2900 gas(更新)
PUSH1    -> 3 gas

一个小工具来估算操作码序列的 gas:

public class GasEstimator {

    private static final Map<String, Long> COST = Map.of(
        "ADD", 3L,
        "PUSH1", 3L,
        "SLOAD", 2100L,
        "SSTORE", 20000L
    );

    public long estimate(List<String> opcodes) {
        return opcodes.stream()
            .mapToLong(op -> COST.getOrDefault(op, 0L))
            .sum();
    }
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
    GasEstimator estimator = new GasEstimator();
    List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");

    long gas = estimator.estimate(ops);

    assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}

estimate 根据价格表累加每个操作码的 gas;结果 22106 显示仅一条 count += 1 指令就消耗了大部分 gas 在 SSTORE 上——这就是为什么 Solidity 开发者总是建议减少存储写入次数。

7. EVM 中一个交易的处理生命周期

当交易到达合约时,EVM 按以下顺序执行:

  1. 检查 nonce、balance 是否足以支付 gasLimit * gasPrice。
  2. 加载目标合约的 calldata 和 code。
  3. 初始化空栈、内存;读取当前 storage。
  4. 按 程序计数器(PC) 执行每个操作码,每步减少相应的 gas。
  5. 如果遇到 REVERT 或 gas 耗尽 → 回滚所有状态更改,但已消费的 gas 仍被扣除。
  6. 如果成功 → 将新存储提交到世界状态,发出 receipt 和 logs(事件)。
交易
   │
   ▼
验证 nonce 和余额
   │
   ▼
加载代码 + 调用数据 ──► 执行操作码(PC 递增)
   │                            │
   │                      每步减少 gas
   │                            │
   ▼                            ▼
 Gas 耗尽 / REVERT        成功
   │                            │
   ▼                            ▼
回滚状态              提交状态 + 日志

8. 实战示例:使用 Web3j 从 Spring Boot 调用合约

为了真实与 EVM 交互(通过本地节点如 Ganache/Anvil 或测试网),你需要 Web3j 库:

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.11.1</version>
</dependency>

一个服务从已部署的合约读取 count 值:

@Service
public class CounterService {

    private final Web3j web3j;
    private final String contractAddress;

    public CounterService(Web3j web3j,
                           @Value("${evm.counter.address}") String contractAddress) {
        this.web3j = web3j;
        this.contractAddress = contractAddress;
    }

    public BigInteger readCount() throws IOException {
        Function function = new Function(
            "count",
            Collections.emptyList(),
            List.of(new TypeReference<Uint256>() {})
        );
        String encoded = FunctionEncoder.encode(function);

        EthCall response = web3j.ethCall(
            Transaction.createEthCallTransaction(null, contractAddress, encoded),
            DefaultBlockParameterName.LATEST
        ).send();

        List<Type> results = FunctionReturnDecoder.decode(
            response.getValue(), function.getOutputParameters()
        );
        return (BigInteger) results.get(0).getValue();
    }
}
  • Function 描述要调用的 count() 函数及返回数据类型(Uint256)。
  • FunctionEncoder.encode 将其转换为 calldata 十六进制(函数选择器 + 参数)——EVM 执行时会加载到栈的数据。
  • ethCall 发送 eth_call 请求——在只读模式下运行 EVM,不创建真实交易,不消耗 gas。
  • FunctionReturnDecoder.decode 将返回的十六进制输出解码为 BigInteger。
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

注意:eth_call 不写入状态,所以不需要实际的 gasLimit 且不返回 transactionHash。如果你需要写数据(调用 increment()),必须使用 eth_sendRawTransaction 并用私钥签名——那时才会消耗真实 gas。

9. 常见错误及何时需要谨慎

  • Gas 耗尽:循环或多次存储写入时 gasLimit 估算过低。始终在发送真实交易前用 eth_estimateGas 测试。
  • 重入漏洞:EVM 允许合约 A 调用合约 B,B 在 A 更新状态前回调 A——导致多次提取资金(The DAO 事件)。使用 checks-effects-interactions 模式或 nonReentrant 修饰符。
  • 栈太深:Solidity 编译器限制本地变量数量,因为栈只有 1024 个槽位;遇到此错误时将函数拆分更小。
  • 不应该在链上运行繁重计算(复杂哈希、大循环)——每次循环都会乘以 gas 成本,应该离链计算然后提交结果,必要时带上证明。

10. 结论

EVM 是一个指令集简洁但在 gas 和状态管理上严格的虚拟机——每个操作码都有成本,每次存储更改都是永久且昂贵的。深入理解栈/内存/存储、gas 计算方式和交易生命周期,能帮助你编写更高效的智能合约,并在交易回滚时进行调试。如果你经常与合约打交道,尝试用 evm disasm 或 Hardhat/Foundry 中的调试器真实读取操作码追踪——比单纯读 Solidity 代码直观得多。

evmethereumblockchainsmart-contractsolidity