Representation
源码是意图,Bytecode 才是执行对象链上通常保存编译后的运行字节码,而不是带变量名、注释和排版的 Solidity 源文件。
从0到深入理解以太坊
第八章 · 第三课
Ethereum learning path · 08.03
你写的是变量、函数与条件;以太坊节点看到的却只是一串十六进制字节。 本课把两者之间的黑箱打开:代码怎样被翻译、部署、选择入口,并在每个节点上得到同一个状态结果。
contract Counter { uint256 public count; function add(uint256 x) external { count += x; } }
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 指令携带的立即数。边界必须按指令规则解码,不能把每个字节都独立当作指令。
变量名、类型、函数、注释与业务意图。
解析、检查、优化并选择目标 EVM 版本。
ABI、创建字节码、运行字节码、元数据。
部署时携带 Init Code;调用时携带 Calldata。
逐条执行、计量 Gas,成功后提交状态。
编译通常发生在链下,由开发者或构建系统完成。进入区块后,执行节点拿到的是交易与链上代码, 按相同协议规则重放 EVM 执行。共识依赖的是执行结果一致,而不是每个节点重新编译你的源文件。
确定性不表示合约只能读取常数。msg.sender、msg.value、
区块号和时间戳等都可以参与计算,但它们必须来自所有节点都能获得并验证的交易或区块上下文。
EVM 不能随意读取本机时钟、文件系统或互联网。
01 · Translation
编译器先理解程序结构与类型,再生成能在栈式虚拟机上运行的控制流与数据操作。
一行 count += x 最终可能展开成参数解码、读取存储、溢出检查、加法、写回存储和错误分支。
高级语义与机器指令之间通常不是一对一关系。
把字符组成 Token,再建立抽象语法树 AST。
解析作用域、继承、类型、安全规则与错误。
可经 Yul IR,也可走另一代码生成路径。
消除冗余、改写控制流并权衡部署与运行成本。
输出 Opcode 序列、立即数、链接引用和源映射。
Compiler
代码生成器、错误检查与优化器会演进;精确复现必须锁定完整版本。
Settings
是否启用优化、runs、viaIR 与 evmVersion 都会改变生成结果。
Linking
外部库链接、文件路径乃至元数据中的源码哈希都可能改变最终字节。
“源代码看起来一样”不够。验证链上 Bytecode 时,需要源码集合、编译器版本、 优化配置、目标 EVM、库地址和元数据设置共同匹配。
02 · Artifacts
前端、部署器、区块浏览器和调试器各自使用不同产物。
新手常把 ABI、Bytecode 与源码验证混成一件事。把四种核心产物分开, 后面的部署与调用就会突然清晰。
deployedBytecode。
创建成功后,它成为合约账户的代码,之后每次普通调用都从这里开始。
| 问题 | 主要产物 | 不是它 |
|---|---|---|
怎样编码 add(5)? |
ABI | ABI 不会在链上替你执行加法 |
| 怎样创建合约? | Creation Bytecode + 构造参数 | 不是直接把 Runtime Bytecode 原样“贴到地址” |
| 以后调用时执行什么? | Runtime Bytecode | 不会重新执行构造函数 |
| 怎样把链上字节连回源码? | Metadata + Source Map + 构建设置 | 函数名并不会天然保留在 Opcode 中 |
Solidity 默认会把 CBOR 编码的元数据信息追加到创建与运行字节码末尾。 因为元数据包含源码相关哈希,文件路径或空白变化也可能间接改变最终 Bytecode。
03 · Two lives
一次性运行的 Init Code 负责“建房”;长期保存的 Runtime Bytecode 负责“营业”。
合约创建本身就是一次 EVM 执行。协议不会假设编译器把构造函数放在哪里, 只关心 Init Code 最终返回了哪些字节。返回数据若满足当前网络规则,就被收进新合约账户的代码字段。 构造函数只在这次创建执行中运行,之后不会成为一个可再次调用的普通函数。
可升级代理通常保持代理地址的 Runtime Bytecode 不变,通过存储中的实现地址与
DELEGATECALL 选择另一份逻辑代码。看起来“升级”的是行为路由,
不是普通调用直接覆盖了原地址的代码字节。
04 · Byte anatomy
两个十六进制字符表示一个字节;Opcode 定义了当前字节的含义,也可能决定后面多少字节只是数据。
看懂 Bytecode 的第一原则不是背表,而是维护程序计数器 PC:
读取当前 Opcode,若它是 PUSHn,就把紧随的 n 个字节作为立即数跳过。
Interactive lab
这段手写 Runtime Bytecode 把 2 和 3 相加,将结果写入 Memory, 再返回一个 32 字节的数值 5。它不是 Solidity 编译产物,只用于隔离 Opcode 机制。
尚未读取。第一字节 0x60 表示 PUSH1,因此下一字节 0x02 是数据。
—
每次点击读取一条完整指令,而不是机械地前进一个字节。
| PC | 字节 | 指令 | 作用 |
|---|---|---|---|
| 0 | 60 02 | PUSH1 0x02 | 把 2 压栈;PC 前进 2 |
| 2 | 60 03 | PUSH1 0x03 | 把 3 压栈 |
| 4 | 01 | ADD | 弹出两项,相加后压入 5 |
| 5 | 60 00 | PUSH1 0x00 | 准备 Memory 偏移 0 |
| 7 | 52 | MSTORE | 把 5 写入 memory[0:32] |
| 8 | 60 20 | PUSH1 0x20 | 准备返回长度 32 |
| 10 | 60 00 | PUSH1 0x00 | 准备返回偏移 0 |
| 12 | f3 | RETURN | 返回 memory[0:32] |
Opcode
0x01 = ADD指令本身只有一个字节,操作数从栈顶取得,结果也放回栈顶。
Immediate
0x60 0x020x60 是 PUSH1;后面的 0x02 是数据,不应再解释成独立 Opcode。
Halting
它们以不同方式结束当前执行帧;“结束”不总等于“提交”。
05 · Calldata
交易没有一个原生的“函数名”字段;函数入口被编码在只读输入数据 Calldata 里。
调用 add(5) 时,工具依据 ABI 生成 4 字节函数选择器,
再拼接一个 32 字节参数槽。合约的分发逻辑读取前 4 字节,与已知选择器比较并跳转。
CALLDATALOAD(0) 取一整个 256 位字,再保留最高 4 字节作为 selector。
EQ 与 JUMPI 匹配对应函数入口;真实编译器可能生成线性、二分或其他分发结构。
uint256 直接占一个 32 字节槽;动态类型则用 offset 指向尾部数据区。
单看一串参数字节,无法可靠知道它原来是地址、整数还是偏移。 解码者需要 ABI 或等价的类型模式。Selector 数据库只能给出候选签名,不能消除 4 字节碰撞的可能。
06 · Execution frame
Stack 负责运算,Memory 负责临时缓冲,Calldata 提供只读输入,Storage 承担持久状态。
后进先出。EVM 是栈机,大多数 Opcode 从栈顶取参数并把结果压回。
当前调用帧的临时、可变字节数组;新帧从空白开始,扩大范围会产生动态 Gas 成本。
当前消息调用携带的输入字节。读取便宜且不可原地修改,常含 selector 与 ABI 参数。
合约的持久键值空间,逻辑上把 256 位 slot 映射到 256 位值,并进入世界状态承诺。
Solidity 的数据位置是一层高级语言规则,编译器会根据类型、生命周期与优化策略安排具体操作。 一个源码变量的值可能在不同时间经过 Calldata、Stack、Memory,最后写入 Storage; 它不一定在整个函数中固定待在某一处。
| 空间 | 可修改 | 存活范围 | 典型用途 |
|---|---|---|---|
Stack | 是 | 当前执行帧 | 算术、比较、偏移、控制流参数 |
Memory | 是 | 当前执行帧 | ABI 解码、返回值、哈希输入、临时数组 |
Calldata | 否 | 当前消息调用 | 函数选择器与调用参数 |
Storage | 是 | 合约状态,成功后持久 | 余额、权限、配置与业务状态 |
现代 EVM 还提供 TLOAD/TSTORE:同一笔交易的内部调用之间可共享,
交易结束后清空,不进入持久世界状态。它不是 Memory 的别名,也不是永久 Storage。
07 · Execution
Counter.add(5)我们追踪机器必须完成的语义步骤,同时保留“教学模型”和“特定编译结果”之间的边界。
下方是为了理解数据流而压缩的概念轨迹,不是某个特定 solc 版本与优化设置的逐字节反汇编。
Solidity 0.8 的真实输出还可能包含非 payable 检查、参数边界检查、溢出检查、共享错误分支与优化器重排。
Interactive lab
假设调用前 storage[0] = 7,Calldata 要求执行
add(5)。前后步进,观察 Stack 与 Storage 的角色变化。
步骤 1 / 8:进入执行帧。
PC 从 0 开始;Stack 与 Memory 为空,Calldata 已固定。
写入先进入本次执行的状态日志;只有成功结束才成为新世界状态。
0x1003e2d2 + ABI(uint256 5)
08 · Commit & revert
EVM 先计算一条候选状态变化;只有调用栈按规则成功完成,相关修改才提交。
顶层执行成功,Storage 写入、余额变化与日志进入交易结果;客户端由此计算新的状态根。 返回数据交给调用方,交易 Receipt 的状态为成功。
当前失败范围内的状态写入与日志被撤销。已经消耗的计算资源不会倒流; 剩余 Gas 如何返回还取决于 REVERT、普通子调用失败或异常耗尽等具体路径。
某些简单指令有稳定的基础成本,但真实费用还受到 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 比较。
eth_getCode 获取目标地址在指定区块的代码字节;代理地址和实现地址要分别检查。
Trace
执行跟踪显示 PC、Opcode、Gas、调用帧与状态访问;调试 API 的具体格式依客户端而异。
Source map
源映射把字节码位置关联到源码范围,但优化会合并、移动或消除代码,映射不是逐行录屏。
链上有 Bytecode ≠ 链上天然有源码;有 ABI ≠ 能证明实现;源码已验证 ≠ 合约安全; 合约地址不变 ≠ 业务逻辑永远不变,代理、DELEGATECALL 与外部依赖都可能改变实际执行路径。
10 · Recap
如果你能从交易输入一直解释到状态根变化,这一课就真正连通了。
60 02 应怎样解码?add(uint256) 的前 4 字节 Selector 来自哪里?count?REVERT,哪句话最准确?11 · Reference
把页面中的教学模型重新连回协议、EIP 与 Solidity 编译器原文。
Lesson complete · 08.03