ABI 负责解释接口,creation bytecode 负责安装,runtime bytecode 才是部署后每次调用执行的代码。
Ethereum · Smart contract lifecycle
一份合约,
怎样变成链上程序?
源码不会直接在链上运行。它先被编译成多种产物,再由一笔创建交易运行一次性的初始化程序, 最后只把 runtime code 留在合约地址上。理解这条链,才真正理解“部署、调用与升级”。
uint value;
function set(...);
}
creation bytecode
runtime bytecode
input: initcode
constructor: once
runtime code
storage
implementation slot
V1 → V2
部署交易运行 initcode 与 constructor;只有它们成功返回的 runtime code 才会被保存到新地址。
代理地址和 storage 保持不变,只把 implementation 指针从 V1 改到 V2,再用 delegatecall 执行新逻辑。
Mental model
先把七个对象分开
初学者最常见的混乱,不是不懂某个词,而是把源码、ABI、字节码、状态和回执都想成“合约”。 它们其实位于不同阶段,承担完全不同的工作。
给人阅读、审查、测试与编译。源文件本身不会被 EVM 直接执行。
描述函数、参数、返回值、事件与错误怎样编码。它通常保存在链下。
创建阶段的一次性程序;负责初始化并返回最终 runtime code。
部署成功后保存在地址上的代码;以后每次调用都从它开始执行。
与合约地址绑定、跨交易持续存在的数据。代码按 slot 读取和写入它。
调用输入:通常由 4 字节 selector 与 ABI 编码参数组成。
交易进入区块后的执行摘要与事件日志,不等同于函数返回值。
ABI 不是可执行代码,也不会自动成为链上的“接口注册表”。 链上合约账户主要关联 balance、nonce、code 与 storage;人类可读源码和 ABI 要靠发布、验证与工具建立对应关系。
你可以把整个生命周期想成安装软件:Solidity 是源项目,编译器生成“说明书”和“安装包”, 部署交易运行安装包,安装包最后把真正的程序留在一个确定地址。之后,用户不再发送函数名; 钱包把人类意图编码为字节,EVM 依据 runtime code 解释这些字节。
这也提前回答了本课最大的悬念:普通合约的 runtime code 部署后没有编辑按钮; “可升级合约”通常从第一天就部署了一个会转发调用的 proxy。升级时改变的是转发目标,而不是把原地址的字节码覆盖掉。
Source
编写:源码定义状态如何改变
智能合约不是“自动执行的协议条款”。更准确地说,它是一段位于地址上的程序: 收到调用后,按确定规则读取输入、检查条件、计算结果,并可能改变共享状态。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract LifecycleBox {
error ZeroValue();
event ValueChanged(
uint256 indexed oldValue,
uint256 indexed newValue,
address indexed caller
);
address public immutable creator;
uint256 public value;
constructor(uint256 initialValue) {
if (initialValue == 0) revert ZeroValue();
creator = msg.sender;
value = initialValue;
}
function set(uint256 newValue) external {
if (newValue == 0) revert ZeroValue();
uint256 oldValue = value;
value = newValue;
emit ValueChanged(oldValue, newValue, msg.sender);
}
function read() external view returns (uint256) {
return value;
}
}
状态变量
value 最终映射到 storage slot,跨交易保留。它不是只活在一次函数调用中的局部变量。
函数与检查
set 定义谁能提交什么输入,以及成功后状态如何变化;失败条件由 revert 表达。
事件
event 写入便于链下检索的 log。它不是 storage,合约也不能像读变量一样查询历史日志。
constructor 只属于“出生”阶段
constructor 在创建合约时执行一次。它可以写初始 storage、接收 ETH、调用别的合约、发出事件,也可能失败。
部署完成后,constructor 不会进入最终 runtime code,因此以后不能再次调用它。
creator 使用 Solidity 的 immutable:值在 constructor 中确定,随后被嵌入 runtime code。
它通常不占普通 storage slot。注意,这个语言特性只说明一个变量的设置时机,不能把它与“整个合约是否采用代理升级”混为一谈。
编译通过只证明语法与类型满足编译器规则,不证明经济设计正确、权限合理或没有漏洞。 单元测试、模糊测试、静态分析、审计与上线后的监控,是生命周期的一部分,而不是部署前可选的装饰。
Compiler
编译:一份源码,变成多种产物
编译器不仅翻译指令,还生成“如何与合约说话”“怎样部署”“怎样调试与验证”的整套材料。 把这些产物分清,后面的部署和调用就会突然变得具体。
Solidity 文件、依赖、编译器版本与设置。
语法树与可选中间表示,供分析和优化。
一次性初始化逻辑与 runtime code 模板。
真正留在合约地址、响应调用的代码。
接口、源码映射、文档、存储布局与验证信息。
编译产物拆包器 点击产物,观察它在生命周期中解决什么问题。示例为教学性节选,不是完整编译结果。
OFFLINE LABABI:编码与解码的共同说明书
钱包、前端和其他工具用它把函数、事件、错误与字节互相翻译。ABI 通常在链下使用,本身不执行。
Creation bytecode:一次性的安装程序
它包含初始化、constructor 与“返回 runtime code”的逻辑。部署完成后,这一整段不会作为合约日常代码保留。
Runtime bytecode:合约真正上线后的程序
它包含函数分派与可达逻辑,不包含 constructor。节点通过合约地址找到它,再从第一个 opcode 开始执行。
Metadata 与 storageLayout:复现与升级的坐标
编译器版本、优化设置、源码哈希、source map 与存储槽信息决定你能否准确验证源码、调试执行和检查升级兼容性。
构造参数为什么不属于普通函数调用?
部署时没有一个可被 selector 选中的 constructor(...) 函数。Solidity 工具把构造参数按 ABI 编码,
追加到 compiler creation bytecode 后面;creation code 自己从这段尾部数据读取参数。
区块浏览器的源码验证,是用相同源码、编译器版本和设置重新编译,再与链上 bytecode 比对。 它证明“公布的源码与运行代码相符”,不证明“代码行为一定正确”。后者属于审计、测试与形式化验证的范围。
Deployment
部署:先运行安装程序,再保存代码
“把 bytecode 上传到链上”是方便但不完整的说法。部署本身是一场 EVM 执行: initcode 必须成功运行,并明确返回一段字节,这段返回值才会成为链上的 runtime code。
to 为空,input 携带完整 initcode。
根据创建者、nonce 或 CREATE2 公式确定。
建立创建执行上下文并开始消耗 Gas。
写初始 storage、设置 immutable,可能调用外部合约。
initcode 返回最终 runtime bytecode。
顶层创建由一笔没有 to 地址的交易触发。需要精确一点:EOA 并不是“执行了 CREATE opcode”;
CREATE 与 CREATE2 是合约内部创建时使用的 EVM 指令,顶层创建交易由协议按创建语义处理。
constructor 执行期间,最终 runtime code 还没有安装。若 constructor 外部调用“自己”的地址,
不能假设已经能像部署后那样分派到所有函数。创建代码最终 RETURN 的字节才会被写入该地址的 code。
全部成功
runtime code 被保存;constructor 写入的 storage 和成功日志被保留;顶层创建回执含 contractAddress。
任何关键步骤失败
创建原子回滚:没有 runtime code 留在新地址,创建阶段的状态修改与 logs 也不会成为最终结果,但已消耗的 Gas 仍需支付。
截至 2026 年 7 月、Glamsterdam 尚未激活时,主网沿用 EIP-170 的 runtime code 上限 24,576 bytes, 以及 EIP-3860 的 initcode 上限 49,152 bytes。未来升级可能调整它们;部署到任何 EVM 链前,都要核对目标链实际启用的规则。
Address derivation
地址:普通创建与 CREATE2
合约地址不是部署后随机分配的。它可以在执行前被确定:普通创建依赖创建者与 nonce; CREATE2 则把 deployer、salt 与完整 initcode 的哈希放进公式。
CREATE / CREATE2 地址实验 使用 EIP-1014 官方测试向量。这里只展示决定因素,不在浏览器内重新实现 Keccak。
OFFICIAL VECTOR改变创建方式或 initcode
- creator
- 0xDeployer…
- creator nonce
- 17
- address input
- RLP([creator, 17])
- 关键变量
- 创建者或 nonce 改变 → 地址改变
- deployer
- 0x0000000000000000000000000000000000000000
- salt
- 0x00…00(32 bytes)
- initcode
- 0x00
- 关键变量
- deployer + salt + initcode hash
- deployer
- 0x0000000000000000000000000000000000000000
- salt
- 0x00…00(32 bytes)
- initcode
- 0xdeadbeef
- 关键变量
- 只改 initcode,预测地址也改变
普通创建不看 salt 或 initcode hash;它取决于实际创建者及其创建 nonce。
CREATE2 的三个常见误解
第一,不是“salt 决定地址”。deployer、32 字节 salt 与完整 initcode 缺一不可。 第二,constructor 参数属于完整 initcode,因此同一 creation bytecode 配上不同构造参数,通常会得到不同地址。 第三,可预计算不等于可以覆盖:目标地址已有非空 code 或非零 nonce 时,创建会按碰撞规则失败。
CREATE2 很适合反事实账户、统一部署与“先知道地址、后部署”的工作流,但它本身不是升级机制。 EIP-6780 之后,也不能把“SELFDESTRUCT 清空旧代码,再用 CREATE2 原址重建”当作当前以太坊的通用升级方案。
Call data
调用:ABI 把意图翻译成字节
用户说“调用 set(42)”,交易里却没有这一行文字。真正进入 EVM 的 input 是一串 calldata: 前 4 字节选函数,后面按 ABI 规则编码参数。
函数签名必须使用规范化类型:uint 在签名中写成 uint256,参数之间没有空格;
返回类型不参与 selector。因为 selector 只有 4 字节,不同签名理论上可能碰撞。
ABI 编码不是自描述格式。单看 000...02a,字节本身不会告诉你它是金额、计数还是地址的一部分;
解码者必须先知道对应 ABI。runtime code 的 dispatcher 读取 calldata 前 4 字节,
跳转到匹配函数;没有匹配时可能进入 fallback,空 calldata 收 ETH 时可能进入 receive。
eth_call 是排练,状态交易才是演出
| 比较项 | eth_call |
状态交易 |
|---|---|---|
| 签名与广播 | 通常不需要 | 需要私钥授权并进入交易传播 |
| 进入区块 | 不会 | 被纳入后成为链上历史 |
| 永久修改状态 | 不会,执行完即丢弃 | 成功时可以 |
| 用户支付网络 Gas | 不支付链上费用 | 支付实际执行费用 |
| 直接结果 | returndata 或 RPC 错误 | 先得到 tx hash,再查询 receipt |
| 持久 logs | 不会留下 | 成功路径发出的 logs 会进入 receipt |
排练 / 提交实验 初始链上 value = 7。所有操作都只发生在当前页面,不连接钱包或网络。
LOCAL STATE同一段 set(42) calldata,在模拟与真实交易路径中得到不同的“持久性”。
- 持久 storage
- slot 0 = 7
- 模拟画面
- 无
- receipt
- 尚未提交交易
- logs
- 0 条
eth_call 不只可以模拟 view 函数:它也能模拟会写状态的路径,只是不提交结果。
反过来,view 函数若被另一笔链上交易调用,执行它依然会消耗那笔交易的 Gas。
切换合约上下文
代码、地址、storage 与 msg.sender 都进入被调用合约的常规上下文。
禁止状态写入
在静态上下文中执行;试图修改状态会失败。常用于只读的合约间调用。
借代码,不换状态主体
执行目标代码,但保留调用者地址、storage、balance、msg.sender 与 msg.value 上下文。
Execution result
结果:return、receipt、log 与 revert
“交易结果”不是一个单独对象。正常返回的字节、失败数据、区块回执与事件日志属于不同层; 混在一起,会误读区块浏览器,也会写错 DApp。
发送者授权的输入:to、value、calldata、Gas 参数、nonce 与签名等。
当前调用正常结束后返回给直接调用者的字节,可按 ABI 解码。
失败原因的编码字节;标准 receipt 本身通常不保存这一段。
status、gasUsed、block、transactionHash、contractAddress 与 logs 等。
成功路径发出的可检索日志;地址、topics 与 data 共同描述事件。
为什么交易 receipt 看不到 Solidity return value?
合约 A 在同一笔执行中调用合约 B 时,A 可以同步收到 B 的 returndata。
但 EOA 发起的一笔顶层状态交易,标准 receipt 记录的是执行摘要,不包含顶层 Solidity return value。
前端若想预览返回值,通常先用 eth_call 模拟;真正提交后,再观察 storage、logs 或特定读取函数。
失败会回滚什么?
REVERT 会撤销当前失败调用帧及向上传播范围内的状态修改,相应 logs 也被移除。
已经执行的计算仍消耗 Gas,但 REVERT 本身不会像无效 opcode 或耗尽 Gas 那样必然烧掉所有剩余 Gas。
高级 Solidity 调用通常让异常向上传播;低级 call、delegatecall、staticcall
返回 (success, returndata)。调用方可以捕获失败并继续,所以“一个子调用 revert”不必然意味着顶层交易最终 status = 0。
日志便于索引,但合约代码不能像读取 storage 那样读取历史 logs;失败路径的 logs 也不会留下。 关键安全条件必须由当前状态与代码规则保证,不能只依赖“我们曾发出过某个 event”。
Upgradeability
升级:代码没被覆盖,调用路线变了
普通合约地址上的 runtime code 没有原地编辑机制。若系统要延续同一入口与状态, 最常见做法是让用户始终调用 proxy,再由 proxy 用 delegatecall 执行 implementation 的代码。
Proxy 保存身份与状态,Implementation 提供行为。 升级把 proxy 的 implementation slot 从 I1 改为 I2;proxy 地址、balance 与 storage 并未被搬走。
代理剖面实验 观察同一组原始 storage 在安全升级与危险布局下如何被新代码解释。
DELEGATECALL地址与状态主体
- address(this)
- 0xProxy…0902
- msg.sender
- 0xUser…
- slot 0
- owner = 0xAdmin…
- slot 1
- value = 42
- slot 2
- 未使用
- impl slot
- I1
读取 V1 布局
- 代码来自
- I1 runtime code
- slot 0 解释
- owner: address
- slot 1 解释
- value: uint256
- slot 2 解释
- —
- 读取结果
- value() → 42
I1 把 slot 0 当 owner、slot 1 当 value。所有读写都发生在 Proxy P 的 storage。
delegatecall 执行要素 |
实际来源 | 为什么重要 |
|---|---|---|
| 正在运行的指令 | Implementation | 业务逻辑可以随 implementation 指针变化 |
address(this) | Proxy | 对外始终表现为 proxy 地址 |
| storage / balance | Proxy | 旧状态延续,新代码必须按兼容布局解释 |
msg.sender | 原始调用者 | 权限检查看到的仍是用户或上游合约 |
msg.value | 原始调用 | 新逻辑在 proxy 上下文处理 ETH |
| event emitting address | Proxy 上下文 | 日志通常显示 proxy 为发出地址 |
Transparent Proxy
管理员调用升级接口,普通用户调用总是委托给实现;以调用者身份区分管理与业务分派,避免常见 selector 冲突。
UUPS
升级机制主要位于 implementation;新实现必须保留安全升级能力,并严格限制 _authorizeUpgrade。
Beacon Proxy
多个 proxy 读取同一个 beacon 指向的实现;改 beacon 可同时影响一组实例,效率更高,爆炸半径也更大。
不是所有合约都应该可升级
不采用 proxy 时,也可以部署 V2 到新地址、迁移资产或状态、更新前端与注册表,并保留 V1 作为历史。 这种路线牺牲地址连续性,却减少管理员未来改变规则的能力。
可升级性是一笔“信任预算”:它允许修复漏洞和迭代功能,也意味着某个管理员、多签、时间锁或治理系统拥有改变未来逻辑的权力。 用户不只需要审查当前 implementation,还要审查谁能升级、是否有延迟、能否暂停、是否有链上监控与退出窗口。
Upgrade safety
安全:initializer 与 storage layout
代理系统最危险的地方,往往不是 delegatecall 本身,而是“谁来初始化、谁能升级、旧 storage 会被新代码怎样解释”。
为什么 implementation 的 constructor 不够?
Implementation 部署时运行自己的 constructor,写入的是 implementation 自己的上下文;
未来用户通过 proxy 委托执行时,业务状态位于 proxy。于是可升级业务合约通常把初始化逻辑放进一次性的
initialize(),并在部署 proxy 时原子调用。
不要简单记成“代理合约不能有 constructor”。准确说法是: 业务 implementation 不能依靠自己的 constructor 初始化 proxy storage;proxy 本身可以在构造阶段设置 implementation,并 delegatecall initializer。
V1 已存在。V2 不能把它改成别的类型或挪走。
旧数据仍是原始 32 字节;升级不会自动迁移或转换。
安全 V2 通常把新变量追加到兼容位置,并用工具验证布局。
初学阶段可以先记四条:不删除已有变量、不改变已有类型、不重排已有变量、不在它们前面插入新变量。
但“只在末尾追加”也不是无条件安全:变量 packing、继承顺序和父合约新增字段都可能移动 slot。
应使用编译器 storageLayout 与升级验证工具,而不是凭肉眼。
- 原子初始化:部署 proxy 时同时执行 initializer,避免未初始化窗口被抢占。
- 一次性保护:initializer 只能成功一次;新增阶段用版本化 reinitializer。
- 锁住实现:implementation 自身通常在 constructor 中调用
_disableInitializers()。 - 父级完整:所有父合约 initializer 必须调用,而且顺序与继承线性化一致。
- 布局验证:保留旧版本 build info,自动比较 storage layout,不依赖记忆。
- 权限最小化:升级权使用合适的多签、时间锁或治理,并监控实现槽变化事件。
- 升级原子化:需要新字段时,将 upgrade 与 reinitialize 尽量放在同一受控操作中。
- 锁定边界:清楚记录 proxy、admin/beacon、implementation 地址和用户应交互的入口。
Proxy 把 implementation、admin 或 beacon 地址放到约定的特殊 storage slot, 避免与编译器按常规顺序分配的业务变量冲突,也让区块浏览器与监控工具能识别代理关系。 标准 slot 解决“放在哪里”,不替你解决升级授权、布局兼容或恶意实现。
Deactivation
“销毁”:旧教材最容易过时的地方
Solidity 已弃用 selfdestruct,而且 Cancun 激活 EIP-6780 后,
对既有合约调用它通常不再删除 code 与 storage。把它理解成“链上删除键”已经过时。
| 场景 | ETH balance | code / storage | 结论 |
|---|---|---|---|
| 历史:Cancun 之前的旧语义 | 转给 beneficiary | 交易结束时可被删除 | 只用于理解历史,不应作为新设计依据 |
| Cancun 后:既有合约 | 转给 beneficiary | 不删除 | 代码与 storage 留在地址上 |
| Cancun 后:同一交易中刚创建 | 转给 beneficiary | 保留旧式删除例外 | 狭窄例外,不是通用升级方案 |
具体行为由目标链启用了哪个 fork 决定;编译器的 --evm-version 不能改变链上已经采用的共识规则。
即使历史删除发生过,相关交易与代码仍属于链历史,并不等于从所有节点和归档数据中抹掉。
真正需要停用合约时,常见做法是让关键函数依据内部状态 revert,例如 pause / shutdown 模式,
再明确资产退出、权限撤销与前端提示。停用是业务状态机设计,不应寄希望于一个已弃用 opcode。
Synthesis
把完整生命周期走一遍
现在把所有对象重新串起来。你会发现,“智能合约”不是一个文件,而是一条可验证的转换与执行链。
定义状态、函数、权限、事件、错误与 constructor。
生成 ABI、creation/runtime code、metadata 与 layout。
创建交易携带 initcode,运行 constructor。
runtime code 与初始 storage 绑定到新地址。
ABI 把意图编码为 selector + 参数。
EVM 读取 code/storage,产生 return、log 或 revert。
部署新地址,或经预设 proxy 指针采用新实现。
普通合约
源码 → 编译 → initcode → 创建交易 → constructor → runtime code + storage → calldata 调用 → 新需求时部署新地址。
代理系统
部署 I1 → 部署 Proxy 并原子 initialize → 用户调用 Proxy → 部署 I2 → 验证布局 → 授权升级并按需 reinitialize。
“升级后,原合约代码被覆盖了。”——通常是错的。 典型代理升级改变的是 proxy 的 implementation 指针;proxy 的 code、地址与旧 storage 没有被覆盖。
Check your model
六道题,检查你是否真的分清了
每题都对应一个高频误区。先作答,再看解释;答错并不重要,能定位错误心智模型才重要。
部署成功后,哪一段代码会留在合约地址上并响应日常调用?
请选择一个答案。
CREATE2 中固定 deployer 与 salt,只改变 constructor 参数,预测地址通常会怎样?
请选择一个答案。
eth_call 模拟执行 set(42) 成功后,链上 storage 会发生什么?
请选择一个答案。
哪一项通常不在标准 transaction receipt 中?
请选择一个答案。
Proxy 通过 delegatecall 执行 I2 时,状态写入哪里?
请选择一个答案。
Cancun 后,对一个早已存在的合约执行 SELFDESTRUCT,当前主网通常会怎样?
请选择一个答案。
术语速查
- ABI
- 合约应用二进制接口;规定函数、参数、返回值、事件与错误的编码方式。
- creation bytecode
- 编译器生成的一次性创建程序,尚未附加具体 constructor 参数。
- initcode
- 创建时真正执行的完整输入,通常是 creation bytecode 加 ABI 编码构造参数。
- runtime bytecode
- initcode 返回并最终保存在合约地址上的代码。
- calldata
- 外部调用的只读输入字节,ABI 调用通常以 4 字节 selector 开头。
- returndata
- 当前调用向直接调用者返回的字节,与 receipt 是不同对象。
- receipt
- 交易入块后的执行摘要,包含 status、Gas、block 信息与 logs。
- proxy
- 持有稳定入口和业务 storage,并把调用委托给 implementation 的合约。
- implementation
- 在代理模式中提供被 delegatecall 执行的业务代码。
- initializer
- 为 proxy storage 执行业务初始化的一次性函数,替代 implementation constructor 的职责。
- storage layout
- 状态变量对应 slot、offset 与类型的规则;升级兼容性的核心。
- source verification
- 重编译源码并与链上 bytecode 比对,证明公布源码与运行代码相符。
一手资料
本课的协议细节以 Ethereum.org、Solidity 官方文档、EIP 与 OpenZeppelin 官方文档为准。 Ethereum 会持续升级;涉及尺寸、Gas 或 opcode 语义时,应再次核对目标链与当前 fork。
- Ethereum.org · Introduction to smart contracts 合约的基本定位、权限开放性、编译与组合性。
- Ethereum.org · Deploying smart contracts 创建交易、bytecode、Gas 与部署后地址。
- Ethereum.org · Transactions 合约创建与调用交易、input、selector 与参数。
- Ethereum.org · Verifying smart contracts 源码验证与形式化验证的区别,以及可复现编译。
- Solidity · Contracts 创建、constructor、函数可见性、事件与 immutable。
- Solidity · Using the compiler ABI、bytecode、deployedBytecode、metadata、source map 与 storageLayout 输出。
- Solidity · Contract ABI specification 函数 selector、参数、返回值、事件与错误的编码。
- Solidity · Error handling revert、异常传播与低级调用的返回语义。
- EIP-1014 · Skinny CREATE2 CREATE2 地址公式、initcode hash 与碰撞规则。
- EIP-170 · Contract code size limit 当前 24,576-byte runtime code 上限的来源。
- EIP-3860 · Limit and meter initcode 49,152-byte initcode 上限与按字计费。
- ERC-1967 · Proxy storage slots implementation、beacon 与 admin 的标准存储槽。
- OpenZeppelin · Proxy upgrade pattern delegatecall、transparent proxy 与 selector 冲突。
- OpenZeppelin · Writing upgradeable contracts initializer、implementation 锁定与 storage layout 安全。
- EIP-6780 · SELFDESTRUCT only in same transaction Cancun 后 SELFDESTRUCT 的当前语义。
- Ethereum.org · Glamsterdam 截至 2026 年 7 月仍属即将到来的 H2 2026 升级及候选变化。
Lesson complete · 09.02
你已经能追踪一份合约的完整生命
从源码到编译产物,从 initcode 到 runtime code,从 calldata 到回执, 再到 proxy 与 implementation 的信任边界——这些对象不再是一团“链上代码”。
返回 LOREWORD