区块链安全101-深入EVM
字数 2008 2025-08-22 12:23:25

EVM 深入解析:从字节码到智能合约执行

1. EVM 概述

EVM(以太坊虚拟机)是以太坊协议的核心组件,负责智能合约的部署和执行。可以将其想象为一台拥有数百万个可执行合约的超级计算机,每个合约都有自己的永久存储空间。

1.1 合约编译结构

Solidity代码编译成字节码后,通常分为三部分:

  • Contract Creation Code:合约创建代码
  • Runtime Code:运行时代码
  • Metadata:元数据

这些部分之间通常用INVALID操作码分隔。当看到CODECOPY-39时,通常表示合约的创建部分。

2. EVM 核心组件

2.1 堆栈(Stack)

  • EVM使用后进先出(LIFO)的堆栈结构
  • 堆栈深度为1024个项,每个项256位(32字节)
  • 操作码使用堆栈顶部的元素作为输入

2.2 内存(Memory)

  • 可扩展的字节寻址一维数组
  • 初始为空,读写和扩展都需要消耗Gas
  • 内存成本与使用量成比例上升
  • 理论上有\(2^{256}\)个元素,但受Gas限制实际使用有限
  • 交易结束后内存内容被丢弃
  • 大多数内存读取操作以32字节为单位

内存扩展Gas成本计算

内存扩展成本计算公式:

memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
memory_expansion_cost = new_memory_cost - last_memory_cost

2.3 存储(Storage)

  • 持久化存储,比内存昂贵得多
  • 可视为初始化为零的\(2^{256}\)个32字节值的数组
  • 智能合约可以在任何位置读写值
  • 固定大小值从slot0开始分配
  • 动态大小值通过哈希安全定位

3. 内存操作

3.1 内存数据结构

  • 简单字节数组,数据可以32字节或1字节存储
  • 读取始终以32字节为单位
  • 主要操作码:
    • MSTORE(x,y):从内存位置x存储32字节的y
    • MLOAD(x):将内存位置x开始的32字节加载到堆栈
    • MSTORE8(x,y):将1字节的y存储到位置x

3.2 空闲内存指针(Free Memory Pointer)

内存布局:

  • 0x00-0x3f (64字节):暂存空间
  • 0x40-0x5f (32字节):空闲内存指针(初始为0x80)
  • 0x60-0x7f (32字节):零槽(不应写入)

空闲内存指针更新公式:

freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer

初始化代码:

60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE

3.3 内存中的数据结构操作

结构体(Struct)

struct Point {
    uint256 x;
    uint32 y;
    uint32 z;
}

// 读取
assembly {
    x := mload(0x80)
    y := mload(add(0x80,0x20))
    z := mload(add(0xa0,0x20))
}

// 写入
assembly {
    mstore(0x80,1)
    mstore(add(0x80,0x20),2)
    mstore(add(0xa0,0x20),3)
}

固定大小数组

uint32[3] memory arr = [uint32(1), uint32(2), uint32(3)];

// 读取
assembly {
    a0 := mload(0x80)
    a1 := mload(0xa0)
    a2 := mload(0xc0)
}

// 写入
assembly {
    mstore(arr, 11)        // 0x80
    mstore(add(arr, 0x20), 22) // 0xa0
    mstore(add(arr, 0x40), 33) // 0xc0
}

动态数组

uint256[] memory arr = new uint256[](5);

// 读取
assembly {
    p := arr
    len := mload(arr)
    a0 := mload(add(arr, 0x20))
    a1 := mload(add(arr, 0x40))
    a2 := mload(add(arr, 0x60))
}

// 写入
assembly {
    p := arr
    mstore(arr, 3) // 存储数组长度
    mstore(add(arr, 0x20), 11)
    mstore(add(arr, 0x40), 22)
    mstore(add(arr, 0x60), 33)
    mstore(0x40, add(arr, 0x80)) // 更新空闲内存指针
}

4. 存储操作

4.1 存储布局规则

不同类型变量的存储位置:

类型 声明 值位置
简单变量 T v v的slot
固定大小数组 T[10] v v[n](v的slot) + n * (T的大小)
动态数组 T[] v v[n]keccak256(v的slot) + n * (T的大小)
v.lengthv的slot
映射 mapping(T1 => T2) v v[key]keccak256(key . (v的slot))

4.2 槽打包(Slot Packing)

Solidity编译器会尝试将多个小类型变量打包到一个32字节的存储槽中。例如:

contract StorageTest {
    uint32 value1; // 4 bytes slot0
    uint32 value2; // 4 bytes slot0
    uint64 value3; // 8 bytes slot0
    uint128 value4;// 16 bytes slot0
}

4.3 存储操作码

  • SSTORE:从堆栈获取32字节key和32字节value,将value存储到key指定的位置
  • SLOAD:从堆栈获取32字节key,将key位置的32字节value推送到堆栈

5. Calldata

Calldata是发送给函数的编码参数,即发送给EVM的数据。每个calldata长度为32字节(64个字符),分为静态和动态两种类型。

5.1 编码

  • 使用abi.encode()生成原始调用数据
  • 对特定接口函数,使用abi.encodeWithSelector()
interface A {
    function transfer(uint256[] memory ids, address to) external;
}

contract B {
    function a(uint256[] memory ids, address to) external pure returns(bytes memory) {
        return abi.encodeWithSelector(A.transfer.selector, ids, to);
    }
}

5.2 解码

使用abi.decode()解码:

(uint256 a, uint256 b) = abi.decode(data, (uint256, uint256));

5.3 静态变量示例

对于函数transfer(uint256 amount, address to),参数:

  • amount: 1300655506
  • address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45

生成的calldata:

0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

5.4 函数选择器

函数选择器是函数签名keccak256哈希的前4个字节。例如transfer(uint256,address)的选择器:

keccak256("transfer(uint256,address)") → 前4字节为b7760c8f

完整calldata:

0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

5.5 动态变量

动态变量(如bytes、string、动态数组)的结构:

  1. 第一个32字节:偏移量(动态数据开始位置)
  2. 第二个32字节:长度
  3. 后续:元素

例如字符串"Hello World!"的编码:

0x0000000000000000000000000000000000000000000000000000000000000020  // 偏移量32字节
0x000000000000000000000000000000000000000000000000000000000000000c  // 长度12字节
0x48656c6c6f20576f726c64210000000000000000000000000000000000000000  // 内容"Hello World!"

6. 参考资料

  1. EVM Deep Dives: The Path to Shadowy Super Coder
  2. Deconstructing a Solidity Smart Contract
EVM 深入解析:从字节码到智能合约执行 1. EVM 概述 EVM(以太坊虚拟机)是以太坊协议的核心组件,负责智能合约的部署和执行。可以将其想象为一台拥有数百万个可执行合约的超级计算机,每个合约都有自己的永久存储空间。 1.1 合约编译结构 Solidity代码编译成字节码后,通常分为三部分: Contract Creation Code :合约创建代码 Runtime Code :运行时代码 Metadata :元数据 这些部分之间通常用 INVALID 操作码分隔。当看到 CODECOPY-39 时,通常表示合约的创建部分。 2. EVM 核心组件 2.1 堆栈(Stack) EVM使用后进先出(LIFO)的堆栈结构 堆栈深度为1024个项,每个项256位(32字节) 操作码使用堆栈顶部的元素作为输入 2.2 内存(Memory) 可扩展的字节寻址一维数组 初始为空,读写和扩展都需要消耗Gas 内存成本与使用量成比例上升 理论上有$2^{256}$个元素,但受Gas限制实际使用有限 交易结束后内存内容被丢弃 大多数内存读取操作以32字节为单位 内存扩展Gas成本计算 内存扩展成本计算公式: 2.3 存储(Storage) 持久化存储,比内存昂贵得多 可视为初始化为零的$2^{256}$个32字节值的数组 智能合约可以在任何位置读写值 固定大小值从slot0开始分配 动态大小值通过哈希安全定位 3. 内存操作 3.1 内存数据结构 简单字节数组,数据可以32字节或1字节存储 读取始终以32字节为单位 主要操作码: MSTORE(x,y) :从内存位置x存储32字节的y MLOAD(x) :将内存位置x开始的32字节加载到堆栈 MSTORE8(x,y) :将1字节的y存储到位置x 3.2 空闲内存指针(Free Memory Pointer) 内存布局: 0x00-0x3f (64字节):暂存空间 0x40-0x5f (32字节):空闲内存指针(初始为0x80) 0x60-0x7f (32字节):零槽(不应写入) 空闲内存指针更新公式: 初始化代码: 3.3 内存中的数据结构操作 结构体(Struct) 固定大小数组 动态数组 4. 存储操作 4.1 存储布局规则 不同类型变量的存储位置: | 类型 | 声明 | 值位置 | |------|------|--------| | 简单变量 | T v | v 的slot | | 固定大小数组 | T[10] v | v[n] 在 (v的slot) + n * (T的大小) | | 动态数组 | T[] v | v[n] 在 keccak256(v的slot) + n * (T的大小) v.length 在 v的slot | | 映射 | mapping(T1 => T2) v | v[key] 在 keccak256(key . (v的slot)) | 4.2 槽打包(Slot Packing) Solidity编译器会尝试将多个小类型变量打包到一个32字节的存储槽中。例如: 4.3 存储操作码 SSTORE :从堆栈获取32字节key和32字节value,将value存储到key指定的位置 SLOAD :从堆栈获取32字节key,将key位置的32字节value推送到堆栈 5. Calldata Calldata是发送给函数的编码参数,即发送给EVM的数据。每个calldata长度为32字节(64个字符),分为静态和动态两种类型。 5.1 编码 使用 abi.encode() 生成原始调用数据 对特定接口函数,使用 abi.encodeWithSelector() 5.2 解码 使用 abi.decode() 解码: 5.3 静态变量示例 对于函数 transfer(uint256 amount, address to) ,参数: amount: 1300655506 address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 生成的calldata: 5.4 函数选择器 函数选择器是函数签名keccak256哈希的前4个字节。例如 transfer(uint256,address) 的选择器: 完整calldata: 5.5 动态变量 动态变量(如bytes、string、动态数组)的结构: 第一个32字节:偏移量(动态数据开始位置) 第二个32字节:长度 后续:元素 例如字符串"Hello World !"的编码: 6. 参考资料 EVM Deep Dives: The Path to Shadowy Super Coder Deconstructing a Solidity Smart Contract