Skip to content

EVM 字节码分析:理解智能合约执行模型

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:

solidity
// 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 存储到链上。

solidity
// 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 获取:

typescript
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 解析字节码 ​

typescript
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 方法,可以获取交易的完整执行追踪:

typescript
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 分析 ​

typescript
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 个槽位。编译器按照声明顺序分配存储槽。

存储槽分配规则 ​

solidity
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
}

前端读取存储槽 ​

typescript
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...'
)

读取动态数组元素 ​

typescript
// 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 分析 ​

solidity
// 一个最简单的合约
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 字节:

typescript
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 ​

typescript
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 消耗可以帮助编写更高效的合约:

solidity
// 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 (一次写入)
    }
}

安全审计 ​

理解存储布局对于安全审计至关重要。许多漏洞源于对存储打包的误解:

solidity
// 漏洞示例:存储碰撞
contract Vulnerable {
    address public owner;      // slot 0, 20 字节
    bool public locked;        // slot 0, 与 owner 打包(1 字节)
    uint256 public funds;      // slot 1

    // 攻击者可能通过特定方式覆盖 locked
    // 如果合约有 delegatecall 到恶意合约
}

调试 ​

当合约交易失败时,理解字节码可以帮助定位问题:

typescript
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 开发者的长期必修课。

MIT Licensed