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,某些字节是前一个 PUSH1PUSH32 指令携带的立即数。边界必须按指令规则解码,不能把每个字节都独立当作指令。

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

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

输入边界

确定性不表示合约只能读取常数。msg.sendermsg.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. 比较并条件跳转 EQJUMPI 匹配对应函数入口;真实编译器可能生成线性、二分或其他分发结构。
  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 最终不是被“读懂”,而是被精确地重放。

返回书架