608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001
本文解释了在构建 Ethereum 智能合约时,在字节码级别会发生什么,以及如何解释构造函数参数。
目录
我们将通过可视化示例讨论以下主题:
- 简介
- Init code
- payable 构造函数合约
- non-payable 构造函数合约
- Runtime code
- Runtime code 解析
- 带参数的构造函数
简介
从高层次来看,部署合约的钱包向 null address 发送一笔交易,交易数据分为三个部分:
<init code> <runtime code> <constructor parameters>
它们一起被称为 creation code。EVM 从执行 init code 开始。如果 init code 编码正确,此执行过程将把 runtime code 存储在区块链上。
EVM 规范中没有任何内容规定布局必须是 init code、runtime code 和构造函数参数。它也可以是 init code、构造函数参数,然后是 runtime code。这只是 Solidity 使用的约定。然而,init code 必须是第一部分,以便 EVM 知道从哪里开始执行。
先决条件
本文假设您了解以下主题的知识:
- Solidity(如果您刚刚起步,请参阅我们的 free Solidity tutorial)。
- EVM 操作码基础知识
让我们开始吧!
作者信息
本文由 Michael Amadi(LinkedIn,Twitter)作为 RareSkills 技术写作计划的一部分共同编写。
Solidity creationCode
Solidity 提供了一种机制,通过 creationCode 关键字获取在智能合约创建交易期间将被部署的字节码。这在下面进行了演示。
这不包括构造函数参数,构造函数参数将作为合约部署期间运行的字节码的一部分被包含进去。本文将解释 init code(creationCode)和参数是如何结构化的。
contract ValueStorage {
uint256 public value;
constructor(uint256 value_) {
value = value_;
}
}
contract GetCreationCode {
function get() external returns (bytes memory creationCode) {
creationCode = type(Simple).creationCode;
}
}
Init code
init code 是 creation code 中负责部署合约的片段。让我们来看看最简单的智能合约。稍后我们将解释为什么要添加一个 payable 构造函数。
payable 构造函数合约
pragma solidity 0.8.17;// optimizer: 200 runscontract Minimal {
constructor() payable {
}
}
要获取编译结果,我们可以在执行部署交易后从 remix 复制“input”字段。

提取合约创建字节码
当我们复制高亮字段时,我们得到
0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033
这当然相当难读。不过,我们可以将其分为两部分。

看起来我们似乎在一个随机的位置分割了字节码,但稍后会更清晰地解释这一点。
如果我们将第一部分复制并粘贴到 evm.codes 中,并将字节码转换为助记符,我们会得到以下 output。这里已添加了注释。
// allocate free memory pointer
PUSH1 0x80
PUSH1 0x40
MSTORE
// length of the runtime code
PUSH1 0x3f
DUP1
// where the runtime code begins
PUSH1 0x11
PUSH1 0x00// copy the runtime code from calldata into memory
CODECOPY
// runtime code is deployed at this step
PUSH1 0x00
RETURN
INVALID
视觉图中高亮显示的代码部分被称为 runtime code,其大小为 63 字节(十六进制为 0x3f)。它在内存中从第 17 个索引(十六进制为 0x11)开始。这解释了上述助记符解析中 0x3f 和 0x11 这两个值是从哪里来的。
从高层次来看,此 init code 中发生了以下三个操作:
- 分配了负责跟踪下一个可用于写入的内存位置的 free memory pointer。
- 然后使用
CODECOPY操作码将 runtime code 复制到此内存位置。 - 最后,包含 runtime code 的内存区域被返回给 EVM,EVM 将其存储为新合约的 runtime bytecode。
non-payable 构造函数合约
pragma solidity 0.8.17;// optimizer: 200 runs
contract Minimal {
constructor() {
}
}
让我们看看当构造函数不是 payable 时的字节码,并看看其中的区别。这是编译器输出。
6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033
将其分解为 init code 和 runtime code,我们得到

让我们将 payable 和 non-payable 的 init code 并排放在一起。
0x6080604052603f8060116000396000f3fe // payable
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe // non-payable
我们可以注意到 payable 合约的 init code 比 non-payable 的要小。我们在下面解释原因。
将较长的序列(non-payable)粘贴到 evm.codes 中,我们得到以下 output,并添加了注释。
// initialize free memory pointer
PUSH1 0x80
PUSH1 0x40
MSTORE
// check the amount of wei that was sent
CALLVALUE
DUP1
ISZERO
// Jump to 0x0f (contract deployment step)
PUSH1 0x0f
JUMPI
// revert if wei sent is greater than 0
PUSH1 0x00
DUP1
REVERT
// Jump dest (0x0f)
JUMPDEST
POP
// length of the runtime code
PUSH1 0x3f
DUP1
// where the runtime code begins
PUSH1 0x1d
PUSH1 0x00
CODECOPY
PUSH1 0x00
RETURN
INVALID
为了解释上面发生的事情,我们解释并使用以下概念:
payable 和 non-payable 构造函数之间的区别
1. 如果 callvalue > 0,init code 将 revert,否则代码将继续执行。
non-payable 构造函数在 non-payable 合约的 free memory pointer 初始化和返回 runtime code 之间,有额外的一段 348015600f57 600080fd 5b50(12 字节)序列。
<init bytecode> <extra 12 byte sequence (payable case)> <return runtime bytecode> <runtime bytecode>
此附加代码检查在部署期间是否有发送任何以太币(wei)(序列 348015600f57),否则将 revert(序列 600080fd)。最后的两个字节 5b50 是 JUMPDEST 和 POP 操作码,如果没有发送 wei,它们将开始前面描述的部署序列。
(之所以有 POP,是因为 callvalue 仍在堆栈上,而我们不再需要它。JUMPDEST 只是 JUMP 和 JUMPI 的目标。如果在指定的跳转位置没有它,JUMP 将无法着陆并且会 revert。)
2. runtime code 的内存偏移量发生了偏移
还要注意的是,runtime code 的长度没有改变,但是复制 runtime code 的偏移量改变了,因为 init code 更长,这使得 runtime code 的偏移量进一步向下推移。
init bytecode 的 non-payable 偏移量是 0x1d,而 payable 情况的偏移量较小,为 0x11。如果我们将它们相减(0x1d - 0x11 = 0x0c,十进制为 12),就会得到在 free memory pointer 初始化块和返回 runtime bytecode 序列之间,检查非零值的额外字节序列的大小。
空合约的 runtime code
空合约中的 runtime code 非空,是因为编译器添加了 metadata
runtime code 是由 init code 返回的 creation code 片段,它被设置为部署后可供用户调用的合约字节码。它成为了我们所熟知的“智能合约”。
此时出现了一个问题:“如果合约为空(没有函数),为什么 runtime code 不是空的?”
Solidity 编译器会将关于你的合约的一些 metadata(元数据)附加到 runtime code 中。有关合约 metadata 的更多信息在这里。操作码 fe INVALID 被预置在 metadata 之前,以防止其被执行。
(新的 Solidity 版本 0.8.18 添加了一个编译器设置 --no-cbor-metadata,你可以通过它告诉编译器不要将此 metadata 附加到你合约的字节码中)
在纯 Yul 合约中,编译器默认不添加 metadata
如果合约是用纯 Yul 编写的,就不会有 metadata。但是,可以通过在全局对象中包含 .metadata 来添加 metadata 部分。
// the output of the compilation of this contract// will have no metadata by default
object "Simple" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
mstore(0x00, 2)
return(0x00, 0x20)
}
}
}
编译器 output 如下 6000600d60003960006000f3fe,转换为助记符后,我们得到
// copy runtime code to memory
PUSH1 00
PUSH1 0d
PUSH1 00
CODECOPY
// Returning a zero sized region because there is no runtime code
PUSH1 00
PUSH1 00
RETURN
INVALID
在这种情况下,返回的内存区域为零,因为没有 runtime code 或 metadata。
(编译器从 0x0d 开始,将 0x00 字节的 runtime code 复制到从偏移量 0x00 开始的内存中。然后返回 0x00 字节。)
非空合约的 runtime code
现在让我们向合约添加最简单的逻辑。
pragma solidity 0.8.7;contract Runtime {
address lastSender;
constructor () payable {}
receive() external payable {
lastSender = msg.sender;
}
}
输出的 creation code 为
608060405260578060116000396000f3fe608060405236601c57600080546001600160a01b03191633179055005b600080fdfea2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033.
这可以分为

让我们详细看看 runtime code
由于这是一个 Solidity 合约,正如前面所解释的,我们可以将其分为可执行字节码和合约 metadata
Runtime code := 0x608060405236601c57600080546001600160a01b03191633179055005b600080fdfe
Metadata := 0xa2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033a2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033.
让我们使用 evm.codes 的 output 来深入了解 runtime code 的作用。为了简化,它已被划分开来。
首先,我们初始化 free memory pointer。
[00] PUSH1 80
[02] PUSH1 40
[04] MSTORE
在这里,我们检查交易是否发送了数据,如果是,我们将 JUMP 到程序计数器(PC)0x1c 处,在那里我们会 revert。合约接收数据的唯一两种有效方式是常规函数和 fallback。我们只有一个 receive 函数,因此合约没有有效的方式来接收 calldata。
[05] CALLDATASIZE
[06] PUSH1 1c
[08] JUMPI
然后我们有存储 msg.sender 的代码。
[09] PUSH1 00
[0b] DUP1
[0c] SLOAD
[0d] PUSH1 01
[0f] PUSH1 01
[11] PUSH1 a0
[13] SHL
[14] SUB
[15] NOT
[16] AND
[17] CALLER
[18] OR
[19] SWAP1
[1a] SSTORE
[1b] STOP
对于发送了 calldata 的情况,这是 JUMPDEST 0x1c。交易发生 revert。
[1c] JUMPDEST
[1d] PUSH1 00
[1f] DUP1
[20] REVERT
[21] INVALID
带参数的构造函数
带有构造函数参数的合约的编码方式略有不同。构造函数参数应附加在 creation code 的末尾(在 runtime code 之后),并进行了 ABI 编码(ABI encoded)。
Solidity 特别添加了额外的检查,以确保构造函数参数长度至少为预期的构造函数参数长度,否则将 revert。
让我们来看一个简单的示例。为了简单起见,我们不包含任何 runtime code。我们包含的唯一代码在构造函数中,它不是 runtime code 的一部分。
// optimizer: 200contract MinimalLogic {
uint256 private x;
constructor (uint256 _x) payable {
x =_x;
}
}
creation code 为
608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
将其分解,我们得到
"Init code": 0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe"Runtime code (metadata only)": 0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033"Constructor arguments are missing!"
以这种方式执行 creation code 会在 init code 中发生 revert,因为它期望在 runtime code 之后至少有 32 个字节用作 uint256 _x。在分解每个操作码时,我们将更详细地看到这一点。现在我们可以附加 ABI 编码后的 uint256(1) 作为 creation code 来作为 _x 使用。
现在,更正后的字节码
608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001
让我们使用 evm.codes 的 output 来分析这一点。
第 1 步:初始化 free memory pointer
和 Solidity 合约一样,我们用 6080604052 初始化 free memory pointer。
第 2 步:获取构造函数参数的长度
// 6040 51 6089 38 03
PC OPCODE
[05] PUSH1 40
[07] MLOAD
[08] PUSH1 89
[0a] CODESIZE
[0b] SUB
在这里,PUSH1 40 MLOAD 会执行 MLOAD 获取 free memory pointer 以供后续使用。我们使用 PUSH1 89 将 creation code 的长度(不包含构造函数参数)推入堆栈,然后调用 CODESIZE(这包含构造函数参数)。我们将两者相减以获得构造函数参数的长度。
第 3 步:将构造函数参数复制到内存
// 80 6089 83 39
PC OPCODE
[0c] DUP1
[0d] PUSH1 89
[0f] DUP4
[10] CODECOPY
在这里,我们为 CODECOPY 准备堆栈。我们使用操作码 DUP1 复制上面的减法结果,并使用 PUSH1 89 将 0x89(不包含构造函数参数的 creation code 长度)推入堆栈。最后,我们使用 DUP4 将内存偏移量带到堆栈的顶部。现在我们调用 CODECOPY 将构造函数参数复制到 free memory pointer 处的内存中。
第 4 步:更新 free memory pointer
将代码写入内存后,Solidity 按如下方式更新 free memory pointer。
// 81 01 6040 81 90 52
PC OPCODE
[11] DUP2
[12] ADD
[13] PUSH1 40
[15] DUP2
[16] SWAP1
[17] MSTORE
我们在这里通过将前面复制的构造函数参数长度(0x20)加到 free memory pointer(0x80),然后在调用 MSTORE 40 之前用 DUP1 和 SWAP1 操作对其进行排列来完成此操作,该操作将新值(0xa0)存储为 free memory pointer。
接下来我们有一系列动态操作和 JUMP,它们不按顺序执行,而是基于某些条件执行。让我们深入探讨。
步骤都已编号,因此你可以按顺序跟随它们,而无需查找所需的 JUMPDEST。
你也可以使用此字节码的 playground link 自己尝试一下。
第 5 步:跳转到 SSTORE 的 JUMPDEST
// 601e 91 6025 56
PC OPCODE
[18] PUSH1 1e
[1a] SWAP2
[1b] PUSH1 25
[1d] JUMP // jump to JUMPDEST 0x25
我们要跳转到执行在 storage 中保存构造函数参数操作的程序计数器(PC)。
我们将 1e 推入堆栈。1e 是程序计数器中实际执行 SSTORE 的位置,但首先我们必须检查复制的构造函数参数是否至少有 32 字节。此操作从程序计数器 0x25 开始,这就是上面的 JUMPDEST。
第 8 步:在 storage 槽 0 中存储构造函数参数
这是 JUMPDEST 0x1e,JUMPDEST 0x25 会首先执行并在下面列出。注意这是第 8 步,而上一节是第 5 步。仅当步骤 6 和 7 中的条件成功完成时,它才会被执行。我们在此处不按顺序引入它,是为了保持已编译字节码的相同序列。
// 5b 6000 55 603d 56
PC OPCODE
[1e] JUMPDEST
[1f] PUSH1 00
[21] SSTORE
[22] PUSH1 3d
[24] JUMP
在这里,我们将 0x00(这是我们要存储 _x 的 storage 槽)推入堆栈并调用 SSTORE。然后推入最终进行 CODECOPY 和返回的跳转目标。
第 6 步:检查构造函数参数大小是否至少为 32 字节
这是 JUMPDEST 0x25
// 5b 6000 6020 82 84
PC OPCODE
[25] JUMPDEST
[26] PUSH1 00
[28] PUSH1 20
[2a] DUP3
[2b] DUP5
// continue// 03 12 15 6036 57
[2c] SUB
[2d] SLT
[2e] ISZERO
[2f] PUSH1 36
[31] JUMPI // Jump to 0x36 if ISZERO returns 1// else continue and revert// 6000 80 fd
[32] PUSH1 00
[34] DUP1
[35] REVERT
在这里,我们检查构造函数参数至少为 32 字节。
首先,我们将 0x00 推入堆栈(供以后使用),将最小可接受长度 0x20(32 字节)推入堆栈。接下来,通过检查构造函数参数的偏移量和我们之前推入堆栈的当前 free memory pointer,我们可以获得要比较的构造函数参数的长度。因此,我们使用 DUP3 获取堆栈的偏移量,然后使用 DUP5 将当前 free memory pointer 带到堆栈的顶部。
调用 SUB 会进行相减并将长度推入堆栈。现在我们可以直接调用 SLT(有符号小于)来检查它是否达到 32 字节,如果为假则推入 0,如果为真则推入 1。ISZERO 操作码会检查堆栈顶部(SLT 结果)是否为 0,将其弹出并将布尔结果推入堆栈。我们将下一个 JUMP 位置推入堆栈,如果 ISZERO 返回 1 则跳转到该位置,否则我们将 revert 以避免在 calldata 无效的情况下执行。
第 7 步:将参数加载到堆栈并排列堆栈,以便将构造函数参数保存到 storage
这是 JUMPDEST 0x36
// 5b 50 51 91 90 50 56
PC OPCODE
[36] JUMPDEST
[37] POP
[38] MLOAD
[39] SWAP2
[3a] SWAP1
[3b] POP
[3c] JUMP // jump to 0x1e
在这里,我们弹出 0(步骤 6 中的程序计数器 26),因为我们不再需要它了。我们用 MLOAD 将构造函数参数加载到堆栈中,并清除构造函数参数的内存偏移量,因为它也不再被需要了。
第 9 步:将 runtime code 复制到内存并将其返回
这是 JUMPDEST 0x3d,上面的 JUMPDEST 0x1e 会首先执行
// 5b 603f 80 604a 6000 39 6000 f3 fe
PC OPCODE
[3d] JUMPDEST
[3e] PUSH1 3f
[40] DUP1
[41] PUSH1 4a
[43] PUSH1 00
[45] CODECOPY
[46] PUSH1 00
[48] RETURN
[49] INVALID
// Unexecutable code (contract metadata)0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
在这里,我们像往常一样从内存中返回合约的 runtime code。
执行 RETURN 之前的内存
0x00 到 0x40 是(空的)runtime code 和 metadata 字节码。0x40 包含 free memory pointer。0x80 包含构造函数参数 uint256(1)。
0x00<->0x20 = 0x6080604052600080fdfea26469706673582212208f9ffa7a3ab43f0ff61d30330x20<->0x40 = 0x624bf0e9d398f9a91213656b13d9ffc8fd90fdbc64736f6c63430008070033000x40<->0x60 = 0x00000000000000000000000000000000000000000000000000000000000000a00x60<->0x80 = 0x00000000000000000000000000000000000000000000000000000000000000000x80<->0xa0 = 0x0000000000000000000000000000000000000000000000000000000000000001
结论
智能合约部署包含了一些被大多数语言抽象掉的底层操作。我们了解了如何使用发送到 null address 的 creation code 来执行智能合约,此 creation code 的不同部分、它们在部署合约时的作用以及它们是如何协同工作的。我们还了解了如何存储、验证和使用构造函数参数来设置合约。
RareSkills Blockchain Bootcamp
请查看我们的高级 blockchain bootcamp 课程,以了解更多关于我们提供的专家级开发者培训的信息。
首发于 2023 年 2 月 6 日