以太坊合约创建码详解:init code 如何部署 runtime code
目录
- 一、创建码的三段结构
- 二、init code vs runtime code
- 三、最简合约示例
- 四、运行时代码细节
- 五、带构造参数的合约
- 六、部署期间的内存布局
- 七、元数据处理
- 八、纯 Yul 写法
- 九、关键要点
- 十、动手练习项目:手写创建码 RawDeployer
一、创建码的三段结构
部署到以太坊的**创建码(creation code)**由三段顺序拼接:
<init code> <runtime code> <constructor parameters>
- init code(初始化代码):部署时执行,设置合约;
- runtime code(运行时代码):部署后永久存上链的字节码;
- constructor parameters(构造参数,可选):ABI 编码的参数,附在最后。
部署合约的钱包向**空地址(null address)**发一笔交易,data 就按这三段排布。
二、init code vs runtime code
init code 负责:
- 初始化空闲内存指针(
PUSH1 0x80, PUSH1 0x40, MSTORE——所有 Solidity 合约都以6080604052开头); - 用
CODECOPY把创建码里的 runtime code 拷到内存; - 用
RETURN把 runtime code 返回给 EVM,EVM 把它存为合约的永久字节码。
runtime code 是:
- 部署后留在链上的实际合约字节码;
- 可被外部调用执行;
- 含 Solidity 附加的元数据(除非 0.8.18+ 关闭)。
核心心智模型:init code 是”安装程序”,跑一次就扔;runtime code 是”安装好的程序”,永久驻留。
三、最简合约示例
3.1 payable 构造函数
pragma solidity 0.8.17;
contract Minimal { constructor() payable { } }
创建字节码:
0x6080604052603f8060116000396000f3fe6080604052600080fd...metadata...
init code 部分 6080604052603f8060116000396000f3fe 的助记符:
PUSH1 0x80 // 设置空闲内存指针
PUSH1 0x40
MSTORE
PUSH1 0x3f // runtime code 长度 = 63 字节
DUP1
PUSH1 0x11 // runtime code 起始偏移 = 17 字节
PUSH1 0x00
CODECOPY // 把 runtime code 从创建码拷到内存
PUSH1 0x00
RETURN // 返回 runtime code 给 EVM
INVALID
3.2 非 payable 构造函数
constructor() { } // 非 payable
非 payable 构造函数会插入额外的校验代码——拒绝部署时附带的 ETH:
CALLVALUE // 检查交易附带的 wei
DUP1
ISZERO
PUSH1 0x0f
JUMPI // 若为 0 跳转继续
PUSH1 0x00 // 若发了 wei,revert
DUP1
REVERT
JUMPDEST // 继续部署
POP
这 12 字节 348015600f57600080fd5b50 让构造函数拒收 ETH。它把 runtime code 偏移从 0x11(payable)推到了 0x1d(非 payable)——这也是为什么 Module 9 说”构造函数设 payable 省 gas”:省掉这段校验。
四、运行时代码细节
空合约也有非空 runtime code,因为编译器会附加元数据。fe(INVALID) 在元数据前防止执行:
0x6080604052600080fdfea2646970667358221220...
非空合约示例:
contract Runtime {
address lastSender;
constructor() payable {}
receive() external payable { lastSender = msg.sender; }
}
runtime code:0x608060405236601c57600080546001600160a01b03191633179055005b600080fdfe
关键操作:设置内存指针 → 检查 CALLDATASIZE(非零则跳 revert,因为 receive 不收 calldata)→ 把 CALLER 存到插槽 0 → STOP;有 calldata 则 REVERT。
五、带构造参数的合约
构造函数带参数时,参数作为 ABI 编码字节附在 runtime code 之后。
contract MinimalLogic {
uint256 private x;
constructor(uint256 _x) payable { x = _x; }
}
带参数 uint256(1) 的创建字节码末尾会多出:
...0033 0000000000000000000000000000000000000000000000000000000000000001
init code 处理参数的步骤:
- 算参数长度:
CODESIZE - 硬编码的无参创建码长度(0x89)= 参数长度; - 拷参数到内存:
CODECOPY把参数从偏移 0x89 拷进内存; - 更新空闲内存指针:加上参数长度,存回 0x40;
- 校验参数大小:检查至少 32 字节(uint256),不足则 revert;
- 加载参数并写存储:
MLOAD读 uint256,SSTORE存到插槽 0; - 部署 runtime code:
CODECOPY+RETURN。
这解释了一个重要事实:构造参数不在 runtime code 里,它只在部署时被 init code 读取并写入存储,之后就不存在了。
关于 immutable:immutable 变量的值由 init code 在部署时算出,并直接写进(嵌入)即将返回的 runtime code 字节里——所以 immutable 读取不花存储 gas,且 delegatecall 时取的是字节码里写死的值(呼应 Module 6 delegatecall 篇的 immutable 陷阱)。
六、部署期间的内存布局
构造参数例子里,RETURN 前的内存:
0x00-0x40: runtime code 和元数据字节码
0x40: 空闲内存指针(更新后为 0xa0)
0x80-0xa0: 构造参数(uint256(1) = 0x00...01)
七、元数据处理
默认 Solidity 把 CBOR 编码的元数据附加到 runtime code(IPFS 哈希、编译器版本、优化设置)。fe(INVALID) 防止 EVM 执行元数据。0.8.18+ 可用 --no-cbor-metadata 关闭(上一篇详解)。
八、纯 Yul 写法
纯 Yul 合约默认不加元数据,能写出极简创建码:
object "Simple" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
mstore(0x00, 2)
return(0x00, 0x20)
}
}
}
外层 code 就是 init code(拷贝并返回 runtime),内层 object “runtime” 就是 runtime code。
九、关键要点
- 创建码是临时的——部署时执行一次后丢弃;
- runtime code 是永久的——存上链,被交易执行;
- init code 必须在最前——EVM 从字节码开头执行;
- 所有 Solidity 合约都以
6080604052开头(初始化空闲内存指针); - 构造参数 ABI 编码后附在末尾,由 init code 读取校验;
- 参数大小被强制校验——大小不对 init code 会 revert。
十、动手练习项目:手写创建码 RawDeployer
项目目标
不写 Solidity 合约,而是手写原始创建码字节码部署一个返回固定值的合约,彻底理解 init code 如何 CODECOPY + RETURN 出 runtime code。部署到 Sepolia。
合约/脚本要求
1. 手写 runtime code
- 目标:一个被调用时永远返回 42 的合约;
- runtime(Yul/手写):
PUSH1 0x2a PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN→602a60005260206000f3。
2. 手写 init code
- 把上面 10 字节的 runtime 拷到内存并返回:
PUSH1 0x0a(runtime 长度 10)PUSH1 <offset>PUSH1 0x00 CODECOPYPUSH1 0x0a PUSH1 0x00 RETURN;
- 拼成完整创建码:
<init><runtime>,算准 offset。
3. RawDeployer.sol
deploy(bytes memory creationCode) external returns (address addr):用汇编create(0, add(creationCode, 0x20), mload(creationCode))部署;deployAndCall(bytes memory creationCode) external returns (uint256):部署后立刻 staticcall 新合约,返回它返回的值(应为 42)。
测试要求(Foundry)
test_DeployRawReturns42:用手写创建码 deploy,对新合约 staticcall,断言返回 42;test_RuntimeCodeMatches:部署后addr.code== 你的 10 字节 runtime;test_PayableVsNonPayable:对比两种构造函数模式的创建码,验证非 payable 多出 12 字节校验、offset 从 0x11 变 0x1d;test_ConstructorArgAppended:用 Solidity 的type(C).creationCode+abi.encode(arg)拼出带参创建码,deploy 后读出存储确认参数生效;test_ImmutableInRuntime:部署一个带 immutable 的合约,证明 immutable 值嵌在 runtime code 里(两个不同 immutable 值的实例 runtime code 不同)。
Sepolia 部署与验证步骤
- 部署 RawDeployer;
- 调
deploy(手写创建码),得到新合约地址; - 在 Etherscan 上看新合约的字节码 == 你的 runtime;
- 对它发一笔 call,确认返回 42;
- 对比
type(SomeContract).creationCode和runtimeCode的长度差,理解 init code 部分。
进阶挑战(可选)
- 手写一个带”非 payable 校验”的 init code(加 CALLVALUE 检查),测试部署时附带 ETH 会 revert;
- 写注释回答:immutable 和构造参数都在创建码里出现,但部署后一个能读到(immutable)、一个消失了(构造参数本身),为什么?从”值写进 runtime 字节码 vs 写进存储”的角度解释。