03 · EVM 原理:Storage / Memory / Stack / Opcode / Bytecode / ABI
03 · EVM 原理:Storage / Memory / Stack / Opcode / Bytecode / ABI(★★★★★)
真正开始。Solidity 只是 EVM 的高级语言;不理解 EVM 本身,你写的每一行 Solidity 都只是"抄语法",遇到 gas 优化、重入攻击、代理升级这些问题时会完全没有方向感。 与
notes/07-EVM与Gas.md、notes/08-智能合约.md互补:那两篇讲交易生命周期与合约调用细节, 本篇聚焦 EVM 的"内存模型 + 指令集 + 编译产物"这条主线。
0. 一句话本质
EVM 是一台"每个节点都独立运行、结果必须完全一致"的确定性虚拟机。 Storage/Memory/Stack 是它的三种数据存放地,Opcode 是它唯一能听懂的语言, Bytecode 是 Solidity 编译后真正跑在链上的东西,ABI 是"外部世界怎么和这台机器说话"的约定。 你对合约的一切"高级操作",最终都会被拆解成这几样东西——这也是为什么读懂它们, 就能读懂任何一份反编译的字节码或一笔"看不懂在干嘛"的交易。
1. 三种数据存放地:Storage / Memory / Stack
| 存放地 | 生命周期 | 读写 gas 成本 | 套利工程师要记住的点 |
|---|---|---|---|
| Storage | 永久(跨交易保存在链上状态里) | 极贵:SSTORE 从 0→非0 约 20000 gas,非0→非0 约 5000 gas;SLOAD 约 2100 gas(冷)/100 gas(热) |
合约状态变量都存这里;这是为什么"减少不必要的状态写入"是 gas 优化第一原则;也是为什么闪电贷/套利合约常用 memory 中转而非频繁读写 storage |
| Memory | 仅本次交易执行期间,函数返回后清空 | 较便宜,但按 32 字节字/线性增长(用得越多越贵,且是平方级增长的开销模型) | 函数内部的临时变量、calldata 解码后的中间结果放这里;memory 数组/struct 常见于函数参数与返回值 |
| Stack | 仅当前操作执行期间 | 最便宜,但最多 1024 层 | EVM 是基于栈的虚拟机,几乎所有 opcode 都是"弹出操作数、压入结果";超过 1024 层会报 stack too deep——这是 Solidity 里"局部变量太多导致编译报错"的直接原因 |
一句话记忆:Storage 是"硬盘"(贵、持久),Memory 是"内存"(中等、临时),Stack 是"CPU 寄存器"(快、极临时)。
2. Opcode:EVM 唯一能听懂的语言
Solidity 代码最终会被编译成一串 opcode(操作码),每个 opcode 是一个字节(0x00-0xff)。
| Opcode | 作用 | 套利/工程视角 |
|---|---|---|
PUSH1-PUSH32 |
把常量压入栈 | 合约里所有字面量常数最终都是 PUSH 指令,字节码长度和它数量正相关 |
POP |
弹出栈顶丢弃 | 清理不需要的返回值 |
CALL |
调用另一个合约,新建独立的执行上下文(自己的 storage 视角) | 外部调用的标准方式;开销高(尤其冷地址访问),是 gas 大头 |
STATICCALL |
只读调用,禁止任何状态改变 | 常用于安全地读取外部合约数据(如查询价格)而不怕被"reentrancy"影响状态 |
DELEGATECALL |
调用另一个合约的代码,但用调用者自己的 storage 上下文执行 | 代理合约升级模式的核心(见 04-Solidity进阶.md);风险极高——被调用方能操纵调用者的 storage |
SSTORE / SLOAD |
写/读 storage | 全 EVM 最贵的读写操作,也是 gas 优化的主战场 |
RETURN |
正常返回并结束执行,返回一段数据 | 函数正常执行完毕的出口 |
REVERT |
回滚本次调用的所有状态改变,返回错误数据(可带自定义 error) | Solidity 的 require/revert 最终都编译成这个;回滚只影响本次 CALL 帧,不影响外层交易已经确认发生的 gas 消耗——这是为什么"revert 不退 gas" |
CREATE / CREATE2 |
部署新合约 | CREATE 地址由部署者地址+nonce 决定,CREATE2 地址由部署者+salt+字节码哈希决定(可提前算出,见 02-密码学基础.md §1) |
LOG0-LOG4 |
写入事件日志(Event) | 机器人监听链上事件(如 Transfer、Swap)就是订阅这些 log,不改变任何 storage,成本远低于 SSTORE |
为什么这些细节重要:读一份反编译字节码/分析一笔"看不出在干嘛"的可疑交易时,
你需要知道它是不是在用 DELEGATECALL(可能是代理升级或攻击)、有没有异常多的 SSTORE(可能在做批量状态迁移),
这是链上核查(notes/18-链上核查方法论.md)能落到字节码层面的基本功。
3. Bytecode:从 Solidity 到链上的完整链路
Solidity 源码 (.sol)
↓ 编译(solc)
Bytecode(EVM 能执行的十六进制字节流,含 constructor 部分 + runtime 部分)
↓ 部署交易(data 字段 = bytecode)
链上账户(该地址的 code 字段永久保存 runtime bytecode)
- Init code(constructor):部署交易的
data字段,只执行一次,负责初始化 storage, 执行完后把runtime bytecode通过RETURN返回,链才把它存为该地址的永久代码。 - Runtime bytecode:之后每次外部调用这个地址,实际执行的是这段代码。
- Etherscan 上"Contract"标签页显示的就是 runtime bytecode 反编译/源码验证后的结果; 没有源码验证的合约,你只能拿到 bytecode,需要反编译工具(如 Dedaub、Heimdall)辅助分析。
4. ABI:外部世界怎么和 EVM 对话
是什么:Application Binary Interface——描述"函数怎么被编码调用、参数怎么排列"的规范。 EVM 本身不认识"函数名",只认识一串字节;ABI 就是把"人类可读的函数签名"转成"EVM 认的字节"的翻译规则。
编码规则:
- 函数选择器 =
Keccak256(函数签名)的前 4 字节。 例如swap(address,uint256)→Keccak256("swap(address,uint256)")取前 4 字节 →0x38ed1739(示例)。 - 之后每个参数按类型编码成 32 字节的字(不足补零,动态类型如
bytes/string/数组另有偏移量规则), 拼接在 selector 后面。 - 完整的
calldata=selector (4字节) + 编码后的参数 (32字节的整数倍)。
为什么套利工程师必须会手动解码 ABI:
- 分析高手交易,第一步就是拿到该笔
input data,decode ABI:先查 4 字节 selector 对应哪个函数 (可去 4byte.directory 反查,或者如果合约已开源直接对照 ABI JSON),再把后面的字节按参数类型切开。 - 很多"匿名合约"没有验证源码,你只能通过 decode 出的参数值(金额、地址、路径数组)反推它在做什么操作, 这比"看不懂就放弃"专业得多。
- 你自己写机器人调用合约时,也是在手动或用库(ethers.js/web3.py/viem)构造这份 calldata—— 理解底层编码规则,能让你在库出问题/需要自定义调用非标准合约时不抓瞎。
5. Gas:EVM 世界的"计价单位"
- 每个 opcode 都有固定/动态的 gas 消耗(如上表);一笔交易执行完所有 opcode 消耗的 gas 总和 乘以 gas price(EIP-1559 后是 base fee + priority fee)就是你付出的成本。
- Gas 优化的本质就是"减少 opcode 执行次数,尤其减少
SSTORE/CALL/SLOAD这类贵操作"。 - 这也是为什么套利合约常见"打包多次操作进一次交易""缓存 storage 读取到 memory 变量里再读写"—— 都是围绕这张 opcode 价目表做的工程决策。
6. 前置名词小词典
- 调用上下文(Execution Context):一次
CALL建立的独立"当前地址/storage 视角/msg.sender"环境。 - Calldata:外部调用传入的原始字节数据,只读、不可修改,比 memory 更省 gas 的输入区。
- 函数选择器(Selector):函数签名哈希的前 4 字节,calldata 的"路由头"。
- 冷/热访问(Cold/Warm Access):EIP-2929 引入,同一交易内首次访问某地址/存储槽更贵(冷), 之后访问更便宜(热)——这是很多"批量操作比分开操作省 gas"的直接原因。
7. 常见误区
- ❌ "revert 会退还所有 gas" → 只退还未消耗的 gas,已执行的 opcode 消耗不退。
- ❌ "只要合约没开源就无法分析" → 字节码本身可反编译、calldata 本身可按 ABI 规则手动解码,源码只是"锦上添花"。
- ❌ "Memory 免费" → Memory 按线性/平方级增长计费,大数组/大量临时变量同样烧 gas。
- ❌ "DELEGATECALL 就是普通调用换个名字" → 它是"借用对方代码,操作自己的 storage", 这是代理合约升级模式的基础,也是历史上多起资金被清空攻击(如 Parity 多签钱包事件)的根源。
8. 与套利实践的对应 / 自检问题
对应:04-Solidity进阶.md(Assembly 直接操作 opcode)、10-MEV.md(gas 竞价的本质是给这套 opcode 计价系统付费)、
11-量化套利系统.md("Gas预测"模块就是在预测一笔交易会触发哪些 opcode、消耗多少 gas)。
自检:
- 为什么同样一次
swap,第二次调用同一个池子往往比第一次省 gas? CALL和DELEGATECALL在"谁的 storage 被改变"这一点上有什么区别?- 给你一个
0x38ed1739...开头的 calldata,你会怎么一步步还原出它调用的函数和参数? - 为什么
REVERT不能让你的交易完全"免费"?
── Q&A / 更正记录区(按日期追加)──
原始讨论记录见
Q&A/(按日期归档,保留完整来回过程);本节是同步后的结论摘要, 只增不删——若后续发现本篇正文表述不准确,按更正记录追加在此,不删改正文, 保留"原来写的是什么 → 后来发现哪里不准 → 更正为什么"的轨迹。规范见Q&A/README.md。
- (2026-07-26 建立。暂无 Q&A——后续围绕本篇的提问/质疑/更正在此累积。)