以太坊智能合约创建代码

文章详细解释了以太坊智能合约在字节码级别是如何构造的,特别是构造函数参数的解释和处理方式。文章通过多个示例和图示,深入探讨了初始化代码、运行时代码以及带参数的构造函数的实现细节。

evm-bytecode 608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001


本文解释了以太坊智能合约构造时在字节码层面发生的事情,以及构造函数参数是如何被解读的。

## 目录

我们讨论以下主题,并提供视觉示例:

  * 引言
  * 初始化代码
    * 可支付的构造函数合约
    * 不可支付的构造函数合约
  * 运行时代码
    * 运行时代码分解
  * 带参数的构造函数

## 引言

在高层次上,部署合约的钱包向空地址发送一个事务,事务数据被分成三部分:

```evm-bytecode
<init code> <runtime code> <constructor parameters>

它们合在一起被称为创建代码。EVM首先执行初始化代码。如果初始化代码正确编码,则该执行会在区块链上存储运行时代码。

EVM规范中没有任何内容指明布局必须是初始化代码、运行时代码和构造函数参数。可以是初始化代码、构造函数参数,然后是运行时代码。这只是 Solidity 使用的惯例。然而,初始化代码必须是第一部分,以便 EVM 知道从哪里开始执行。

前提条件

本文假定读者对以下主题有一定了解:

让我们深入探讨吧!

作者

本文由 Michael Amadi(领英推特)共同撰写,是 RareSkills 技术写作计划的一部分。

Solidity creationCode

Solidity 具有一个机制,通过 creationCode 关键字获取在智能合约创建事务期间将被部署的字节码。下面演示了这一点。

这不包括构造函数参数,构造函数参数将在合约部署期间作为字节码的一部分包含在内。如何构造初始化代码(creationCode)和参数将在本文中解释。

contract ValueStorage {
    uint256 public value;
    constructor(uint256 value_) {
        value = value_;
    }
}

contract GetCreationCode {
    function get() external returns (bytes memory creationCode) {
        creationCode = type(Simple).creationCode;
    }
}

初始化代码

初始化代码是创建代码的片段,负责部署合约。让我们看一下最简单的智能合约。稍后我们将解释为什么添加了可支付的构造函数。

可支付的构造函数合约

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

    }
}

要获取编译结果,我们可以在执行部署事务后从 remix 复制“输入”字段。

提取合约生成字节码
提取合约生成字节码

当我们复制高亮部分时,我们得到

0x6080604052603f8060116000396000f3fe6080604052600080fdfea2646970667358221220d03248cf82928931c158551724bebac67e407e6f3f324f930c4cf1c36e16328764736f6c63430008110033

这当然相当难以阅读。然而,我们可以将其拆分为两部分。

初始化代码和运行时代码高亮显示

它可能看起来像我们在随机位置切分字节码,但稍后将更清楚地解释。

如果我们将第一部分复制并粘贴到 evm 代码中,并将字节码转换为助记符,我们得到以下 输出. 已添加注释。

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

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

// 运行时代码开始的位置
PUSH1 0x11 
PUSH1 0x00 // 将运行时代码从 calldata 复制到内存中
CODECOPY

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

在视觉中标记的代码部分被称为运行时代码,大小为63字节(十六进制中的0x3f)。它从内存中的第17个索引(十六进制中的0x11)开始。这解释了助记符分解中0x3f和0x11值的来源。

在高层次上,以下三项操作在此初始化代码中发生:

  • 分配内存指针,用于跟踪下一个可用的写入内存位置。
  • 然后使用“CODECOPY”操作码将运行时代码复制到该内存位置。
  • 最后,返回包含运行时代码的内存区域给 EVM,EVM 将其存储为新合约的运行字节码。

不可支付的构造函数合约

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

    }
}

让我们看一下构造函数不可支付时的字节码并看看有什么不同。这是编译器输出。

6080604052348015600f57600080fd5b50603f80601d6000396000f3fe6080604052600080fdfea2646970667358221220a6271a05446e269126897aea62fd14e86be796da8d741df53bdefd75ceb4703564736f6c63430008070033

将其分解为初始化和运行时代码,我们得到

不可支付构造函数空合约的初始化代码和运行时代码

让我们并排展示可支付和不可支付的初始化代码。

0x6080604052603f8060116000396000f3fe // 可支付的
0x6080604052348015600f57600080fd5b50603f80601d6000396000f3fe // 不可支付的

我们可以注意到,可支付合约的初始化代码比不可支付的要小。我们下面将解释原因。

将较长的序列(不可支付)粘贴到 evm 代码中,我们得到以下 输出, 添加了注释。

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

// 检查发送的 wei 数量
CALLVALUE
DUP1
ISZERO

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

// 如果发送的 wei 大于 0,则回退
PUSH1 0x00
DUP1
REVERT

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

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

// 运行时代码开始的位置
PUSH1 0x1d
PUSH1 0x00
CODECOPY
PUSH1 0x00
RETURN
INVALID

为了说明上面的情况,我们解释并使用这些概念:

可支付和不可支付构造函数的区别

1. 如果 callvalue > 0,初始化代码将回退,否则代码将继续执行。

不可支付的构造函数在自由内存指针初始化和返回运行时代码之间增加了一段额外的348015600f57 600080fd 5b50 (12 字节) 的字节序列。

<init bytecode> <extra 12 byte sequence (可支付情况)> <return runtime bytecode> <运行时代码>

这段附加代码检查在部署期间没有值(wei)被发送(序列348015600f57),否则则回退(序列600080fd)。最后两个字节5b50是一个JUMPDEST和POP操作码,在没有发送wei的情况下开始之前描述的部署序列。

(这里有一个pop的原因是因为callvalue仍在堆栈上,我们不再需要它。JUMPDEST只是一个JUMP和JUMPI的目标。如果在指定的跳转位置没有它,JUMPs无法着陆,并将回退。)

2. 运行时代码的内存偏移发生了变化

还要注意,运行时代码的长度没有变化,但复制运行时代码的偏移确实变化了,因为初始化代码更长,导致运行时代码的偏移进一步下移。

不可支付初始字节码的偏移为0x1d,而可支付情况的偏移较小,为0x11。如果我们将它们相减(0x1d – 0x11 = 0x0c,十进制为12),我们得到自由内存指针初始化块和运行字节码返回序列之间检查非零值的附加字节序列的大小。

空合约的运行时代码

空合约的运行时代码是非空的,因为编译器添加了元数据

运行时代码是由初始化代码返回的创建代码的片段,并被设置为合约在部署后用户可以调用的字节码。它成为我们所称的“智能合约”。

出现一个问题,“如果合约是空的(没有函数),为什么运行时代码是非空的?”

solidity 编译器在运行时代码中附加了一些关于你合约的元数据。更多关于合约元数据的信息 这里。操作码fe INVALID被放置在元数据前,以防止它被执行。

(新的solidity版本 0.8.18 添加了一个编译器设置 –no-cbor-metadata,以便你可以告知编译器不要将此元数据附加到合约的字节码中)

纯 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 并且转化为助记符时,我们获得

// 将运行时代码复制到内存
PUSH1   00
PUSH1   0d
PUSH1   00
CODECOPY    

// 返回一个零大小的区域,因为没有运行时代码
PUSH1   00
PUSH1   00
RETURN  
INVALID

在这种情况下,返回的内存区域为零,因为没有运行时代码或元数据。

(编译器从0x0d开始,复制0x00个运行时代码的字节到从偏移量0x00开始的内存中。然后返回0x00个字节。)

非空合约的运行时代码

现在让我们给合约添加最简单的逻辑。

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

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

输出的创建代码是

608060405260578060116000396000f3fe608060405236601c57600080546001600160a01b03191633179055005b600080fdfea2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033.

这可以分为

非空智能合约的初始化代码和运行时代码

让我们详细查看运行时代码

由于这是个 solidity 合约,我们可以将其分为可执行字节码和合约元数据,如前所述

Runtime code := 0x608060405236601c57600080546001600160a01b03191633179055005b600080fdfe

元数据 := 0xa2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033a2646970667358221220e9b731ab28726d97cbf5219f1e5eaec508f23254c60b15ed1d3456572547c5bf64736f6c63430008070033.

让我们深入研究运行时代码所做的事情,使用 evm 代码的 输出。它已被分为简化它。

首先,我们初始化自由内存指针。

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

在这里,我们检查交易是否发送了数据,如果是,我们 JUMP 到程序计数器 (PC) 0x1c,在那里我们会回退。合约接收数据的唯一两个有效方法是常规函数和回退函数。我们只有一个接收函数,因此合约没有有效的方法接收 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

这是 JUMPDEST 0x1c 的程序计数器,用于发送 calldata 的情况。事务将回退。

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

带参数的构造函数

带构造函数参数的合约的编码略有不同。构造函数参数被期望附加在创建代码的末尾(运行时代码之后),并且是 ABI 编码

特别是,Solidity 添加了一个额外的检查,以确保构造函数参数的长度至少与预期的构造函数参数长度相同,否则它会回退。

让我们来看一个简单的例子。为了简单起见,我们不包括任何运行时代码。我们包含的唯一代码是在构造函数中,而这不是运行时代码的一部分。

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

创建代码是

608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033

将其分解,我们得到

"Init code": 0x608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe"运行时代码(仅限元数据)": 0x6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c63430008070033"构造函数参数缺失!"

这样执行创建代码将回退,因为它期望在运行时代码之后至少有32个字节用作 uint256 _x。我们在分解每个操作码时将更详细地看到这一点。现在,我们可以将创建代码附加为 ABI 编码的 uint256(1),以用作 _x

现在,修正后的字节码

608060405260405160893803806089833981016040819052601e916025565b600055603d565b600060208284031215603657600080fd5b5051919050565b603f80604a6000396000f3fe6080604052600080fdfea26469706673582212204a131c1478e0e7bb29267fd8f6d38a660b40a25888982bd6618b720d4498b6b464736f6c634300080700330000000000000000000000000000000000000000000000000000000000000001

让我们使用 evm 代码的 输出 分析这一点。

步骤 1: 初始化自由内存指针

与 Solidity 合约常规做法一样,我们用 6080604052 初始化自由内存指针。

步骤 2: 获取构造函数参数的长度

// 6040 51 6089 38 03
PC   OPCODE

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

这里用PUSH1 40 MLOAD将自由内存指针加载以供稍后使用。我们压入创建代码的长度(不包括构造函数参数)与PUSH1 89,然后调用 CODESIZE (这包括构造函数参数)。我们用两者相减得到构造函数参数的长度。

步骤 3: 将构造函数参数复制到内存

// 80 6089 83 39
PC   OPCODE

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

这里我们为 CODECOPY 准备堆栈。使用操作码 DUP1 复制前面取得的长度结果,并用 PUSH1 89 将创建代码的长度(不包含构造函数参数)推送到堆栈。同样使用 DUP4,将内存偏移量移动到堆栈的顶部。然后我们调用 CODECOPY,将构造函数参数复制到自由内存指针处的内存中。

步骤 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: 跳转到 SSTORE 的 JUMPDEST

// 601e 91 6025 56
PC   OPCODE

[18] PUSH1 1e
[1a] SWAP2
[1b] PUSH1 25
[1d] JUMP // 跳转到 JUMPDEST 0x25

我们想跳转到执行存储构造函数参数的操作的程序计数器。我们压入1e,一旦构造函数参数添加到存储中就处理在程序计数器25处的SSTORE。

步骤 6: 检查构造函数参数大小是否至少为 32 字节

这是 JUMPDEST 0x25

// 5b 6000 6020 82 84
PC   OPCODE

[25] JUMPDEST
[26] PUSH1 00
[28] PUSH1 20
[2a] DUP3
[2b] DUP5

// 继续// 03 12 15 6036 57
[2c] SUB
[2d] SLT
[2e] ISZERO
[2f] PUSH1 36
[31] JUMPI // 如果 ISZERO 返回 1 跳转到 0x36 // 否则继续并回退 // 6000 80 fd

[32] PUSH1 00
[34] DUP1
[35] REVERT

在这里,我们检查构造函数参数是否至少为 32 字节。

首先,我们将0x00压入堆栈(稍后使用),将要求的最小长度0x20(32 字节)压入堆栈。接下来,通过检查其偏移量和当前自由内存指针获得构造函数参数的长度以进行比较。我们用DUP3获得偏移,用DUP5获得当前自由内存指针并放到堆栈顶部。

调用SUB减去并压入长度到堆栈。现在我们可以直接调用SLT(带符号小于)来检查它是否大于32字节,如果为假则压入0,为真则压入1,ISZERO操作验证堆栈顶部(SLT结果)是否为0,将其弹出并把布尔结果压入堆栈。我们将下一个JUMP位置压入堆栈,如果ISZERO返回1则跳转到它,否则回退以避免执行无效的calldata。

步骤 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 // 跳转到 0x1e

在这里我们将0从堆栈中弹出(程序计数器26到步骤6)因为我们已不再需要。然后,我们将构造函数参数加载到堆栈中并清除构造函数参数在内存上的偏移地址,因为我们不再需要它。

步骤 8: 复制运行时代码到内存并返回

这是 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 = 0x6080604052600080fdfea26469706673582212208f9ffa7a3ab43f0ff61d30330x20<->0x40 = 0x624bf0e9d398f9a91213656b13d9ffc8fd90fdbc64736f6c63430008070033000x40<->0x60 = 0x00000000000000000000000000000000000000000000000000000000000000a00x60<->0x80 = 0x00000000000000000000000000000000000000000000000000000000000000000x80<->0xa0 = 0x0000000000000000000000000000000000000000000000000000000000000001

结论

智能合约的部署包括一些由大多数语言抽象出来的底层操作。我们了解了如何通过发送创建代码到空地址执行智能合约,这段创建代码的不同部分、它们在部署合约时的作用以及它们如何共同发挥作用。我们看到构造函数参数是如何存储、验证和用于设置合约的。

RareSkills 区块链训练营

请查看我们的高级 区块链训练营,了解更多关于我们提供的专家级开发者培训。

最初发布于 2023 年 2 月 6 日

  • 原文链接: rareskills.io/post/ether...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/