本文解析了 Creation Code,包括 Init Code、Runtime Code 和 Constructor Parameters,并通过空合约示例展示了 Solidity 编译器生成字节码的过程,重点对比了 payable 和 non-payable 构造函数的区别及元数据的处理。
你是否曾经好奇过,当你在以太坊上部署一个智能合约时,在字节码层面上到底发生了什么?
本文将通过一些简单的空合约例子,带你理解 Creation Code。
从高层次来看,部署合约的钱包会向空地址发送一笔交易,其交易数据按照以下三部分进行布局:
<init code> <runtime code> <constructor parameters>
这三部分合称为 creation code
。EVM 会从执行 init code
开始。如果 init code
正确编码,那么该执行过程会将 runtime code
存储在区块链上。
在 EVM 规范中,并未规定布局必须是“init code、runtime code、constructor parameters”。它也可以是“init code、constructor parameters、runtime code”。这是 Solidity 使用的惯例。不过,init code 必须是第一部分,以便 EVM 知道从哪里开始执行。
Init Code
init code 是部署合约时首先执行的代码。它的主要职责是初始化合约的状态变量,并将 runtime code 部署到区块链上。init code 执行完毕后,EVM 会将其从区块链上移除,只保留 runtime code。
Runtime Code
runtime code 是合约的实际逻辑代码,负责处理合约的所有交易和调用。它包含了合约的业务逻辑和状态变量的读写操作。
Constructor Parameters
constructor parameters 是合约构造函数所需的参数。这些参数在部署时传递给合约,用于初始化合约的状态。
Solidity 提供了一个便捷的关键字 creationCode,用于获取智能合约创建时将部署的字节码。需要注意的是,creationCode 返回的字节码不包含构造函数的参数。这些参数会在合约部署时作为字节码的一部分被附加。
contract ValueStorage {
uint256 public value;
constructor(uint256 value_) {
value = value_;
}
}
contract GetCreationCode {
function get() external returns (bytes memory creationCode) {
creationCode = type(ValueStorage).creationCode;
}
}
init code 是创建代码中负责部署合约的部分。我们通过一个最简单的智能合约来说明。
pragma solidity 0.8.17;// optimizer: 200 runs
contract Minimal {
constructor() payable {
}
}
要获取编译结果,可以在 Remix 中执行部署交易后复制 input 字段的内容,得到以下内容:
0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033
这段字节码可以分为两部分:
Init code:
6080604052603f8060116000396000f3fe
Runtime code:
6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033
如果我们将第一部分复制到 EVM 工具中并将字节码转换为助记符,我们会得到以下输出:
// 分配空闲内存指针
PUSH1 0x80
PUSH1 0x40
MSTORE
// runtime code 的长度
PUSH1 0x3f
DUP1
// runtime code 的起始位置
PUSH1 0x11
PUSH1 0x00
// 将 runtime code 从 calldata 拷贝到内存
CODECOPY
// 部署 runtime code
PUSH1 0x00
RETURN
INVALID
这段 init code 执行了以下三项操作:
pragma solidity 0.8.17;// optimizer: 200 runs
contract Minimal {
constructor() {
}
}
以下是编译器的输出:
6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033
将其分为两部分:
Init code:
6080604052348015600f57600080fd5b50603f80601d6000396000f3fe
Runtime code:
6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033
与 payable 的做比较:
0x6080604052603f8060116000396000f3fe // payable
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe // nonpayable
可以注意到,payable 合约的 init code 比 non-payable 合约的更短。non-payable 合约中间多了一段 348015600f57600080fd5b50 的字节码。
将较长的字节码序列(non-payable)粘贴到 EVM 工具中,我们得到以下输出:
// 初始化空闲内存指针
PUSH1 0x80
PUSH1 0x40
MSTORE
// 检查传入的 wei 数量
CALLVALUE
DUP1
ISZERO
// 跳转到 0x0f (合约部署步骤)
PUSH1 0x0f
JUMPI
// 如果传入的 wei 大于 0,则回滚
PUSH1 0x00
DUP1
REVERT
// 跳转目标(0x0f)
JUMPDEST
POP
// runtime code 的长度
PUSH1 0x3f
DUP1
// runtime code 的起始位置
PUSH1 0x1d
PUSH1 0x00
CODECOPY
PUSH1 0x00
RETURN
INVALID
non-payable 造函数的字节码中,在空闲内存指针初始化和返回运行时代码之间,额外包含了一段字节序列 348015600f57600080fd5b50(12 字节)。
这段额外的代码会检查在部署过程中是否传入了 wei 值。如果传入了 wei,它会回滚(字节序列 348015600f57)。字节序列 600080fd 表示如果传入了 wei,则回滚。最后两个字节 5b50 是 JUMPDEST 和 POP 操作码,它们指示在没有传入 wei 时,继续执行先前描述的部署步骤。
之所以需要 POP,是因为 callvalue 仍然在栈中,而我们不再需要它。JUMPDEST 只是一个跳转目标,JUMP 或 JUMPI 需要有一个指定的跳转位置,否则它们会导致回滚。
还需要注意的是,虽然 runtime code 的长度没有变化,但复制 runtime code 的偏移量发生了变化,因为 init code 更长,这使得 runtime code 的偏移量向后移动。
non-payable 构造函数的初始化字节码的偏移量是 0x1d,而 payable 构造函数的偏移量较小,为 0x11。如果我们相减(0x1d – 0x11 = 0x0c,即十进制的 12),就得到了额外字节序列的大小。
runtime code 是 creation code 的一部分,它由 init code 返回,并被设置为合约的字节码,用户在部署后可以调用它。它成为了我们所说的“智能合约”。
有一个问题:如果合约是空的(没有函数),为什么运行时代码还是非空的?
Solidity 编译器会将一些关于合约的元数据附加到运行时代码中。有关合约元数据的更多信息,请参见此处。操作码 fe INVALID 会被加到元数据前面,以防止其被执行。
(Solidity 0.8.18 开始的版本增加了一个编译器设置 –no-cbor-metadata,可以通过它告诉编译器不要将这些元数据附加到合约的字节码中。)
如果合约是用纯 Yul 编写的,那么不会有元数据。不过,可以通过在全局对象中包含 .metadata 来添加元数据。
// 编译此合约的输出默认不会包含元数据
object "Simple" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
mstore(0x00, 2)
return(0x00, 0x20)
}
}
}
编译器输出如下:6000600d60003960006000f3fe,转换为助记符后得到:
// 将 runtime code 复制到内存
PUSH1 00
PUSH1 0d
PUSH1 00
CODECOPY
// 返回一个零大小的区域,因为没有 runtime code
PUSH1 00
PUSH1 00
RETURN
INVALID
在这种情况下,返回的内存区域为零,因为没有 runtime code 或 metadata。
编译器从 0x0d 开始,复制 0x00 字节的 runtime code 到从 0x00 偏移量开始的内存中,然后返回 0x00 字节。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!