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 按以下顺序执行:
- 检查
nonce、balance是否足以支付gasLimit * gasPrice。 - 加载目标合约的
calldata和code。 - 初始化空栈、内存;读取当前
storage。 - 按 程序计数器(PC) 执行每个操作码,每步减少相应的 gas。
- 如果遇到
REVERT或 gas 耗尽 → 回滚所有状态更改,但已消费的 gas 仍被扣除。 - 如果成功 → 将新存储提交到世界状态,发出
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 代码直观得多。