一、定义

EVM 全称是 Ethereum Virtual Machine ,具备以下特性:

  • 虚拟运行环境: 它不直接运行在硬件上,而是作为一个软件层运行在每一个以太坊节点(计算机)中。
  • 沙盒化(Sandboxed): EVM 是完全隔离的。在 EVM 中运行的代码(智能合约)无法访问网络、文件系统或其他进程。这保证了即使合约有恶意代码,也不会损害节点本身。
  • 统一性: 无论在 Windows、Linux 还是 macOS 上运行以太坊节点,EVM 的执行结果都是完全一致的。

二、工作原理

evm

1. 代码的编译与执行流程

  • 编写: 开发者写好 Solidity 代码。
  • 编译: 编译器将代码转换为 Bytecode(字节码)。这是 EVM 唯一能读懂的语言(一串十六进制数字)。
  • 部署: 字节码被作为数据发送到区块链上存储。
  • 执行: 当用户调用合约时,EVM 读取这些字节码,并将其分解为 Opcode(操作码) 逐行执行。实际是底层的 switch-case 解释器中逐条触发宿主机早已编译好的对应逻辑「比如 go 编写的 geth,把 Opcode 映射成了已经编译好的 go 代码,复用这个高级语言的跨平台特性」

2. 栈式架构

与我们在个人电脑中常用的 x86 架构(基于寄存器)不同,EVM 是基于栈(Stack) 的虚拟机。

  • 后进先出 (LIFO): 数据像叠盘子一样,最后放进去的数据最先被取出。
  • 操作方式: EVM 指令(如 ADD)会从栈顶弹出两个数字,相加,然后将结果压回栈顶。
  • 优点: 这种架构更容易实现,且对指令长度要求更短(不需要指定操作数的地址,默认就在栈顶)。

3. 数据存储空间

EVM 在执行时有三种主要的数据存储区域,它们的成本和用途各不相同:
(这块其实在学习到 solidity 语言的时候会理解的更深刻一些)

存储类型 特点 成本 (Gas) 用途
Stack (栈) 免费、极快,但空间很小(仅1024层)。 用于运算过程中的临时变量交换。
Memory (内存) 临时存储,合约执行完即清空。数据量越大越贵。 用于存储函数参数、返回值、临时数组。
Storage (存储) 永久存储在区块链上。全网同步,最昂贵。 极高 用于存储合约的状态变量(如用户的代币余额)。

Stack(栈)

在堆栈中,每个元素长度为256位(32字节),最大深度为1024元素,但是每个操作只能操作堆栈顶的16个元素。这也是为什么有时Solidity会报Stack too deep错误。

问题1 :为什么最大深度 1024,却只能一次性操作 16 个元素呢?
首先,目前能够操作 16 个元素的只有有 SWAP16和 DUP16,如果想要操作第 17 个元素,那么需要增加指令 SWAP17 或 DUP17 了。其次,前面提到过,Opcode 最多有 256 个,资源有限,是十分珍贵的,所以不能随意扩充,而且 16 个元素已经可以满足大部分场景了。
聪明的你可能想到了,为什么不能直接定义 SWAP 和 DUP 两个不带数字后缀的指令,然后字节码中该指令后面紧跟着的就代表元素位置呢,相关的法案EIP-663已经在 24 年提出来了,目前还未落实。
问题2:Stack too deep 是如何造成的?
造成该问题的根本原因,就是仅仅操作 16 个元素无法满足我的代码逻辑的实现了,比如下面的 solidity 程序,函数内部定义了超过 16 个元素的,然后想要访问第 17 个元素就会报错,下面附带了一段程序。其中很容易想到的优化方式就是把变量从栈移到内存中去即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.0;

contract StackError {
function tooManyVariables() external pure returns (uint) {
// 1. 我们开始疯狂往栈里压入变量
uint v1 = 1;
uint v2 = 2;
uint v3 = 3;
uint v4 = 4;
uint v5 = 5;
uint v6 = 6;
uint v7 = 7;
uint v8 = 8;
uint v9 = 9;
uint v10 = 10;
uint v11 = 11;
uint v12 = 12;
uint v13 = 13;
uint v14 = 14;
uint v15 = 15;
uint v16 = 16;
uint v17 = 17; // 此时,v1 已经被埋到了第 17 层

// 2. 这里尝试访问 v1, 编译器报错:Stack too deep. Try removing local variables.
return v1 + v17;
}
}

Memory(内存)

堆栈虽然计算高效,但是存储能力有限,因此EVM使用内存来支持交易执行期间的数据存储和读取。EVM的内存是一个线性寻址存储器,可以把它理解为一个动态字节数组,可以根据需要动态扩展。它支持以8或256 bit写入(MSTORE8/MSTORE),但只支持以256 bit读取(MLOAD)。

Storage(存储)

EVM的账户存储(Account Storage)是一种映射(mapping,键值对存储),每个键和值都是256 bit的数据,它支持256 bit的读和写。这种存储在每个合约账户上都存在,并且是持久的,它的数据会保持在区块链上,直到被明确地修改。
对存储的读取(SLOAD)和写入(SSTORE)都需要gas,并且比内存操作更昂贵。这样设计可以防止滥用存储资源,因为所有的存储数据都需要在每个以太坊节点上保存。

4. Gas 机制 (燃料)

由于 EVM 是图灵完备的(允许循环),如果有人写了一个死循环(while(true)),全网节点都会卡死。为了解决这个问题(停机问题),以太坊引入了 Gas

  • 付费计算: 每一个 Opcode(如加法、乘法、存储数据)都有固定的 Gas 成本。
  • 耗尽即停: 交易发送者必须预付 Gas。如果代码执行中 Gas 耗尽,EVM 会立即停止执行,回滚所有状态更改,但不退还已消耗的 Gas。

三、Opcode

Opcode 是 EVM 的机器语言,是 1 字节的指令,最多能有 256 个指令($2^8$),目前以太坊定义了约 140 多个,其余未定义。直接去记0x01 之类的字节码是比较困难的,所以每一个指令都有对应的英文缩写,类似于汇编语言,将 2 进制的 0,1 转换成我们更容易理解的符号而已

1. 基础操作指令

定义自己的 OpCode 的原因是:为了日后拓展其他语言,不只 solidity 也能支持其他语言。更容易做适配以太坊环境的拓展,比如 gas 数量和 OpCode 的绑定
Opcode明细在这里查看

1.1 栈操作 (Stack Manipulation)

这是 EVM 最频繁使用的指令,用于数据的搬运。

字节码 指令名称 指令解释 基础 Gas
0x50 POP 从栈顶移除 1 个元素 2
0x60 PUSH1 将随后的 1 字节数据压入栈顶 3
0x600x7F PUSH1-32 将随后的 2~32 字节数据压入栈顶 3
0x80 DUP1 复制栈顶第 1 个元素并再次压入栈 3
0x810x8F DUP2-16 复制栈深处第 N 个元素到栈顶 3
0x90 SWAP1 交换栈顶第 1 和第 2 个元素 3
0x910x9F SWAP2-16 交换栈顶第 1 和第 N+1 个元素 3

问题:已知栈元素基本长度就是 32 个字节,只保留 PUSH32 不就可以了么?
PUSHN 指令的执行逻辑是 将后面 N 个字节长度的字节码压入栈中

  • 用 PUSH1 0x01:字节码占 0x60 0x01(2 字节)
  • 若只有 PUSH32:需要写成 0x7f 0x0000000000000000000000000000000000000000000000000000000000000001(33 字节)

字节码的长度变长了,那么需要的Gas 费用也会更高了,已知 200Gas/Byte

1.2 算术运算 (Arithmetic Operations)

用于基本的数学计算。

字节码 指令名称 指令解释 基础 Gas
0x01 ADD 加法 (a + b) 3
0x02 MUL 乘法 (a * b) 5
0x03 SUB 减法 (a - b) 3
0x04 DIV 整数除法 (a / b),分母为0结果为0 5
0x06 MOD 取模/余数 (a % b) 5
0x0A EXP 指数运算 (a ** exponent) 10 + (50 * 字节数)
0x01 ADDMOD 加法取模 (a + b) % N 8
0x02 MULMOD 乘法取模 (a * b) % N 8

1.3 比较与逻辑 (Comparison & Logic)

结果通常为 1 (True) 或 0 (False)。

字节码 指令名称 指令解释 基础 Gas
0x10 LT 小于 (a < b) 3
0x11 GT 大于 (a > b) 3
0x14 EQ 等于 (a == b) 3
0x15 ISZERO 是否为零 (a == 0) 3
0x16 AND 按位与 (&) 3
0x17 OR 按位或 (|) 3
0x19 NOT 按位取反 (~) 3

1.4 内存与存储 (Memory & Storage) - 最重要且最贵

理解这些指令对优化 Gas 至关重要。

字节码 指令名称 指令解释 Gas (估算)
0x51 MLOAD 从内存读取 32 字节 3 + 内存扩展费
0x52 MSTORE 向内存写入 32 字节 3 + 内存扩展费
0x53 MSTORE8 向内存写入 1 字节 3 + 内存扩展费
0x54 SLOAD 从**存储(硬盘)**读取数据 100 (热读) / 2100 (冷读)
0x55 SSTORE 向**存储(硬盘)**写入数据 2900 (热写) / 20000 (新存)
0x20 SHA3 计算 Keccak256 哈希值 30 + (6 * 字节数)

_注:存储操作(SLOAD/SSTORE)的价格会根据 EIP(如 EIP-2929)经常调整,且取决于该槽位是否已被访问过(冷/热)。

问题:其中的冷热表示什么?
在以太坊的底层代码(客户端如 Geth)中,每个交易执行时都会维护一个 Access List(访问列表/缓存集合)。这里的冷、热由是否使用了 EVM 缓存决定,用到了就是热,否则冷。虽然热操作节省 Gas,但是只能针对单笔交易有效。示例:

  1. 交易 A 读取了变量 x -> 冷读(付 2100)。
  2. 交易 A 再次读取变量 x -> 热读(付 100)。
  3. (交易 A 结束,区块打包)
  4. (Access List 被清空)
  5. 交易 B 开始,读取变量 x -> 又是冷读(付 2100)。

1.5 环境信息 (Environmental Information)

获取当前交易、区块或发送者的上下文信息。

字节码 指令名称 指令解释 基础 Gas
0x30 ADDRESS 获取当前合约地址 (address(this)) 2
0x31 BALANCE 获取某地址余额 100 (热) / 2600 (冷)
0x32 ORIGIN 交易发起者 (tx.origin) 2
0x33 CALLER 直接调用者 (msg.sender) 2
0x34 CALLVALUE 随交易发送的 ETH (msg.value) 2
0x36 CALLDATASIZE 输入数据的大小 2
0x3A GASPRICE 当前交易 Gas 价格 (tx.gasprice) 2
0x42 TIMESTAMP 当前区块时间戳 (block.timestamp) 2

1.6 流程控制 (Flow Control)

决定代码跳转到哪里执行。

字节码 指令名称 指令解释 基础 Gas
0x56 JUMP 无条件跳转到指定位置 8
0x57 JUMPI 条件跳转 (if (condition) jump) 10
0x58 PC 获取当前程序计数器位置 2
0x5B JUMPDEST 标记一个合法的跳转目的地 1
0x00 STOP 停止执行,成功退出 0
0xFD REVERT 停止执行,回滚状态,返回数据 0 + 内存消耗
0xF3 RETURN 停止执行,保留状态,返回数据 0 + 内存消耗

1.7 系统操作 (System Operations)

与其他合约交互。

字节码 指令名称 指令解释 基础 Gas
0xF0 CREATE 创建新合约 32000 + 其它
0xF1 CALL 调用其他合约 100 (热) / 2600 (冷) + 其它
0xF4 DELEGATECALL 委托调用 (保留上下文) 100 (热) / 2600 (冷) + 其它
0xFA STATICCALL 静态调用 (不允许修改状态) 100 (热) / 2600 (冷) + 其它
0xFF SELFDESTRUCT 销毁合约并发送余额 (已弃用/改动) 5000 + 其它

2. 理解 Opcode 的重要性

平时写 Solidity 不需要直接写 Opcode,但理解它有巨大好处:

  1. 省钱 (Gas Optimization):
    • 知道了 SSTORE 很贵,就会尽量减少写状态变量,改用 Memory 里的变量做中间计算,最后一次性写入 Storage
    • 知道 0 值和 非0 值处理成本不同,就会注意变量初始化。
  2. 理解黑客攻击:
    • 很多攻击(如 Reentrancy 重入攻击)在 Solidity 层面看很抽象,但在 Opcode 执行流层面(CALL 之后控制权移交,然后再次 CALL)就非常清晰。
  3. 内联汇编 (Inline Assembly):
    • 在 Solidity 里可以使用 assembly { ... } 直接写 Opcode。这能绕过 Solidity 的一些限制,或者做极致的性能优化。

优化示例

A. 绕过数组边界检查 (Bypassing Array Bounds Check)

在 Solidity 中,每次访问数组元素 arr[i],编译器都会自动插入一段 Opcode 来检查 i < arr.length。如果在 for 循环中,这个检查会重复执行无数次,浪费大量 Gas。

优化原理: 使用汇编直接计算内存地址并读取,跳过检查。

普通 Solidity 写法:

1
2
3
4
5
6
function sumSolidity(uint[] memory arr) external pure returns (uint sum) {
for (uint i = 0; i < arr.length; i++) {
// 每次访问 arr[i] 都要检查 i 是否越界
sum += arr[i];
}
}

Opcode 优化写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function sumAssembly(uint[] memory arr) external pure returns (uint sum) {
assembly {
// 1. 获取数组长度
// 在内存中,数组变量 arr 指向的位置存储的是长度
let len := mload(arr)

// 2. 获取数据开始的内存指针
// 数据从 arr + 32 字节开始
let dataPtr := add(arr, 0x20)

// 3. 循环
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
// 计算当前元素的地址:dataPtr + i * 32
// 读取该地址的值,累加到 sum
sum := add(sum, mload(add(dataPtr, mul(i, 0x20))))
}
}
}

收益: 在大循环中,每次迭代可节省约 100-200 Gas


B. 利用草稿区避免内存分配

Solidity 的 stringbytes 类型非常昂贵,因为它们涉及大量的内存复制,如果设计内存空间不大,可以直接在草稿区操作

场景: 比如需要哈希两个输入参数。

普通 Solidity 写法:

1
2
3
4
// 数据会先被复制到 Memory,产生内存扩展费和复制费
function hashSolidity(uint a, uint b) external pure returns (bytes32) {
return keccak256(abi.encodePacked(a, b));
}

Opcode 优化写法:

1
2
3
4
5
6
7
8
9
10
11
12
function hashAssembly(uint a, uint b) external pure returns (bytes32 result) {
assembly {
// 1. 利用 Scratch Space (0x00 - 0x40)
// 这是一个专门给哈希计算用的临时草稿区,不需要分配内存

mstore(0x00, a) // 把 a 写入内存 0x00
mstore(0x20, b) // 把 b 写入内存 0x20

// 2. 直接哈希这 64 字节
result := keccak256(0x00, 0x40)
}
}

收益: 节省了 abi.encodePacked 带来的内存分配(MSTORE)和指针移动开销,且没有触碰 Free Memory Pointer。


C. 判断是否为合约地址 (isContract)

这是一个非常常见的检查。Solidity 标准库通常会用 addr.code.length > 0

普通 Solidity 写法:

1
2
3
function isContract(address account) external view returns (bool) {
return account.code.length > 0;
}

Opcode 优化写法:

1
2
3
4
5
6
7
function isContractAssembly(address account) external view returns (bool result) {
assembly {
// extcodesize 是一个直接的 Opcode,获取目标地址的代码大小
// gt 是 greater than (>)
result := gt(extcodesize(account), 0)
}
}

收益: 避免了 Solidity 包装器带来的一些栈操作和类型转换开销,更加直接。

方式很多,不一一列举了….


参考:

  1. WTF 学院的 Opcodes 教程
  2. 模拟 Opcode 执行