Solidity 代码在部署到以太坊之前,会编译为 EVM 字节码。链上运行的不是 Solidity 源码,而是这些字节码。理解字节码和 EVM 执行模型,对于 gas 优化、安全审计、合约调试和前端交互都至关重要。本文从 EVM 架构入手,逐步拆解从 Solidity 到字节码到操作码的完整链路。
EVM 架构概述
以太坊虚拟机(EVM)是一个基于栈的虚拟机,具有以下核心特征:
- 栈深度:1024 个槽位,每个 256-bit(32 字节)
- 字长:256-bit(32 字节),所有计算都在 256-bit 整数上进行
- 存储:每个合约有独立的持久化存储空间,256-bit key → 256-bit value
- 内存:临时内存,交易执行后销毁,按 32 字节 word 寻址
- Gas:每条操作码有固定的 Gas 消耗,防止无限循环
EVM 的计算模型
┌──────────────────────────────┐
│ Calldata │ ← 输入数据(只读)
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Stack │ ← 1024 × 256-bit
│ (LIFO, max depth 16) │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Memory │ ← 临时可读写
│ (byte-addressable) │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Storage │ ← 持久化
│ (256-bit key → 256-bit val) │
└──────────────────────────────┘
EVM 不是图灵完备的——它的"图灵完备"是通过跳转指令实现的有限循环。Gas 机制保证了计算必然终止。
操作码分类与功能
EVM 共有约 140 个操作码,分为以下几类:
算术运算
ADD 0x01 // 栈顶两元素相加
SUB 0x03 // 减法
MUL 0x02 // 乘法
DIV 0x04 // 除法
MOD 0x06 // 取模
ADDMOD 0x08 // 模加法
MULMOD 0x09 // 模乘法
EXP 0x0A // 指数运算
SIGNEXTEND 0x0B // 符号扩展
比较与位运算
LT 0x10 // 小于
GT 0x11 // 大于
EQ 0x14 // 等于
ISZERO 0x15 // 是否为零
AND 0x16 // 按位与
OR 0x17 // 按位或
XOR 0x18 // 按位异或
NOT 0x19 // 按位取反
SHL 0x1B // 左移
SHR 0x1C // 右移
栈操作
POP 0x50 // 弹出栈顶
PUSH0 0x5F // 压入 0(EIP-3855,上海升级)
PUSH1-PUSH32 0x60-0x7F // 压入 1-32 字节
DUP1-DUP16 0x80-0x8F // 复制栈中元素
SWAP1-SWAP16 0x90-0x9F // 交换栈中元素
内存与存储
MLOAD 0x51 // 从内存读取 32 字节
MSTORE 0x52 // 向内存写入 32 字节
MSTORE8 0x53 // 向内存写入 1 字节
SLOAD 0x54 // 从存储读取
SSTORE 0x55 // 向存储写入
控制流
JUMP 0x56 // 无条件跳转
JUMPI 0x57 // 条件跳转
PC 0x58 // 获取当前程序计数器
MSIZE 0x59 // 获取内存大小
GAS 0x5A // 获取剩余 Gas
JUMPDEST 0x5B // 跳转目标标记
环境信息
ADDRESS 0x30 // 当前合约地址
BALANCE 0x31 // 地址余额
CALLER 0x33 // 调用者地址(msg.sender)
CALLVALUE 0x34 // 发送的 ETH(msg.value)
CALLDATALOAD 0x35 // 从 calldata 读取
CALLDATASIZE 0x36 // calldata 长度
CALLDATACOPY 0x37 // 复制 calldata 到内存
CODESIZE 0x38 // 代码大小
CODECOPY 0x39 // 复制代码到内存
SELFBALANCE 0x47 // 合约自身余额
CHAINID 0x46 // 链 ID
调用与创建
CALL 0xF1 // 调用另一个合约
CALLCODE 0xF2 // 调用代码(已不推荐)
DELEGATECALL 0xF4 // 委托调用
STATICCALL 0xFA // 静态调用(不修改状态)
CREATE 0xF0 // 创建合约
CREATE2 0xF5 // CREATE2 创建合约
Gas 计算模型
每个操作码都有 Gas 消耗,分为两类:
- 静态 Gas:操作码本身的固定费用
- 动态 Gas:取决于操作参数的额外费用
常见操作码的 Gas 消耗
ADD/SUB/MUL/DIV 3 Gas (算术运算)
LT/GT/EQ/ISZERO 3 Gas (比较运算)
AND/OR/XOR/NOT 3 Gas (位运算)
PUSH1-PUSH32 3 Gas (压栈)
DUP1-DUP16 3 Gas (复制)
SWAP1-SWAP16 3 Gas (交换)
MLOAD/MSTORE 3 Gas (内存读写)
SLOAD 2100 Gas (存储读取,冷)
SLOAD (warm) 100 Gas (存储读取,热)
SSTORE (cold, from zero to non-zero) 22100 Gas
SSTORE (warm) 100 Gas (存储写入,热)
BALANCE 700 Gas (冷) / 100 Gas (热)
CALL 2600 Gas (冷) / 100 Gas (热)
CREATE/CREATE2 32000 Gas (合约创建)
存储槽的冷热模型
EIP-2929 引入了访问列表(Access List)机制,将存储槽和地址分为"冷"和"热":
- 冷访问:交易中首次访问,Gas 较高
- 热访问:同一交易中已访问过,Gas 较低
// 首次 SLOAD:2100 Gas(冷)
// 后续 SLOAD:100 Gas(热)
// 首次 SSTORE(0→非0):22100 Gas
// 后续 SSTORE:100 Gas(热)
这就是为什么在合约中缓存存储变量到内存中可以节省 Gas:
// Gas 高:多次读取存储
function bad() public view returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < array.length; i++) { // 每次 array.length 读存储
sum += array[i]; // 每次读存储
}
return sum;
}
// Gas 低:缓存到内存
function good() public view returns (uint256) {
uint256[] memory arr = array; // 一次性读取
uint256 sum = 0;
for (uint256 i = 0; i < arr.length; i++) {
sum += arr[i]; // 内存读取,3 Gas
}
return sum;
}
合约部署:constructor bytecode 与 runtime bytecode
合约部署涉及两种字节码:
Creation Bytecode(部署字节码)
包含 constructor 代码 + runtime bytecode。部署时执行 constructor,然后将 runtime bytecode 存储到链上。
// SimpleStorage.sol
pragma solidity 0.8.17;
contract SimpleStorage {
uint256 public value;
constructor(uint256 _value) {
value = _value;
}
function setValue(uint256 _value) external {
value = _value;
}
function getValue() external view returns (uint256) {
return value;
}
}
编译后的字节码结构:
Creation Bytecode:
┌─────────────────────────────────┐
│ Constructor 代码 │
│ (执行初始化逻辑) │
├─────────────────────────────────┤
│ Runtime Bytecode │
│ (返回给 EVM 存储的代码) │
└─────────────────────────────────┘
部署过程:
1. EVM 执行 Creation Bytecode
2. Constructor 设置初始状态
3. Constructor 返回 Runtime Bytecode
4. Runtime Bytecode 被存储到合约地址
Runtime Bytecode(运行时字节码)
这是链上存储的、实际响应交易的代码。可以通过 eth_getCode 获取:
import { ethers } from 'ethers'
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
// 获取链上 runtime bytecode
const bytecode = await provider.getCode(contractAddress)
// "0x608060405234801561001057600080fd5b5060..."
字节码反编译工具
在线反编译工具
- ethervm.io:可视化展示操作码,支持主网和测试网
- dedaub.com:Decompiler,将字节码反编译为可读的伪代码
- etherscan.io:合约页面的 "Disassemble" 功能
使用 ethers 解析字节码
import { ethers } from 'ethers'
// 解析字节码为操作码
function disassemble(bytecode: string): { opcode: string; pc: number }[] {
// 去除 0x 前缀
const code = bytecode.startsWith('0x')
? bytecode.slice(2)
: bytecode
const opcodes: { opcode: string; pc: number }[] = []
let pc = 0
while (pc < code.length) {
const byte = parseInt(code.slice(pc, pc + 2), 16)
const opcode = OPCODE_MAP[byte] || `UNKNOWN(0x${byte.toString(16)})`
opcodes.push({ opcode, pc })
// PUSH1-PUSH32 需要跳过数据
if (byte >= 0x60 && byte <= 0x7f) {
const pushSize = byte - 0x5f
pc += 2 + pushSize * 2
} else {
pc += 2
}
}
return opcodes
}
// 常见操作码映射表
const OPCODE_MAP: Record<number, string> = {
0x00: 'STOP',
0x01: 'ADD',
0x02: 'MUL',
0x03: 'SUB',
0x04: 'DIV',
0x10: 'LT',
0x11: 'GT',
0x14: 'EQ',
0x15: 'ISZERO',
0x16: 'AND',
0x17: 'OR',
0x34: 'CALLVALUE',
0x35: 'CALLDATALOAD',
0x36: 'CALLDATASIZE',
0x50: 'POP',
0x51: 'MLOAD',
0x52: 'MSTORE',
0x54: 'SLOAD',
0x55: 'SSTORE',
0x56: 'JUMP',
0x57: 'JUMPI',
0x5b: 'JUMPDEST',
0x80: 'DUP1',
0x90: 'SWAP1',
0xf3: 'RETURN',
0xfd: 'REVERT',
}
// 使用
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const bytecode = await provider.getCode('0x...')
const opcodes = disassemble(bytecode)
opcodes.forEach((op) => console.log(`${op.pc}: ${op.opcode}`))
执行追踪
debug_traceTransaction 是 Geth 的调试 RPC 方法,可以获取交易的完整执行追踪:
import { ethers } from 'ethers'
// 需要支持 debug_traceTransaction 的节点
const provider = new ethers.providers.JsonRpcProvider(
'https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY'
)
async function traceTransaction(txHash: string) {
const trace = await provider.send('debug_traceTransaction', [
txHash,
{
disableStorage: false,
disableMemory: false,
disableStack: false,
},
])
// trace.structLogs 是操作码执行序列
trace.structLogs.forEach((log: any, index: number) => {
console.log(`[${index}] PC: ${log.pc}, OP: ${log.op}`)
console.log(` Stack: ${log.stack}`)
if (log.storage) {
console.log(` Storage: ${JSON.stringify(log.storage)}`)
}
})
return trace
}
使用 Trace 进行 Gas 分析
function analyzeGasUsage(trace: any) {
const gasPerOpcode: Record<string, { count: number; gas: number }> = {}
trace.structLogs.forEach((log: any) => {
const op = log.op
if (!gasPerOpcode[op]) {
gasPerOpcode[op] = { count: 0, gas: 0 }
}
gasPerOpcode[op].count++
// 估算每步 Gas(实际需要更复杂的计算)
})
// 按 Gas 消耗排序
const sorted = Object.entries(gasPerOpcode).sort(
(a, b) => b[1].count - a[1].count
)
console.log('操作码使用统计:')
sorted.forEach(([op, data]) => {
console.log(` ${op}: ${data.count} 次`)
})
}
存储布局
Solidity 合约的存储是 256-bit key → 256-bit value 的映射,共 2^256 个槽位。编译器按照声明顺序分配存储槽。
存储槽分配规则
contract StorageLayout {
// Slot 0: state variables 按声明顺序打包
uint256 public a; // slot 0(占用整个槽)
uint128 public b; // slot 1(与 c 打包)
uint128 public c; // slot 1(与 b 打包)
// Slot 2: 数组长度存储在此
uint256[] public array; // slot 2 存长度,数据在 keccak256(2) + i
// Slot 3: mapping 不存储值,key 通过 keccak256(key . slot) 计算
mapping(address => uint256) public balances; // slot 3
// Slot 4: 嵌套 mapping
mapping(address => mapping(uint256 => uint256)) public nested; // slot 4
}
前端读取存储槽
import { ethers } from 'ethers'
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
// 读取 slot 0
const slot0 = await provider.getStorageAt(contractAddress, 0)
console.log('Slot 0:', ethers.BigNumber.from(slot0).toString())
// 读取 mapping 中的值
// balances[user] 存储在 keccak256(userAddress . slotNumber)
async function getMappingValue(
contractAddress: string,
slot: number,
key: string
) {
// 计算 key 的存储位置
const paddedKey = ethers.utils.hexZeroPad(key, 32)
const paddedSlot = ethers.utils.hexZeroPad(slot, 32)
const concatenated = paddedKey + paddedSlot.slice(2)
const hash = ethers.utils.keccak256(concatenated)
const value = await provider.getStorageAt(contractAddress, hash)
return ethers.BigNumber.from(value)
}
// 读取 balances[0xUser...]
const balance = await getMappingValue(
contractAddress,
3, // mapping 的 slot
'0xUserAddress...'
)
读取动态数组元素
// array[i] 存储在 keccak256(slot) + i
async function getArrayElement(
contractAddress: string,
slot: number,
index: number
) {
// 计算数组数据起始位置
const paddedSlot = ethers.utils.hexZeroPad(slot, 32)
const arrayStart = ethers.utils.keccak256(paddedSlot)
// 元素位置 = arrayStart + index
const elementSlot = ethers.BigNumber.from(arrayStart).add(index)
const value = await provider.getStorageAt(
contractAddress,
elementSlot.toHexString()
)
return ethers.BigNumber.from(value)
}
完整的 Solidity → Bytecode → Opcode 分析
// 一个最简单的合约
pragma solidity 0.8.17;
contract Adder {
function add(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
}
编译后的 runtime bytecode(简化版):
0x6080604052
// 函数选择器检查
34156100... // CALLVALUE, ISZERO, ...
6004351460... // CALLDATALOAD 4字节, EQ, JUMPI
// add(uint256,uint256) 的函数选择器: 0x771602f7
// calldata 布局: [4字节selector][32字节 a][32字节 b]
// 读取参数 a 和 b
35 // CALLDATALOAD
6004602410... // offset 4, 加载参数 a
35 // CALLDATALOAD
6024602410... // offset 36, 加载参数 b
// 执行加法
01 // ADD
// 返回结果
602052... // MSTORE 到内存
f3 // RETURN
对应的操作码序列:
PC OP 说明
0 PUSH1 0x80 压入 0x80
2 PUSH1 0x40 压入 0x40
4 MSTORE 在内存 0x40 处存储 0x80(空闲内存指针)
5 CALLVALUE 获取 msg.value
6 DUP1 复制
7 ISZERO 检查是否为 0
8 PUSH1 0x0f 压入跳转目标
10 JUMPI 如果 msg.value == 0,跳转
...
函数选择器
Solidity 使用函数选择器来路由调用。选择器是函数签名的 keccak256 哈希的前 4 字节:
import { ethers } from 'ethers'
// 计算函数选择器
const selector = ethers.utils
.id('add(uint256,uint256)')
.slice(0, 10) // 0x + 4字节
console.log(selector) // 0x771602f7
// 从 calldata 中提取选择器
function parseCalldata(calldata: string) {
const selector = calldata.slice(0, 10) // 0x + 4字节
const params = '0x' + calldata.slice(10)
return {
selector,
params,
}
}
前端工具:用 ethers 解析 bytecode
import { ethers } from 'ethers'
class BytecodeAnalyzer {
constructor(private bytecode: string) {}
// 检查是否是合约(而非 EOA)
isContract(): boolean {
return this.bytecode !== '0x' && this.bytecode.length > 2
}
// 提取 immutable 变量
// immutable 变量在字节码中以 PUSH32 存储
extractImmutableValues(): string[] {
const values: string[] = []
const code = this.bytecode.slice(2)
for (let i = 0; i < code.length; i += 2) {
const byte = parseInt(code.slice(i, i + 2), 16)
// PUSH32 (0x7f)
if (byte === 0x7f) {
const value = '0x' + code.slice(i + 2, i + 66)
if (value !== '0x' + '0'.repeat(64)) {
values.push(value)
}
i += 64 // 跳过 32 字节数据
}
}
return values
}
// 检测代理合约模式
isProxyContract(): boolean {
// 代理合约通常在开头有 DELEGATECALL (0xf4)
// EIP-1167 最小代理模式
const minimalProxyPattern =
'0x3d602d80600a3d3981f3363d3d373d3d3d363d73'
return this.bytecode.startsWith(minimalProxyPattern)
}
// 提取合约接口(通过分析 calldata 路由)
extractFunctionSelectors(): string[] {
const selectors: string[] = []
// 查找 PUSH4 后跟 EQ 的模式
const code = this.bytecode.slice(2)
for (let i = 0; i < code.length - 10; i += 2) {
const byte = parseInt(code.slice(i, i + 2), 16)
// PUSH4 (0x63)
if (byte === 0x63) {
const selector = '0x' + code.slice(i + 2, i + 10)
// 检查后面是否有 EQ (0x14)
const nextByte = parseInt(code.slice(i + 10, i + 12), 16)
if (nextByte === 0x14) {
selectors.push(selector)
}
}
}
return [...new Set(selectors)]
}
}
// 使用
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const bytecode = await provider.getCode(contractAddress)
const analyzer = new BytecodeAnalyzer(bytecode)
console.log('Is contract:', analyzer.isContract())
console.log('Is proxy:', analyzer.isProxyContract())
console.log('Function selectors:', analyzer.extractFunctionSelectors())
理解 EVM 对 DApp 开发的实际价值
Gas 优化
理解 EVM 操作码的 Gas 消耗可以帮助编写更高效的合约:
// Gas 优化示例:减少存储写入
contract GasOptimized {
mapping(address => uint256) public balances;
// 优化前:2 次存储写入
function update_bad(address user, uint256 amount) external {
balances[user] = 0; // SSTORE (0→0, 100 Gas if warm)
balances[user] = amount; // SSTORE (0→nonzero, 22100 Gas)
}
// 优化后:1 次存储写入
function update_good(address user, uint256 amount) external {
balances[user] = amount; // SSTORE (一次写入)
}
}
安全审计
理解存储布局对于安全审计至关重要。许多漏洞源于对存储打包的误解:
// 漏洞示例:存储碰撞
contract Vulnerable {
address public owner; // slot 0, 20 字节
bool public locked; // slot 0, 与 owner 打包(1 字节)
uint256 public funds; // slot 1
// 攻击者可能通过特定方式覆盖 locked
// 如果合约有 delegatecall 到恶意合约
}
调试
当合约交易失败时,理解字节码可以帮助定位问题:
async function debugFailedTransaction(
txHash: string,
provider: ethers.providers.Provider
) {
const tx = await provider.getTransaction(txHash)
const receipt = await provider.getTransactionReceipt(txHash)
if (receipt.status === 0) {
// 交易失败,尝试 replay
try {
await provider.call({
to: tx.to,
data: tx.data,
from: tx.from,
value: tx.value,
}, tx.blockNumber)
} catch (error) {
// 解析 revert 原因
const revertReason = ethers.utils.toUtf8String(
'0x' + error.data.slice(138)
)
console.log('Revert reason:', revertReason)
}
}
}
小结
EVM 字节码分析是 Web3 开发中的深度技能。虽然日常开发中不需要手写字节码,但理解操作码、Gas 模型和存储布局对三个方面有直接价值:
Gas 优化是立竿见影的收益。知道 SLOAD 是 2100 Gas 而 MLOAD 是 3 Gas,就会本能地把循环中的存储读取缓存到内存。知道存储打包规则,就会合理安排变量声明顺序以减少槽位使用。
安全审计需要理解存储布局。代理合约的 delegatecall 会复用调用者的存储,如果布局不一致就会导致存储碰撞。理解 slot 分配规则才能设计安全的代理模式。
调试能力是最后的保障。当交易失败且没有 revert 原因时,通过 debug_traceTransaction 追踪操作码执行是定位问题的最后一招。前端开发者虽然不常做字节码级调试,但知道这个工具的存在和基本用法,在关键时刻能节省大量排查时间。
随着 EVM 的持续演进(EIP-3540 EOF、EIP-3074 等提案),字节码结构本身也在变化。保持对 EVM 规范的关注,是 Web3 开发者的长期必修课。
