从EVM 角度看合约创建与部署

从EVM 角度探究合约创建与部署

本文我们探讨智能合约是如何创建和部署的。

当谈论以太坊上的合约构建时,我们必须区分在EVM层面上发生的事情和作为Solidity开发者,在编写我们想要部署的合约时看到的事情之间的区别。

在这份文件中,我们将探讨智能合约是如何在链上部署的,以及与EVM执行合约创建代码有关的微妙之处。

前备知识点

  1. 构造函数用于初始化状态变量,与普通函数不同,它在合约部署后是不可访问的。
  2. 以太坊的合约部署是独特的,因为这个动作本身就是运行EVM字节码的副产品。
  3. 部署者附加构造函数参数,这使得初始化的合约状态可以改变而不必重新编译。
  4. 尽管常量(constants)的使用成本较低,但不可变的变量(immutable)有更大的灵活性,因为它们可以在构造函数中初始化,也可以运行时复制值。
  5. 当运行一个合约的构造函数时,新创建的合约存在,但只是部分存在,这可能会引起不可预测的操作码执行结果。

Solidity

当Solidity开发者编写合约时,可以定义一个特殊的函数,称为constructor(),其作用很像其他面向对象编程中的构造函数。就好像Solidity团队想让开发者有宾至如归的感觉,让他们觉得他们只是在定义一个类(合约)和它的构造函数。

constructor()函数通常用于初始化状态变量:

pragma solidity ^0.8.13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

上面的例子是一个简单的构造函数,它将部署器的余额设置为totalSupply值,在我们的例子中这是一个常数。

尽管构造函数在Solidity中是明确定义的,但它与普通函数的不同之处在于,它在合约部署后是不可访问的。我们将使用solc- abi输出标志来观察合约的可访问函数列表:

[{
  "inputs": [],
  "stateMutability": "nonpayable",
  "type": "constructor"
}, {
  "inputs": [],
  "name": "totalSupply",
  "outputs": [{
    "internalType": "uint256",
    "name": "",
    "type": "uint256"
  }],
  "stateMutability": "view",
  "type": "function"
}]

编译器为totalSupply状态变量创建了一个公共函数,因为我们把这个变量声明为public

我们的 构造函数在合约的ABI中具有一个特殊的 类型,以表明它不是一个普通的函数。它没有name字段,与 真正的函数不同,暗示它是不可访问的。

部署

让我们看看当我们部署合约时将会运行的实际代码:

solc --bin ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary:
608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

这是 init code,而不仅仅是将被部署到链上的代码。对于后者,我们使用solc-- bin-runtime 选项获得:

solc --bin-runtime ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary of the runtime part:
6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

-- bin输出包含-- bin-runtime输出,这不是巧合。-- bin输出包含部署动作和要部署的代码,而-- bin-runtime输出只显示要部署的代码:

让我们仔细看看init code 是做什么的,看一下我们例子合约的反汇编:

label_0000:
    0000 60 PUSH1 0x80
    0002 60 PUSH1 0x40
    0004 52 MSTORE
    0005 34 CALLVALUE
    0006 80 DUP1
    0007 15 ISZERO
    0008 61 PUSH2 0x0010
    000B 57 JUMPI
label_000C:
    000C 60 PUSH1 0x00
    000E 80 DUP1
    000F FD REVERT
label_0010:
    0010 5B JUMPDEST
    0011 50 POP
    0012 6B PUSH12 0x033b2e3c9fd0803ce8000000
    001F 60 PUSH1 0x00
    0021 80 DUP1
    0022 33 CALLER
    0023 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    0038 16 AND
    0039 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    004E 16 AND
    004F 81 DUP2
    0050 52 MSTORE
    0051 60 PUSH1 0x20
    0053 01 ADD
    0054 90 SWAP1
    0055 81 DUP2
    0056 52 MSTORE
    0057 60 PUSH1 0x20
    0059 01 ADD
    005A 60 PUSH1 0x00
    005C 20 SHA3
    005D 81 DUP2
    005E 90 SWAP1
    005F 55 SSTORE
    0060 50 POP
    0061 60 PUSH1 0xbd
    0063 80 DUP1
    0064 61 PUSH2 0x006e
    0067 60 PUSH1 0x00
    0069 39 CODECOPY
    006A 60 PUSH1 0x00
    006C F3 RETURN
    006D FE ASSERT

为了完全理解这个例子的要点,建议读者尝试用调试器自己执行这个例子,比如使用evm.codes

这里发生了很多事情,但我们可以把它分解成2个基本部分:

  • 运行构造函数(设置代码

    • 这一部分来自0000-005F
  • 返回将被部署在链上的运行时字节码

    • 这一部分来自于0060-006C

对于构造函数部分,事情是非常简单的。请记住,我们将Solidity构造函数设置为 nonpayable. 事实上,我们不需要做任何事情就可以使构造函数成为 nonpayable,这是一个 "solc "默认值,除非指定 payable

因此,如果任何 value 与运行我们代码的交易一起被发送,0005-000F 行将简单地回退。

在第0012-005F行,可以看到Solidity构造函数的反汇编,它包含了一行代码:

pragma solidity ^0.8.13;
...

balances[msg.sender] = totalSupply;

由于我们用一个常数来表示 totalSupply值,编译器将该值植入EVM字节码。这可以在第0012行看到,十六进制值0x033b2e3c9fd0803ce8000000被推送到堆栈。这是1000000000000000000000的十进制,也就是我们给totalSupply赋值。

CALLER操作码,在第0022行检索Solidity的msg.sender

随后,在005F行,我们可以看到SSTORE,它实际上是在映射中存储常量。

在第0060-006C行,CODECOPY将当前代码从偏移量0x006E复制到偏移量0x00的内存中,该偏移量刚好在init code的最后一行。RETURN被执行。

这实际上是将 runtime code完整地返回给EVM,以便它将其作为代码存储在账户的状态中。这种行为,类似于一个安装者(init code),想要部署它所持有的一段代码(runtime code)。

由于在以太坊中存储数据的成本很高,最好只存储我们真正打算多次使用的代码,即运行时代码(runtime code)。这就是为什么初始化合约状态的一次性设置代码,即 constructor,不是部署代码的一部分。进行一个疯狂类比,设置代码可以被看作是火箭飞船的助推器,在使用一次后就被处理掉。然而,当一次性助推器永远消失在海洋中时,设置代码作为合约部署交易的一部分永远记录在链上。保留部分对于分析新部署的合约往往是有用的。

我们可以用一幅漂亮的ASCII图来总结本节,它显示了 "初始代码(init code)" 是如何组成的。

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+                           +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           |                                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                                   +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                          runtime_code                         |
+                     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     |
+-+-+-+-+-+-+-+-+-+-+-+

我们有110字节的 "设置代码(setup code)",用于初始化合约的状态,附加189字节的 "运行时代码(runtime code)",将被部署到链上。

构造函数参数

如果我们想在构造函数的参数中指定总的发行量呢?这在同一合约将以不同的初始化值被多次部署的情况下最为有用:

pragma solidity ^0.8.13;

contract MyCoin2 {
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    balances[msg.sender] = _totalSupply;
  }
}

为构造函数提供参数的能力允许 Solidity 开发人员改变合约的初始化状态,而不必重新编译合约。这与我们观察到的在字节码中把初始化参数设置成常量的以往方法不同。

对源码模式的小改变对 init code 有实质性的影响:

solc --bin ~/Desktop/MyCoin2.sol

======= Desktop/MyCoin2.sol:MyCoin2 =======

Binary: 6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033

让我们检查一下代码的大小:

In [3]: hex(len(binascii.unhexlify('6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffff
ffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b
5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033')))

Out[3]: '0x122'

这个链接 是反汇编的init code除去runtime后的代码

Solidity代码中的一个小变化引起了EVM字节码的巨大变化。当我们处理EVM字节码时,提供构造函数参数是相当复杂的。

我们建议读者用evm.codes 来执行每一步代码。

虽然也可能通过反汇编整个代码来了解发生了什么,但让我们查看反编译版本中的main()函数:

 pragma solidity ^ 0.8 .13;
 ...

 var temp0 = memory[0x40: 0x60];
 var temp1 = code.length - 0x0122;
 memory[temp0: temp0 + temp1] = code[0x0122: 0x0122 + temp1];
 memory[0x40: 0x60] = temp1 + temp0;
 var0 = 0x2f;
 var var2 = temp0;
 var var1 = var2 + temp1;
 var0 = func_00AD(var1, var2);

main()函数抓取CODESIZE操作码的结果,用于计算当前运行代码的大小,并从中减去0x0122。记住,init code0x0122字节。init code 在意它的大小,并期望在执行前有东西附加到它上面!

反编译中的其他函数用来验证附加在init code上的东西不大于0x20(32)字节,并获取它。

然后我们看到这个值在同一设置代码中使用,因为它被SSTORE存储在合约的状态中。

总之,当EVM执行init code时,构造函数参数会被附加到init code上。负责正确追加参数的是部署者,而不是编译器。再次使用一个图表示:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     |                                                         |
+-+-+-+                                                         +
|                          runtime_code                         |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |                   constructor_arguments                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |
+-+-+

我们可以看到这次的setup_code非常笨重,因为它包含了辅助函数来检索附加在runtime_code末尾的constructor_arguments,而这次的runtime_code出乎意料的小!

为什么这次的 runtime code 只有63字节?(提示:我们是否在合约中省略了任何可公开访问的函数?🙂)

常量(Constant) 与不可变量(Immutable)

我们已经看 到Solidity编译器是如何将定义为常量的值植入EVM字节码的。这并不是在Solidity中可以声明不可修改的变量的唯一形式, 我们也可以将状态变量声明为 immutable

constant变量不同,immutable变量可以在构造函数中被初始化:

pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint256 public immutable totalSupply;
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    totalSupply = _totalSupply;
    balances[msg.sender] = _totalSupply;
  }
}

通过这样的设置,会在运行时字节码中看到一大块零:


solc --bin ~/Desktop/MyCoin3.sol

======= Desktop/MyCoin3.sol:MyCoin =======

Binary: 60a060405234801561001057600080fd5b506040516101d43803806101d4833981810160405281019061003291906100be565b8060808181525050806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550506100eb565b600080fd5b6000819050919050565b...

剩余50%的内容订阅专栏后可查看

2 条评论

请先 登录 后评论
翻译小组
翻译小组 - 首席翻译官

176 篇文章, 31498 学分