深入理解 Solidity - 关于合约代码
2023-02-2718:00
登链社区
2023-02-27 18:00
登链社区
2023-02-27 18:00
收藏文章
订阅专栏
  • 原文链接:https://betterprogramming.pub/solidity-tutorial-all-about-code-10889b88632f
  • 译文出自:登链翻译计划[1]
  • 译者:翻译小组[2]  校对:Tiny 熊[3]

了解智能合约的字节码的结构和行为

图片来源:Eva Gorobets[4] on Unsplash

本文是 "理解 EVM , 关于数据位置的一切[5]"子系列的第五篇,每篇文章都干货满满, 其他几篇如下:

深入 Solidity 数据存储位置[6]深入 Solidity 数据存储位置 - 存储[7]深入 Solidity 数据存储位置 - 内存[8]深入了解 Solidity 数据位置 - Calldata[9]深入了解 Solidity - 堆栈[10]

这篇文章重点介绍了在 Solidity 中可以访问的 EVM 的最后一个数据位置:智能合约的字节码。

我们将在架构层面上考察合约的字节码的大部分内容。这包括对 "智能合约的字节码存储在哪里 "的一些详细解释,以及创建时(creation)和运行时(runtime)代码的区别。

我们还将解密部署智能合约时运行的字节码,以了解当我们部署一个没有 constructor的智能合约时,它是如何工作的。这将有助于我们理解 EVM 如何(以及为什么)返回智能合约的运行时代码,将其保存在智能合约地址下的以太坊的世界状态。

我们最后将看看围绕 OpenZeppelin 库的isContract()函数的一些安全注意事项,这些注意事项与EXTCODESIZE操作码直接相关。

代码的基础知识

当我们学习以太坊时,首先了解到的是以太坊上有两种类型的账户[11]-- 外部拥有的账户(EOAs)和智能合约。以太坊网站提供了以下的定义:

  • 外部拥有的账户(EOAs)-- 由任何人通过其私钥控制。
  • 合约账户[12]-- 部署在网络中的智能合约,由代码控制。

由于外部拥有的账户(或 EOAs)在其地址下没有存储代码,这就是智能合约的独特之处:其代码,也被称为 "合约字节码"。

一个合约的字节码是构成智能合约逻辑的所有 EVM 指令的存储地。代码中的每个字节都是一个操作码的十六进制表示。因此,合约代码的字节码是:

EVM.codes 的解释为 "字节码是智能合约执行过程中 EVM 读取、解释和执行的字节。"

我们使用术语 "字节码 "而不是 "代码",以避免混淆并与 Solidity 高层代码相区别。

合约字节码的属性

机器不遵循标准的冯 - 诺依曼架构。它不是将程序代码存储在一般可访问的内存或存储器中,而是单独存储在一个虚拟 ROM 中,只能通过专门的指令进行交互。

如果我们看一下以太坊黄皮书的这段摘录,我们可以看到合约的字节码被存储在一个单独的虚拟 ROM(只读存储器)中。这给我们带来了合约代码的一个重要特征:代码是不可改变的。

这意味着一旦合约被部署,合约的代码就不能被修改。它的指令数据,存储在代码中(构成智能合约逻辑的操作码),是持久的,如上所述,是账户状态字段的一部分。

一旦合约被部署,其代码就不能被改变。因此,存储在代码中的数据和变量是只读的,不能编辑。

将变量存储在合约的字节码内是 Gas 高效的。从合约字节码中访问这些变量是廉价和高效的。

与代码有关的操代码。

有四个操作码与合约的字节码有关。

  • CODESIZE
  • CODECOPY
  • EXTCODESIZE
  • EXTCODECOPY

操作码CODESIZECODECOPY使你能够读取和复制我们目前正在执行的合约的字节码。

最后,EXTCODESIZEEXTCODECOPY使你能够从一个合约中提供体统的地址读取和复制另一个外部合约的字节码。

代码的布局

注意:请参阅系列文章,来自 OpenZeppelin " 解构 Solidity 合约 [13]”,以深入了解一个合约字节码的布局。

代码是由字节组成的(与存储不同,它是由 槽(slot) 组成的)。在智能合约的字节码中,不存在 的概念。存储在合约字节码中的变量,如 constantimmutable,编译器可能放置在代码中的任何位置。

代码总是 32 字节的倍数。参见 zkSync 的 L1ERC20Bridge 使用的 L2ContractHelper。

Solidity 库合约 L2ContractHelper 来自 GitHub 上的 zkSync[14]

智能合约的运行时字节码可以被分成三个主要部分:

  • 调度器 (dispatcher):也被称为 "枢纽 (hub)",旨在通过分析 calldata 并将其与函数选择器进行比较来找到智能合约。
  • 函数包装器:旨在解包 / 拆包函数参数,并包装由函数主体返回的值。
  • 函数主体:包含 Solidity 函数的主要逻辑。

参见解构 Solidity 合约 #1 - 字节码[15] 文章的解构图[16]

除了这三个主要部分,智能合约的字节码还包括三个小部分:

  • 自由空闲指针[17]
  • Calldata 检查:确保我们至少发送四个字节函数选择器。如果没有,则使用receive/fallback函数作为默认的函数处理程序。
  • 合约元数据[18]

为了简洁起见,我们将不详细包括这些部分。然而,我强烈建议你看看上面提到的专栏的 OpenZeppelin 系列解构文章,以便深入了解。

专栏:理解 EVM[19] 已经包含 OpenZeppelin 系列文章:

解构 Solidity 合约 #1: 字节码[20]

解构 Solidity 合约 #2: 函数选择器[21]

解构 Solidity 合约 #3:函数包装器[22]

解构 Solidity 合约 #4:  函数体[23]

我们看看调度器是如何工作的,因为它是任何智能合约字节码中的主要通用组件之一(每个合约的其余字节码是独特的,因为它取决于 Solidity 合约的内部逻辑)。

调度器(dispatcher)

感谢[Faheel (721Orbit)](https://twitter.com/721Orbit "Faheel (721Orbit "Faheel (721Orbit)")") 为本文撰写本节内容并提供 CLI 中的插图。

你有没有想过,你的智能合约在收到 calldata 时如何知道要执行哪个外部 / 公共函数?

正如我们所看到的,一个合约的 EVM 字节码的结构本身就包含了大量的数据,即使是它发出的一个小的Ownable合约。

其中一个相当小但重要的部分是一个调度器。让我们以一个Ownable合约为例,看看调度器如何工作。下面是代码:

pragma solidity >= 0.7 .0 < 0.9 .0;

contract Ownable {
address private owner;

// event for EVM logging
event OwnerSet(address indexed oldOwner, address indexed newOwner);

// modifier to check if caller is owner
modifier isOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}

/**
* @dev Set contract deployer as owner
*/
constructor() {
owner = msg.sender; // 'msg.sender' is sender of current call, contract
// deployer for a constructor
emit OwnerSet(address(0), owner);
}

/**
* @dev Change owner
* @param _newOwner address of new owner
*/
function updateOwner(address _newOwner) external isOwner {
emit OwnerSet(owner, _newOwner);
owner = _newOwner;
}

/**
* @dev Return owner address
* @return address of owner
*/
function getOwner() external view returns(address) {
return owner;
}
}

为了解释什么是调度器以及它是如何工作的,让我们看一下上面的 Solidity 代码。我们的Ownable合约包含两个外部函数:

  • updateOwner(address newOwner) => 四字节的函数签名 = 0x880cdc31.
  • getOwner() => 四字节的函数签名 = 0x893d20e8

如果你用solc命令为这个合约生成运行时字节码,它看起来会是这样的。

solc — bin-runtime Ownable.sol

你将在 CLI 中获得以下运行时字节码作为输出:

这个字节码包含了一堆十六进制代码,如果我们把它分解成代表操作码的代码,就会更有意义。在生成它的反汇编代码时,我们得到合约字节码的所有操作码表示,如下所示:

译者注:反编译工具可以使用:evmasm[24]

整个反汇编代码是相当大的,但我想让你关注红框内的操作码:这个红框代表了我们字节码中的调度器。

那么,什么是调度器?调度器是运行时字节码的一部分,它检查用户要求执行的函数在智能合约中是否存在。使用函数选择器来检查其存在。

  • 如果存在性检查通过(意味着该函数存在于合约中),它就会跳转到其函数主体来执行其逻辑。
  • 如果没有找到该函数的存在,它要么执行智能合约的 fallback函数,要么在合约不包含 fallback函数的情况下回退(revert)。

那么,调度器是如何工作的?调度器如何找到要执行的函数?

让我们再仔细看一下反汇编。如果用户想执行我们合约中的getOwner函数,函数调用 calldata 将是0x893d20e8...

调度器包含所有的函数签名。如果你看一下下面调度器中0x210x2c的位置,他是updateOwner(address newOwner)getOwner()的函数签名。

根据反汇编,调度器将开始比较(使用EQ opcode)我们的 calldata 和里面所有的函数签名。

  • 如果它与位置0x21的函数签名相匹配,它将跳到字节码中0x003b的位置,执行updateOwner(address newOwner)的逻辑。
  • 如果它与位置0x2c的函数签名匹配,它将跳转到字节码中的位置0x0057,执行getOwner()的逻辑。
  • 如果它不能匹配调度器中定义的任何函数签名,它将revert[25],如位置 0x3a 所示。

在我们的例子中,由于我们想执行调度器(dispatcher)中定义的getOwner函数,它将跳到字节码中的0x0057位置,执行那里的任何逻辑。

你可以把调度器想象成一个switchcase 语句,就像你在许多编程语言中可能使用过的那样。switch case是如何工作的呢?它接受 switch 中的数据,并检查它是否与任何定义的 case 相匹配。同样地,我们可以写一些伪代码来描述调度器的样子。下面是一个例子:

智能合约的代码存储在哪里?

代码作为一个数据位置是指合约的字节码,所以你可能想知道这个(字节)代码存储在哪里。

合约代码存储在 EVM 的什么地方?

这是一个复杂的问题,需要一个指南来解决。正如我们将看到的低层,访问特定地址下的智能合约字节码的路径要经过多个步骤。但让我们先来回顾一下。

在介绍性文章["Solidity 教程:关于数据位置](https://learnblockchain.cn/article/4864 ""Solidity 教程:关于数据位置" ""Solidity 教程:关于数据位置" ""Solidity 教程:关于数据位置" ""Solidity 教程:关于数据位置") "中,我们强调了 EVM 中可用的不同数据位置,使用的是精通以太坊一书中的 EVM 架构图[26].

其中,存储(以下为绿色)和代码(以下为紫色)是与实际智能合约直接相关的两个数据位置(而内存或 calldata 与 EVM 执行环境有关的)。

指令数据是合约账户状态域的一部分。如果我们再看看下面的 EVM 架构图,我们可以想象账户状态(每个以太坊地址下的状态)、合约字节码和合约的存储之间的直接联系。

因此,对于 "智能合约的字节码存储 / 定位在哪里,如何访问?"这个问题的答案很简单,智能合约的字节码存储在账户状态下,在智能合约的地址状态下。

然而,这里面有一个细微的差别!智能合约的字节码不是直接存储在账户状态下。相反,它是被存储的codeHash

因此,我们接下来要了解合约的字节码存储在哪里的问题是:

  1. 什么是 codeHash
  2. 合约的字节码位于哪里?
  3. 为什么我们要对智能合约的字节码进行哈希处理?
  4. 为什么我们要将合约字节码的哈希值存储在账户状态中,而不是直接存储字节码?

回答问题 1),codeHash只是合约字节码的 keccak256 哈希值。

要回答问题 2),让我们看看这个图, 节选自黄皮书 详细的 EVM 架构图[27]

了解账户状态下的 codeHash(来源:以太坊黄皮书,第 4 页,柏林版[28])

从上图我们可以看到,账户状态只存储哈希值。无论是合约的存储还是合约的字节码。那么,如果我们只存储合约字节码的哈希值,实际的合约字节码存储在哪里呢?

如上图所示,《黄皮书》指出:

"所有这些(合约的)代码片段都包含在状态数据库中,在它们相应的哈希值下。"

这里的 "状态数据库 "指的是什么?

每个以太坊客户端(Geth、Nethermind 等)都在底层使用一个底层数据库(leveldb for Geth[29], rocksdb for Nethermind[30])。这种基本的底层数据库软件使你能够以基本的键值对来存储数据。数据可以被存储在一个特定的键下。

因此,一个智能合约的字节码被存储在以太坊客户端的底层数据库中,在合约字节码的 keccak256 哈希值对应的字段下。

最后,是时候回答最后一个问题了,3)和 4)。为什么我们要存储合约字节码的哈希值而不是直接存储合约的字节码?

使用codeHash而不是代码的唯一原因是为了性能和优化。

  • 出于性能的考虑

当智能合约的 noncebalancestorageRoot发生变化时,我们需要再次将合约的账户状态的四个元素重新洗牌("nonce "+"balance "+"storageRoot "+"codeHash")以得到该账户的根。

如果我们使用代码而不是codeHash,我们将不得不“重洗”所有的字段,导致一个更昂贵的计算,而只是使用codeHash,永远不会改变。

  • 为了优化以节省底层数据库的空间

当多个智能合约有相同的代码 / 字节码时(例如,10 个智能合约部署在 10 个不同的地址),我们可以在codeHash下只保存一次字节码,在每个智能合约地址下保存codeHash,而不是在每个地址下保存相同的字节码 10 次。这就避免了多次存储相同的数据,减少了以太坊客户端的底层数据库所使用的磁盘空间。

创建与运行时代码

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

专栏文章
查看更多
数据请求中

推荐专栏

数据请求中

一起「遇见」未来

DOWNLOAD FORESIGHT NEWS APP

Download QR Code