继续深入了解EVM , 本文探究一下智能合约的字节码
- 原文链接: https://betterprogramming.pub/solidity-tutorial-all-about-code-10889b88632f
- 译文出自:登链翻译计划
- 译者:翻译小组 校对:Tiny 熊
- 本文永久链接:learnblockchain.cn/article…
了解智能合约的字节码的结构和行为
图片来源:Eva Gorobets on Unsplash
本文是 "理解 EVM , 关于数据位置的一切"子系列的第五篇,每篇文章都干货满满, 其他几篇如下:
深入Solidity数据存储位置 深入Solidity数据存储位置 - 存储 深入Solidity数据存储位置 - 内存 深入了解Solidity数据位置 - Calldata 深入了解 Solidity - 堆栈
这篇文章重点介绍了在Solidity中可以访问的EVM的最后一个数据位置:智能合约的字节码。
我们将在架构层面上考察合约的字节码的大部分内容。这包括对 "智能合约的字节码存储在哪里 "的一些详细解释,以及创建时(creation)和运行时(runtime)代码的区别。
我们还将解密部署智能合约时运行的字节码,以了解当我们部署一个没有 constructor
的智能合约时,它是如何工作的。这将有助于我们理解EVM如何(以及为什么)返回智能合约的运行时代码,将其保存在智能合约地址下的以太坊的世界状态。
我们最后将看看围绕OpenZeppelin库的isContract()
函数的一些安全注意事项,这些注意事项与EXTCODESIZE
操作码直接相关。
当我们学习以太坊时,首先了解到的是以太坊上有两种类型的账户--外部拥有的账户(EOAs)和智能合约。以太坊网站提供了以下的定义:
由于外部拥有的账户(或EOAs)在其地址下没有存储代码,这就是智能合约的独特之处:其代码,也被称为 "合约字节码"。
一个合约的字节码是构成智能合约逻辑的所有EVM指令的存储地。代码中的每个字节都是一个操作码的十六进制表示。因此,合约代码的字节码是:
EVM.codes的解释为 "字节码是智能合约执行过程中 EVM 读取、解释和执行的字节。"
我们使用术语 "字节码 "而不是 "代码",以避免混淆并与Solidity高层代码相区别。
如果我们看一下以太坊黄皮书的这段摘录,我们可以看到合约的字节码被存储在一个单独的虚拟ROM(只读存储器)中。这给我们带来了合约代码的一个重要特征:代码是不可改变的。
这意味着一旦合约被部署,合约的代码就不能被修改。它的指令数据,存储在代码中(构成智能合约逻辑的操作码),是持久的,如上所述,是账户状态字段的一部分。
一旦合约被部署,其代码就不能被改变。因此,存储在代码中的数据和变量是只读的,不能编辑。
将变量存储在合约的字节码内是Gas高效的。从合约字节码中访问这些变量是廉价和高效的。
有四个操作码与合约的字节码有关。
CODESIZE
CODECOPY
EXTCODESIZE
EXTCODECOPY
操作码CODESIZE
和CODECOPY
使你能够读取和复制我们目前正在执行的合约的字节码。
最后,EXTCODESIZE
和EXTCODECOPY
使你能够从一个合约中提供体统的地址读取和复制另一个外部合约的字节码。
注意:请参阅系列文章,来自OpenZeppelin "解构Solidity合约 ",以深入了解一个合约字节码的布局。
代码是由字节组成的(与存储不同,它是由 槽(slot)
组成的)。在智能合约的字节码中,不存在 槽
的概念。存储在合约字节码中的变量,如 constant
或 immutable
,编译器可能放置在代码中的任何位置。
代码总是32字节的倍数。参见zkSync的L1ERC20Bridge使用的L2ContractHelper。
智能合约的运行时字节码可以被分成三个主要部分:
参见解构Solidity合约 #1 - 字节码 文章的解构图。
除了这三个主要部分,智能合约的字节码还包括三个小部分:
为了简洁起见,我们将不详细包括这些部分。然而,我强烈建议你看看上面提到的专栏的OpenZeppelin系列解构文章,以便深入了解。
专栏: 理解 EVM 已经包含OpenZeppelin系列文章:
我们看看调度器是如何工作的,因为它是任何智能合约字节码中的主要通用组件之一(每个合约的其余字节码是独特的,因为它取决于 Solidity 合约的内部逻辑)。
感谢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
整个反汇编代码是相当大的,但我想让你关注红框内的操作码:这个红框代表了我们字节码中的调度器。
那么,什么是调度器?调度器是运行时字节码的一部分,它检查用户要求执行的函数在智能合约中是否存在。使用函数选择器来检查其存在。
fallback
函数,要么在合约不包含 fallback
函数的情况下回退(revert)。那么,调度器是如何工作的?调度器如何找到要执行的函数?
让我们再仔细看一下反汇编。如果用户想执行我们合约中的getOwner
函数,函数调用calldata将是0x893d20e8...
。
调度器包含所有的函数签名。如果你看一下下面调度器中0x21
和0x2c
的位置,他是updateOwner(address newOwner)
和getOwner()
的函数签名。
根据反汇编,调度器将开始比较(使用EQ
opcode)我们的calldata和里面所有的函数签名。
0x21
的函数签名相匹配,它将跳到字节码中0x003b
的位置,执行updateOwner(address newOwner)
的逻辑。0x2c
的函数签名匹配,它将跳转到字节码中的位置0x0057
,执行getOwner()
的逻辑。在我们的例子中,由于我们想执行调度器(dispatcher)中定义的getOwner
函数,它将跳到字节码中的0x0057
位置,执行那里的任何逻辑。
你可以把调度器想象成一个switch
case语句,就像你在许多编程语言中可能使用过的那样。switch case
是如何工作的呢? 它接受 switch
中的数据,并检查它是否与任何定义的 case
相匹配。同样地,我们可以写一些伪代码来描述调度器的样子。下面是一个例子:
代码作为一个数据位置是指合约的字节码,所以你可能想知道这个(字节)代码存储在哪里。
合约代码存储在EVM的什么地方?
这是一个复杂的问题,需要一个指南来解决。正如我们将看到的低层,访问特定地址下的智能合约字节码的路径要经过多个步骤。但让我们先来回顾一下。
在介绍性文章"Solidity教程:关于数据位置 "中,我们强调了EVM中可用的不同数据位置,使用的是精通以太坊一书中的EVM架构图.
其中,存储(以下为绿色)和代码(以下为紫色)是与实际智能合约直接相关的两个数据位置(而内存或calldata 与EVM执行环境有关的)。
指令数据是合约账户状态域的一部分。如果我们再看看下面的EVM架构图,我们可以想象账户状态(每个以太坊地址下的状态)、合约字节码和合约的存储之间的直接联系。
因此,对于 "智能合约的字节码存储/定位在哪里,如何访问?"这个问题的答案很简单,智能合约的字节码存储在账户状态下,在智能合约的地址状态下。
然而,这里面有一个细微的差别!智能合约的字节码不是直接存储在账户状态下。相反,它是被存储的codeHash
。
因此,我们接下来要了解合约的字节码存储在哪里的问题是:
codeHash
?回答问题1),codeHash
只是合约字节码的keccak256哈希值。
要回答问题2),让我们看看这个图, 节选自黄皮书 详细的EVM架构图。
了解账户状态下的codeHash(来源:以太坊黄皮书,第4页,柏林版)
从上图我们可以看到,账户状态只存储哈希值。无论是合约的存储还是合约的字节码。那么,如果我们只存储合约字节码的哈希值,实际的合约字节码存储在哪里呢?
如上图所示,《黄皮书》指出:
"所有这些(合约的)代码片段都包含在状态数据库中,在它们相应的哈希值下。"
这里的 "状态数据库 "指的是什么?
每个以太坊客户端(Geth、Nethermind等)都在底层使用一个底层数据库(leveldb for Geth, rocksdb for Nethermind)。这种基本的底层数据库软件使你能够以基本的键值对来存储数据。数据可以被存储在一个特定的键下。
因此,一个智能合约的字节码被存储在以太坊客户端的底层数据库中,在合约字节码的keccak256哈希值对应的字段下。
最后,是时候回答最后一个问题了,3)和4)。为什么我们要存储合约字节码的哈希值而不是直接存储合约的字节码?
使用codeHash
而不是代码的唯一原因是为了性能和优化。
当智能合约的 nonce
、balance
或 storageRoot
发生变化时,我们需要再次将合约的账户状态的四个元素重新洗牌("nonce "+"balance "+"storageRoot "+"codeHash")以得到该账户的根。
如果我们使用代码而不是codeHash
,我们将不得不“重洗”所有的字段,导致一个更昂贵的计算,而只是使用codeHash
,永远不会改变。
当多个智能合约有相同的代码/字节码时(例如,10个智能合约部署在10个不同的地址),我们可以在codeHash
下只保存一次字节码,在每个智能合约地址下保存codeHash
,而不是在每个地址下保存相同的字节码10次。这就避免了多次存储相同的数据,减少了以太坊客户端的底层数据库所使用的磁盘空间。
注意:你会在网上...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!