欢迎光临
我们一直在努力

从字节码层面剖析以太坊智能合约创建原理

1. 引言

阅读完本文之后,将能理解一下字节码含义:

608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001

本文解释了:

  • 以太坊智能合约在 字节码层面 是如何被构造的,
  • 以及构造函数参数是如何被解释的。

本文基本结构为:

  • Solidity 中的 creationCode
  • 初始化代码(Init code)
    • 可支付(payable)构造函数合约
    • 不可支付(non-payable)构造函数合约
  • 运行时代码(Runtime code)
    • 运行时代码解析
  • 带参数的构造函数

从宏观上看,部署合约的钱包会向 空地址(null address) 发送一笔交易,交易数据分为三部分:

<init code> <runtime code> <constructor parameters>

三者合起来被称为 创建代码(creation code)。EVM 会首先执行 init code。如果 init code 编码正确,这段执行会把运行时代码存储到区块链上。

在 EVM 规范中,并没有强制要求布局必须是 init code、runtime code 和 constructor parameters。理论上可以是 init code、constructor parameters 然后 runtime code。这只是 Solidity 的约定。但是 init code 必须在最前面,否则 EVM 不知道从哪里开始执行。

本文假设读者已经了解以下内容:

  • Solidity(可参看 Solidity 免费教程)。
  • EVM 操作码基础

2. Solidity 中的 creationCode

Solidity 提供了一个机制,可以通过 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(creationCode)和参数是如何组织的,本文将进行解释。

3. Init code(初始化代码)

init code 是创建代码的一部分,负责部署合约。先看一个最简单的智能合约。稍后会解释为什么要加上一个 payable 构造函数。

3.1 Init code(初始化代码)——可支付(Payable)构造函数合约

pragma solidity 0.8.17; // optimizer: 200 runs
contract Minimal {
constructor() payable {

}
}

要获得编译结果,可以在 Remix 部署交易后,复制交易输入中的 “input” 字段来获取交易创建字节码: 在这里插入图片描述

复制出高亮部分后,得到:

0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033

当然,这串字节码很难直接阅读。但可以把它拆成两部分。 在这里插入图片描述

看起来似乎在随机分割字节码,但稍后会更清楚地解释。

如果把第一部分复制到 EVM Codes,并把字节码转换为助记符(mnemonics),就会得到以下输出(已添加注释):

// 分配自由内存指针
PUSH1 0x80
PUSH1 0x40
MSTORE

// 运行时代码的长度
PUSH1 0x3f
DUP1

// 运行时代码开始的位置
PUSH1 0x11
PUSH1 0x00

// 从 calldata 复制运行时代码到内存
CODECOPY

// 此步骤会部署运行时代码
PUSH1 0x00
RETURN
INVALID

在上图中被高亮的部分就是 运行时代码,其大小为 63 字节(十六进制 0x3f)。它从内存的第 17 个索引(十六进制 0x11)开始。这也解释了上面助记符解析中 0x3f 和 0x11 的来源。

从宏观上看,这段 init code 中主要做了三件事:

  • 设置自由内存指针(用于记录下一个可写入的内存位置)。
  • 使用 CODECOPY 操作码把运行时代码复制到该内存位置。
  • 最后,把包含运行时代码的内存区域返回给 EVM,EVM 会把它存储为新合约的运行时字节码。

3.2 Init code(初始化代码)——不可支付(Non-payable)构造函数合约

pragma solidity 0.8.17; // optimizer: 200 runs
contract Minimal {
constructor() {

}
}

来看一下当构造函数不是 payable 时生成的字节码,并分析其中的区别。以下是编译器的输出:

6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033

将其拆分为 初始化代码(init code) 和 运行时代码(runtime code),如下图所示: 在这里插入图片描述

把 payable 和 nonpayable 的 init code 放在一起对比:

0x6080604052603f8060116000396000f3fe // payable
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe // nonpayable

可以注意到:payable 合约的 init code 更短,而 non-payable 的要长一些。

将较长的字节序列(non-payable)放入 EVM playground,得到如下输出(已加上注释):

// 初始化自由内存指针
PUSH1 0x80
PUSH1 0x40
MSTORE

// 检查发送的以太数量(wei)
CALLVALUE
DUP1
ISZERO

// 跳转到 0x0f(合约部署步骤)
PUSH1 0x0f
JUMPI

// 如果发送了 wei > 0,就 revert
PUSH1 0x00
DUP1
REVERT

// 跳转目标 (0x0f)
JUMPDEST
POP

// 运行时代码长度
PUSH1 0x3f
DUP1

// runtime code 开始位置
PUSH1 0x1d
PUSH1 0x00
CODECOPY
PUSH1 0x00
RETURN
INVALID

接下来,将解释“Payable 与 Non-payable 构造函数的区别”,以便于深入理解以上初始化代码的区别。

  • 1)非 payable 构造函数会在部署时校验 callvalue

    • init code 会在 callvalue > 0 时 revert,否则继续执行。

    • 在 non-payable 的合约中,会额外插入一段 12字节 的字节码序列:

      348015600f57 600080fd 5b50

      它位于初始化内存指针和返回 runtime code 的字节码之间:

      <init bytecode> <额外12字节序列> <返回runtime字节码> <runtime字节码>

      这段额外的代码负责检查在部署时是否附带了 value(wei)。

      • 如果发送了 wei,就 revert。
      • 如果没发送 wei,就继续部署 runtime code。

      其中:

      • 348015600f57 → 检查 value 是否为 0,否则跳转。
      • 600080fd → 否则revert。
      • 5b50 → JUMPDEST + POP。POP 用于丢弃 callvalue(因为它还在栈上,不再需要)。 JUMPDEST 是跳转目标,没有它的话,JUMP 或 JUMPI 无法落地,会导致 revert。
  • 2)运行时代码的内存偏移不同 注意:runtime code 的长度没有变化,但 拷贝的偏移量 变了。 因为 non-payable 的 init code 更长,所以 runtime code 被推后了。

    • Non-payable 的偏移量:0x1d
    • Payable 的偏移量:0x11

    差值:0x1d – 0x11 = 0x0c(即 12),这正好对应那段额外的检查字节序列。

4. 运行时代码

4.1 空合约的运行时代码

即便合约是空的(没有函数),runtime code 依然不是空的。

原因是 solidity 编译器会在运行时代码后附加合约的元数据。

  • fe(INVALID) 会被加在元数据之前,防止其被当成可执行字节码。

更多合约元数据信息,可参看:Solidity metadata.json playground。

(在 Solidity 0.8.18 中,引入了 –no-cbor-metadata 编译选项,可以不在合约字节码中附加这些元数据。)

4.1.1 在纯 Yul 合约中,编译器默认不会添加元数据

如果合约是用纯 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

转换为助记符(mnemonics)后,得到:

// 将 runtime 代码复制到内存
PUSH100
PUSH10d
PUSH100
CODECOPY

// 返回一个零大小的区域,因为没有 runtime 代码
PUSH100
PUSH100
RETURN
INVALID

在这种情况下,返回的内存区域大小为零,因为既没有 runtime 代码,也没有元数据。

(编译器从 0x0d 开始,复制 0x00 字节的 runtime 代码到内存偏移 0x00 的位置,然后返回 0x00 字节。)

4.2 非空合约的 Runtime 代码

现在给合约加上最简单的逻辑。

pragma solidity 0.8.7;
contract Runtime {
address lastSender;
constructor () payable {}

receive() external payable {
lastSender = msg.sender;
}
}

其输出的创建代码为:

608060405260578060116000396000f3fe608060405236601c57600080546001600160a01b03191633179055005b600080fdfea2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033

可以拆分为: 在这里插入图片描述 接下来将详细看看其中的 runtime 代码。

由于这是一个 Solidity 合约,可以像之前解释的那样,将其分为 可执行字节码 和 合约元数据。

Runtime code :=
0x608060405236601c57600080546001600160a01b03191633179055005b600080fdfe

Metadata :=
0xa2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033

使用 evm.codes 输出 来分析 runtime 代码。它已被拆分以便于理解。

首先,初始化空闲内存指针:

[00] PUSH1 80
[02] PUSH1 40
[04] MSTORE

接下来检查交易是否带有数据,如果有,则跳转到程序计数器 (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,交易会回退:

[1c] JUMPDEST
[1d] PUSH1 00
[1f] DUP1
[20] REVERT
[21] INVALID

5. 带参数的构造函数

带构造函数参数的合约在编码时会有一些不同。构造函数的参数会被追加到创建代码(creation code)的末尾(在运行时代码 runtime code 之后),并且是以 ABI 编码 的形式追加的。

Solidity 还会额外增加一个检查:

  • 确保构造函数参数的长度至少等于预期的构造函数参数长度,否则会直接 revert。

来看一个简单的例子。这里不包含任何运行时代码(runtime code),只在构造函数中写逻辑(构造函数代码不会存在于运行时代码中):

// optimizer: 200
contract MinimalLogic {
uint256 private x;
constructor (uint256 _x) payable {
x = _x;
}
}

生成的创建代码是:

608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033

拆解:

  • Init code(初始化代码): 0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe
  • Runtime code(仅包含 metadata): 0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033
  • 缺少构造函数参数!

如果直接执行这份创建代码,会在 init code 中失败(revert),因为它期望在运行时代码后至少有 32 字节的数据来作为 uint256 _x 的值。

为了解决这个问题,可以把 ABI 编码后的 uint256(1) 追加到创建代码末尾,作为构造函数参数_x。

修正后的字节码:

608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001

字节码解析(通过 EVM playground 来演示):

  • 1)Step 1: 初始化 free memory pointer 和所有 solidity 合约一样,先用 6080604052 初始化空闲内存指针。

  • 2)Step 2: 获取构造函数参数的长度

    // 6040 51 6089 38 03
    PC OPCODE

    [05] PUSH1 40
    [07] MLOAD
    [08] PUSH1 89
    [0a] CODESIZE
    [0b] SUB

    这里 PUSH1 40 MLOAD 会将 自由内存指针(free memory pointer)加载到栈上,以便后续使用。接着通过 PUSH1 89 将 创建代码长度(不包含构造函数参数部分) 压入栈中,然后调用 CODESIZE(它包含了构造函数参数)。通过两者相减来得到 构造函数参数的长度。

  • 3)Step 3: 把构造函数参数复制到内存

    // 80 6089 83 39
    PC OPCODE

    [0c] DUP1
    [0d] PUSH1 89
    [0f] DUP4
    [10] CODECOPY

    这里为 CODECOPY 做栈准备。首先使用 DUP1 复制前面减法得到的结果,然后通过 PUSH1 89 将 创建代码长度(不包含构造函数参数) 压入栈中。最后,使用 DUP4 将内存偏移量放到栈顶。现在调用 CODECOPY,把构造函数参数复制到内存中的 自由内存指针 位置。

  • 4)Step 4:更新自由内存指针 在把代码写入内存后,Solidity 会按如下方式更新 自由内存指针:

    // 81 01 6040 81 90 52
    PC OPCODE

    [11] DUP2
    [12] ADD
    [13] PUSH1 40
    [15] DUP2
    [16] SWAP1
    [17] MSTORE

    这里通过把之前复制的 构造函数参数长度 (0x20) 加到 自由内存指针 (0x80) 上来实现更新。然后用 Dup1 和 Swap1 来调整栈结构,最后调用 MSTORE 40 把新的值 (0xa0) 存储为新的自由内存指针。

    接下来会有一系列的 动态操作和 JUMP,这些不会顺序执行,而是取决于条件分支。 这些步骤都有编号,可以按编号顺序理解,而不需要自己去找对应的 JUMPDEST。

    也可以用这个 playground 链接 来运行这个字节码。

  • 5)Step 5:跳转到 SSTORE 的 JUMPDEST

    // 601e 91 6025 56
    PC OPCODE

    [18] PUSH1 1e
    [1a] SWAP2
    [1b] PUSH1 25
    [1d] JUMP // jump to JUMPDEST 0x25

    在此,想跳转到执行存储构造函数参数的代码位置。 这里先把 1e 压入栈中。1e 对应的正是 SSTORE 实际执行的位置。但在这之前,需要先检查构造函数参数是否至少有 32 字节。这个检查过程从 PC = 0x25(即上面的 JUMPDEST)开始。

  • 8)Step 8:把构造函数参数存储到存储槽 0 这是 JUMPDEST 0x1e。不过要注意,JUMPDEST 0x25 会先执行(见下面步骤 6)。 另外要注意,这里是 Step 8,而上一个还是 Step 5。它之所以乱序,是因为只有 Step 6 和Step 7 条件满足后,这里才会真正执行。为了和编译后的字节码保持一致,在这里提前引入。

    // 5b 6000 55 603d 56
    PC OPCODE

    [1e] JUMPDEST
    [1f] PUSH1 00
    [21] SSTORE
    [22] PUSH1 3d
    [24] JUMP

    这里把 0x00 压栈,作为存储槽编号,把 _x 存入其中,然后调用 SSTORE。接着压入 0x3d,作为最后一次 CODECOPY 和 RETURN 的跳转目标。

  • 6)Step 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 字节)。
  • 然后通过之前压入栈的 offset 和 自由内存指针 来计算参数实际长度。
    • DUP3 得到 offset
    • DUP5 得到当前自由内存指针
  • SUB 相减得到长度并压栈。
  • SLT 检查是否小于 32 字节,返回 0(不小于)或 1(小于)。
  • ISZERO 对结果取反:如果参数长度 >= 32,则结果为 1。
  • 压入跳转目标 0x36,如果条件满足就跳转,否则执行 REVERT 来避免无效输入。
  • 7)Step 7:加载参数到栈,并准备写入存储 这是 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(来自Step 6 的 [26])弹出,因为已经不需要了。 然后:

    • 使用 MLOAD 把构造函数参数加载到栈顶
    • 清理掉构造函数参数的内存偏移(已无用)
    • 最后 JUMP 到 0x1e,进入 Step 8,执行真正的存储操作。
  • 9)Step 9:将运行时代码拷贝到内存并返回 这是 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

    无执行能力的代码(合约元数据):

    0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033

    这里按惯例从内存中返回合约的运行时代码。

RETURN 执行前的内存布局:

  • 0x00 到 0x40:空的运行时代码和元数据字节码
  • 0x40:自由内存指针
  • 0x80:构造函数参数 uint256(1)

内存详细分布:

0x00 <- 0x20 = 0x6080604052600080fdfea26469706673582212208f9ffa7a3ab43f0ff61d3033
0x20 <- 0x40 = 0x624bf0e9d398f9a91213656b13d9ffc8fd90fdbc64736f6c63430008070033
0x40 <- 0x60 = 0x0000000000000000000000000000000000000000000000000000000000000a0
0x60 <- 0x80 = 0x00000000000000000000000000000000000000000000000000000000000000
0x80 <- 0xa0 = 0x0000000000000000000000000000000000000000000000000000000000000001

6. 总结

智能合约部署包含了一些低级操作,这些通常被高级语言封装起来。 本文学习了:

  • 智能合约如何通过发送 创建代码到零地址 来执行
  • 创建代码的不同组成部分及其在部署中的作用
  • 各部分如何协同工作完成合约部署
  • 构造函数参数如何存储、验证,并用于初始化合约
  • 通过这个流程,可以对智能合约的底层执行有更清晰的理解。

    参考资料

    [1] RareSkills团队2023年2月博客 Ethereum smart contract creation code

    赞(0)
    未经允许不得转载:171主机测评 » 从字节码层面剖析以太坊智能合约创建原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址