Ethereum learning path · 08.03

Solidity 如何变成 Bytecode?Source → Compiler → Opcodes → EVM → State

你写的是变量、函数与条件;以太坊节点看到的却只是一串十六进制字节。 本课把两者之间的黑箱打开:代码怎样被翻译、部署、选择入口,并在每个节点上得到同一个状态结果。

  • 08 · 03 EVM 核心技术
  • 82 min 从源代码到状态
  • 2 labs 字节码与执行轨迹
一次翻译,一次执行 target: EVM
contract Counter {
  uint256 public count;
  function add(uint256 x) external {
    count += x;
  }
}
human intent .sol
translation solc
machine input 0x60…55

EVM 不认识 function 或变量名。它只按程序计数器读取 Opcode, 操作 256 位栈项,并在成功结束时提交状态变化。

Representation

源码是意图,Bytecode 才是执行对象

链上通常保存编译后的运行字节码,而不是带变量名、注释和排版的 Solidity 源文件。

Two lives

部署代码与运行代码是两段生命

创建时先执行 Init Code;它返回的 Runtime Bytecode 才被放进合约账户,等待日后调用。

Determinism

执行是可重放的状态转换

相同前置状态、交易与区块上下文必须产生相同结果,否则节点无法对同一条链达成一致。

00 · Orientation

先把“代码运行”拆成一条完整因果链

从人类可读的表达,到所有节点可重复的状态转换,中间没有魔法,只有一层层明确的数据表示。

目录定位:第八章第三项 · Solidity 代码如何经编译形成 Bytecode 并由 EVM 执行

当你点击“部署”或“调用”时,网络不会收到一个 Solidity 文件。 钱包发送的是交易;交易携带字节;执行客户端按当前协议规则解释这些字节。

可以先把 Solidity 想成乐谱,把编译器想成制谱与翻译系统,把 Bytecode 想成演奏机器真正读取的打孔带。源代码帮助人类表达与审查;Bytecode 才是共识系统需要逐字节解释的机器程序。

先记住这个定义

Bytecode 是一串字节。某些字节被解释为 Opcode,某些字节是前一个 PUSH1 到 PUSH32 指令携带的立即数。边界必须按指令规则解码,不能把每个字节都独立当作指令。

“全网重复执行”不是所有电脑同时跑 Solidity 编译器

编译通常发生在链下,由开发者或构建系统完成。进入区块后,执行节点拿到的是交易与链上代码, 按相同协议规则重放 EVM 执行。共识依赖的是执行结果一致,而不是每个节点重新编译你的源文件。

输入边界

确定性不表示合约只能读取常数。msg.sender、msg.value、 区块号和时间戳等都可以参与计算,但它们必须来自所有节点都能获得并验证的交易或区块上下文。 EVM 不能随意读取本机时钟、文件系统或互联网。

01 · Translation

编译不是“把单词换成十六进制”

编译器先理解程序结构与类型,再生成能在栈式虚拟机上运行的控制流与数据操作。

一行 count += x 最终可能展开成参数解码、读取存储、溢出检查、加法、写回存储和错误分支。 高级语义与机器指令之间通常不是一对一关系。

同一份源代码,为什么可能得到不同 Bytecode?

Compiler

编译器版本

代码生成器、错误检查与优化器会演进;精确复现必须锁定完整版本。

Settings

优化与目标版本

是否启用优化、runs、viaIR 与 evmVersion 都会改变生成结果。

Linking

库地址与元数据

外部库链接、文件路径乃至元数据中的源码哈希都可能改变最终字节。

可复现构建的核心

“源代码看起来一样”不够。验证链上 Bytecode 时,需要源码集合、编译器版本、 优化配置、目标 EVM、库地址和元数据设置共同匹配。

02 · Artifacts

编译器交付的不是一条字符串,而是一只工具箱

前端、部署器、区块浏览器和调试器各自使用不同产物。

新手常把 ABI、Bytecode 与源码验证混成一件事。把四种核心产物分开, 后面的部署与调用就会突然清晰。

ABI
一份接口描述:有哪些函数、参数与返回类型,事件和自定义错误如何编码。 它帮助工具构造或解释数据,本身不是 EVM 执行程序。
Creation Bytecode
创建字节码,也常称 Init Code 的主体。它包含构造逻辑以及“如何返回运行字节码”的程序。 部署交易还会在其末尾追加 ABI 编码的构造参数。
Runtime Bytecode
运行时字节码,也常在编译器输出中叫 deployedBytecode。 创建成功后,它成为合约账户的代码,之后每次普通调用都从这里开始。
Metadata & Source Map
元数据记录编译器、设置、源码哈希、ABI 与文档;源映射把指令位置关联回源码范围, 为验证、调试和错误定位提供桥梁。
哪种产物回答哪个问题?
问题 主要产物 不是它
怎样编码 add(5)? ABI ABI 不会在链上替你执行加法
怎样创建合约? Creation Bytecode + 构造参数 不是直接把 Runtime Bytecode 原样“贴到地址”
以后调用时执行什么? Runtime Bytecode 不会重新执行构造函数
怎样把链上字节连回源码? Metadata + Source Map + 构建设置 函数名并不会天然保留在 Opcode 中
Metadata 尾部

Solidity 默认会把 CBOR 编码的元数据信息追加到创建与运行字节码末尾。 因为元数据包含源码相关哈希,文件路径或空白变化也可能间接改变最终 Bytecode。

03 · Two lives

部署与调用,走的是两条不同路径

一次性运行的 Init Code 负责“建房”;长期保存的 Runtime Bytecode 负责“营业”。

Creation Bytecode 为什么要“返回” Runtime Bytecode?

合约创建本身就是一次 EVM 执行。协议不会假设编译器把构造函数放在哪里, 只关心 Init Code 最终返回了哪些字节。返回数据若满足当前网络规则,就被收进新合约账户的代码字段。 构造函数只在这次创建执行中运行,之后不会成为一个可再次调用的普通函数。

creation input = creation bytecode ‖ ABI(constructor arguments) 符号 ‖ 表示字节拼接;构造参数不是 Runtime Bytecode 的一部分
代理合约并没有改写这条规律

可升级代理通常保持代理地址的 Runtime Bytecode 不变,通过存储中的实现地址与 DELEGATECALL 选择另一份逻辑代码。看起来“升级”的是行为路由, 不是普通调用直接覆盖了原地址的代码字节。

04 · Byte anatomy

一串十六进制,怎样切成真正的指令?

两个十六进制字符表示一个字节;Opcode 定义了当前字节的含义,也可能决定后面多少字节只是数据。

看懂 Bytecode 的第一原则不是背表,而是维护程序计数器 PC: 读取当前 Opcode,若它是 PUSHn,就把紧随的 n 个字节作为立即数跳过。

Interactive lab

逐条拆解一段最小 Bytecode

LAB · 01

这段手写 Runtime Bytecode 把 2 和 3 相加,将结果写入 Memory, 再返回一个 32 字节的数值 5。它不是 Solidity 编译产物,只用于隔离 Opcode 机制。

0x600260030160005260206000f3

尚未读取。第一字节 0x60 表示 PUSH1,因此下一字节 0x02 是数据。

Program counter —
等待开始 —

每次点击读取一条完整指令,而不是机械地前进一个字节。

完整静态解码,未启用 JavaScript 也可阅读
PC 字节 指令 作用
060 02PUSH1 0x02把 2 压栈;PC 前进 2
260 03PUSH1 0x03把 3 压栈
401ADD弹出两项,相加后压入 5
560 00PUSH1 0x00准备 Memory 偏移 0
752MSTORE把 5 写入 memory[0:32]
860 20PUSH1 0x20准备返回长度 32
1060 00PUSH1 0x00准备返回偏移 0
12f3RETURN返回 memory[0:32]

Opcode、操作数与立即数

Opcode

0x01 = ADD

指令本身只有一个字节,操作数从栈顶取得,结果也放回栈顶。

Immediate

0x60 0x02

0x60 是 PUSH1;后面的 0x02 是数据,不应再解释成独立 Opcode。

Halting

STOP / RETURN / REVERT

它们以不同方式结束当前执行帧;“结束”不总等于“提交”。

05 · Calldata

Runtime Bytecode 怎样知道你要调用哪个函数?

交易没有一个原生的“函数名”字段;函数入口被编码在只读输入数据 Calldata 里。

调用 add(5) 时,工具依据 ABI 生成 4 字节函数选择器, 再拼接一个 32 字节参数槽。合约的分发逻辑读取前 4 字节,与已知选择器比较并跳转。

selector = first4bytes(keccak256("add(uint256)")) = 0x1003e2d2 返回类型不参与 selector;类型必须使用规范化写法,且 4 字节理论上可能碰撞
selector · 4 bytes
argument · 32 bytes
1003e2d2
0000000000000000000000000000000000000000000000000000000000000005
  1. 检查长度 若 Calldata 少于 4 字节,常规函数 selector 不存在,代码会进入 receive、fallback 或回滚分支。
  2. 读取前 32 字节并右移 CALLDATALOAD(0) 取一整个 256 位字,再保留最高 4 字节作为 selector。
  3. 比较并条件跳转 用 EQ 与 JUMPI 匹配对应函数入口;真实编译器可能生成线性、二分或其他分发结构。
  4. 从偏移 4 解码参数 静态 uint256 直接占一个 32 字节槽;动态类型则用 offset 指向尾部数据区。
ABI 不是自描述格式

单看一串参数字节,无法可靠知道它原来是地址、整数还是偏移。 解码者需要 ABI 或等价的类型模式。Selector 数据库只能给出候选签名,不能消除 4 字节碰撞的可能。

06 · Execution frame

每条 Opcode 都在一个执行帧里工作

Stack 负责运算,Memory 负责临时缓冲,Calldata 提供只读输入,Storage 承担持久状态。

不要再问“变量到底在栈还是内存”而忽略编译器

Solidity 的数据位置是一层高级语言规则,编译器会根据类型、生命周期与优化策略安排具体操作。 一个源码变量的值可能在不同时间经过 Calldata、Stack、Memory,最后写入 Storage; 它不一定在整个函数中固定待在某一处。

四个空间的生命周期与共识意义
空间 可修改 存活范围 典型用途
Stack是当前执行帧算术、比较、偏移、控制流参数
Memory是当前执行帧ABI 解码、返回值、哈希输入、临时数组
Calldata否当前消息调用函数选择器与调用参数
Storage是合约状态,成功后持久余额、权限、配置与业务状态
还有 Transient Storage

现代 EVM 还提供 TLOAD/TSTORE:同一笔交易的内部调用之间可共享, 交易结束后清空,不进入持久世界状态。它不是 Memory 的别名,也不是永久 Storage。

07 · Execution

现在逐步执行 Counter.add(5)

我们追踪机器必须完成的语义步骤,同时保留“教学模型”和“特定编译结果”之间的边界。

概念轨迹,不是反汇编

下方是为了理解数据流而压缩的概念轨迹,不是某个特定 solc 版本与优化设置的逐字节反汇编。 Solidity 0.8 的真实输出还可能包含非 payable 检查、参数边界检查、溢出检查、共享错误分支与优化器重排。

Interactive lab

从 Selector 到 Storage Slot 0

LAB · 02

假设调用前 storage[0] = 7,Calldata 要求执行 add(5)。前后步进,观察 Stack 与 Storage 的角色变化。

步骤 1 / 8:进入执行帧。

Current action FRAME START

PC 从 0 开始;Stack 与 Memory 为空,Calldata 已固定。

Stack · bottom → top
empty
Storage slot 0 7 · unchanged

写入先进入本次执行的状态日志;只有成功结束才成为新世界状态。

Context calldata ready

0x1003e2d2 + ABI(uint256 5)

执行循环的共同骨架

  1. Fetch 按 PC 从代码中取 Opcode;若是 PUSHn,同时读取 n 个立即数字节。
  2. Validate & Charge 检查栈深度、静态调用限制等前置条件,并从剩余 Gas 中扣除本步成本。
  3. Execute 读取或修改 Stack、Memory、Storage 日志、返回数据和子调用帧。
  4. Advance or Jump PC 前进到下一条指令,或按合法目标进行条件/无条件跳转。
  5. Halt STOP、RETURN、REVERT、异常或耗尽 Gas 结束当前帧,并把结果交回上层。

08 · Commit & revert

执行过,不代表状态一定留下

EVM 先计算一条候选状态变化;只有调用栈按规则成功完成,相关修改才提交。

SUCCESS · 状态提交

顶层执行成功,Storage 写入、余额变化与日志进入交易结果;客户端由此计算新的状态根。 返回数据交给调用方,交易 Receipt 的状态为成功。

REVERT / EXCEPTION · 状态回滚

当前失败范围内的状态写入与日志被撤销。已经消耗的计算资源不会倒流; 剩余 Gas 如何返回还取决于 REVERT、普通子调用失败或异常耗尽等具体路径。

为什么 Gas 不能只靠一张“Opcode 固定价格表”?

某些简单指令有稳定的基础成本,但真实费用还受到 Memory 扩张、账户或存储槽的 warm/cold 状态、 SSTORE 的旧值与新值、复制长度、哈希长度、子调用与协议升级影响。 Gas Schedule 属于共识规则;编译器优化则决定程序最终走过哪些指令。

同一个助记符,成本为什么可能动态变化
操作 基础含义 动态因素
MSTORE写 32 字节 Memory若触及更高地址,需要支付 Memory 扩张成本
SLOAD读取 Storage Slot同一交易中该槽是否已 warm
SSTORE改变 Storage Slot原值、当前值、新值及 refund 规则
CALL创建子调用帧目标冷热状态、转账、内存、子调用预算与返回数据
确定性的真正含义

动态成本不等于不确定。只要前置状态、交易、区块上下文和协议版本相同, 每个节点都能算出相同的 warm/cold 集合、指令路径、Gas 消耗、返回数据与最终状态。

09 · Verification

面对真实合约,怎样把源码与链上字节重新接起来?

不要从“页面显示 Verified”直接跳到“代码绝对安全”;先理解验证究竟证明了什么。

源码验证的核心是可复现:用声明的源码与编译设置重新构建, 再把结果与创建交易或链上 Runtime Bytecode 比较。

  1. 读取链上代码 用标准 RPC eth_getCode 获取目标地址在指定区块的代码字节;代理地址和实现地址要分别检查。
  2. 找到创建上下文 识别创建交易或工厂的 CREATE/CREATE2,区分 Creation Bytecode、构造参数与最后保存的 Runtime Bytecode。
  3. 锁定完整构建设置 编译器完整版本、优化器、runs、viaIR、evmVersion、库地址、remapping 与元数据模式缺一不可。
  4. 比较 Bytecode 处理库链接和不可变量引用,理解 metadata 尾部,再判断是完整匹配还是仅运行代码匹配。
  5. 再做安全审查 验证只说明某份源码能够产生这份字节,不证明权限合理、经济模型正确或不存在漏洞。

Trace

从交易向下看

执行跟踪显示 PC、Opcode、Gas、调用帧与状态访问;调试 API 的具体格式依客户端而异。

Source map

从指令向上看

源映射把字节码位置关联到源码范围,但优化会合并、移动或消除代码,映射不是逐行录屏。

四个常见误区

链上有 Bytecode ≠ 链上天然有源码;有 ABI ≠ 能证明实现;源码已验证 ≠ 合约安全; 合约地址不变 ≠ 业务逻辑永远不变,代理、DELEGATECALL 与外部依赖都可能改变实际执行路径。

10 · Recap

把整课压缩成一条可重放的因果链

如果你能从交易输入一直解释到状态根变化,这一课就真正连通了。

Source → solc(settings) → artifacts → transaction input → Runtime Bytecode → Opcode trace → commit / revert 每一支箭头都有明确输入输出,因此可以检查、重建与审计

六题校准

1. 普通调用一个已部署 Solidity 合约时,EVM 直接执行什么?

2. 创建合约时,哪项描述正确?

3. 字节序列 60 02 应怎样解码?

4. add(uint256) 的前 4 字节 Selector 来自哪里?

5. 哪个空间在成功提交后能跨交易保留 count?

6. 合约执行到 REVERT,哪句话最准确?

11 · Reference

术语与一手资料

把页面中的教学模型重新连回协议、EIP 与 Solidity 编译器原文。

核心术语

Bytecode
按 EVM 规则解释的字节序列;包含 Opcode 及 PUSH 指令携带的立即数。
Opcode
单字节操作码,定义栈、内存、状态、环境、控制流或调用操作。
Creation Bytecode
创建阶段执行的代码主体,负责构造逻辑并返回 Runtime Bytecode。
Runtime Bytecode
创建成功后保存到合约账户、供普通消息调用执行的代码。
ABI
合约接口的数据编码约定与 JSON 描述,不是可执行代码。
Calldata
消息调用的只读输入字节,常由 4 字节 selector 与 ABI 编码参数组成。
Program Counter
指向下一条要执行指令的字节偏移,缩写 PC。
Source Map
把字节码指令位置关联回源码范围的编译器产物。
Execution Frame
一次消息调用的局部执行环境,含 PC、Stack、Memory、Calldata、Gas 与返回数据等。
Determinism
相同可验证输入与协议规则必定产生相同执行结果。

官方与标准原文

  1. ethereum.org · Ethereum Virtual Machine 状态转换函数、1024 深度的 256 位栈、Memory、Transient Storage、Storage 与 Opcode 总览。
  2. ethereum.org · Opcodes for the EVM Opcode 字节、栈输入输出、基础与动态 Gas 注记的官方参考页。
  3. Solidity · Introduction to Smart Contracts 合约创建执行代码并把返回数据保存为合约代码,以及 Stack、Memory、Storage 的说明。
  4. Solidity · Contract ABI Specification 函数选择器、静态与动态类型参数、返回值、事件与错误的标准编码。
  5. Solidity · Using the Compiler Standard JSON 输入输出、optimizer、evmVersion、viaIR、bytecode 与 deployedBytecode 等字段。
  6. Solidity · Contract Metadata 元数据结构、源码与设置、CBOR 尾部和源代码验证关系。
  7. Solidity · Layout in Memory Solidity 对 EVM Memory 的保留区域、自由内存指针与临时空间约定。
  8. Solidity · Layout of State Variables in Storage 状态变量如何装入 Storage Slot,Mapping 与动态数组如何定位。
  9. EIP-170 · Contract code size limit 主网合约 Runtime Code 大小限制及其资源安全动机。
  10. EIP-3860 · Limit and meter initcode Init Code 的计量、创建交易与 CREATE/CREATE2 的执行规则。
  11. EIP-2929 · Gas cost increases for state access opcodes 交易级 warm/cold 状态如何影响账户与 Storage 访问成本。
  12. Ethereum Execution Layer Specifications 以可执行 Python 规范描述各网络升级下的状态转换与 EVM 规则。
  13. Ethereum Execution APIs · eth_getCode 按地址和区块读取账户代码的标准 JSON-RPC 方法。
  14. Sourcify · How to Verify 源码、Metadata、创建输入与部署后 Bytecode 的可复现验证流程。

Lesson complete · 08.03

一段 Solidity 最终不是被“读懂”,而是被精确地重放。

返回书架