03 · EVM 原理:Storage / Memory / Stack / Opcode / Bytecode / ABI(★★★★★)

真正开始。Solidity 只是 EVM 的高级语言;不理解 EVM 本身,你写的每一行 Solidity 都只是"抄语法",遇到 gas 优化、重入攻击、代理升级这些问题时会完全没有方向感。 与 notes/07-EVM与Gas.mdnotes/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) 机器人监听链上事件(如 TransferSwap)就是订阅这些 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 认的字节"的翻译规则。

编码规则

  1. 函数选择器 = Keccak256(函数签名)前 4 字节。 例如 swap(address,uint256)Keccak256("swap(address,uint256)") 取前 4 字节 → 0x38ed1739(示例)。
  2. 之后每个参数按类型编码成 32 字节的字(不足补零,动态类型如 bytes/string/数组另有偏移量规则), 拼接在 selector 后面。
  3. 完整的 calldata = selector (4字节) + 编码后的参数 (32字节的整数倍)

为什么套利工程师必须会手动解码 ABI

  • 分析高手交易,第一步就是拿到该笔 input datadecode 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)。

自检

  1. 为什么同样一次 swap,第二次调用同一个池子往往比第一次省 gas?
  2. CALLDELEGATECALL 在"谁的 storage 被改变"这一点上有什么区别?
  3. 给你一个 0x38ed1739... 开头的 calldata,你会怎么一步步还原出它调用的函数和参数?
  4. 为什么 REVERT 不能让你的交易完全"免费"?

── Q&A / 更正记录区(按日期追加)──

原始讨论记录见 Q&A/(按日期归档,保留完整来回过程);本节是同步后的结论摘要, 只增不删——若后续发现本篇正文表述不准确,按更正记录追加在此,不删改正文, 保留"原来写的是什么 → 后来发现哪里不准 → 更正为什么"的轨迹。规范见 Q&A/README.md

  • (2026-07-26 建立。暂无 Q&A——后续围绕本篇的提问/质疑/更正在此累积。)