以太坊合约创建码详解:init code 如何部署 runtime code

理解创建码三段结构、init code 如何返回运行时代码、构造参数与 immutable 的字节码处理

6 分钟阅读
以太坊合约创建码详解:init code 如何部署 runtime code

以太坊合约创建码详解:init code 如何部署 runtime code

目录


一、创建码的三段结构

部署到以太坊的**创建码(creation code)**由三段顺序拼接:

<init code> <runtime code> <constructor parameters>
  1. init code(初始化代码):部署时执行,设置合约;
  2. runtime code(运行时代码):部署后永久存上链的字节码;
  3. 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 处理参数的步骤:

  1. 算参数长度CODESIZE - 硬编码的无参创建码长度(0x89) = 参数长度;
  2. 拷参数到内存CODECOPY 把参数从偏移 0x89 拷进内存;
  3. 更新空闲内存指针:加上参数长度,存回 0x40;
  4. 校验参数大小:检查至少 32 字节(uint256),不足则 revert;
  5. 加载参数并写存储MLOAD 读 uint256,SSTORE 存到插槽 0;
  6. 部署 runtime codeCODECOPY + 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。

九、关键要点

  1. 创建码是临时的——部署时执行一次后丢弃;
  2. runtime code 是永久的——存上链,被交易执行;
  3. init code 必须在最前——EVM 从字节码开头执行;
  4. 所有 Solidity 合约都以 6080604052 开头(初始化空闲内存指针);
  5. 构造参数 ABI 编码后附在末尾,由 init code 读取校验;
  6. 参数大小被强制校验——大小不对 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 RETURN602a60005260206000f3

2. 手写 init code

  • 把上面 10 字节的 runtime 拷到内存并返回:
    • PUSH1 0x0a(runtime 长度 10)PUSH1 <offset> PUSH1 0x00 CODECOPY PUSH1 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)

  1. test_DeployRawReturns42:用手写创建码 deploy,对新合约 staticcall,断言返回 42;
  2. test_RuntimeCodeMatches:部署后 addr.code == 你的 10 字节 runtime;
  3. test_PayableVsNonPayable:对比两种构造函数模式的创建码,验证非 payable 多出 12 字节校验、offset 从 0x11 变 0x1d;
  4. test_ConstructorArgAppended:用 Solidity 的 type(C).creationCode + abi.encode(arg) 拼出带参创建码,deploy 后读出存储确认参数生效;
  5. test_ImmutableInRuntime:部署一个带 immutable 的合约,证明 immutable 值嵌在 runtime code 里(两个不同 immutable 值的实例 runtime code 不同)。

Sepolia 部署与验证步骤

  1. 部署 RawDeployer;
  2. deploy(手写创建码),得到新合约地址;
  3. 在 Etherscan 上看新合约的字节码 == 你的 runtime;
  4. 对它发一笔 call,确认返回 42;
  5. 对比 type(SomeContract).creationCoderuntimeCode 的长度差,理解 init code 部分。

进阶挑战(可选)

  • 手写一个带”非 payable 校验”的 init code(加 CALLVALUE 检查),测试部署时附带 ETH 会 revert;
  • 写注释回答:immutable 和构造参数都在创建码里出现,但部署后一个能读到(immutable)、一个消失了(构造参数本身),为什么?从”值写进 runtime 字节码 vs 写进存储”的角度解释。

💬 评论