您正在查看: 2023年2月

解构 Solidity 合约 #4: 函数体

这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的Solidity合约EVM字节码

我们已经走过了很长的路,不是吗?首先,我们理解了合约的创建时间和运行时字节码之间的区别;接下来,我们理解了来自任何调用或交易的执行入口是如何通过函数选择器被路由到特定的函数的;最后,我们看到了传入的交易数据是如何被解包给函数使用的,以及函数产生的数据是如何通过函数包装器为用户重新打包的。在这一节中,我们将(最后)看看函数的实际执行情况,或者我们通常称为 "函数体" 的部分。

函数体正是函数包装器在解开传入的calldata后所跳入的部分。当一个函数体被执行时,函数的参数应该安然无恙的在堆栈中(如果数据是动态的,则在内存中),等待被使用。让我们看看balanceOf(address)函数的实际应用。这个函数应该接收一个 address,并返回这个地址相应的 uint256 的余额。

让我们回到Remix,像以前一样编译和部署合约,然后调用balanceOf函数,把部署合约时用的地址作为参数。这应该返回数字10000,因为它是最初赋值给构造函数代码中部署合约的地址的,我们在部署合约时使用了这个地址。

好了,现在让我们来调试一下这个交易。

你会注意到的第一件事是,调试器把我们放在了指令252处。如果你看一下解构图,在包装器的蓝色部分,你应该看到balanceOf函数包装器将指令175处重定向到251的JUMPDEST指令。正如我们之前多次看到的,Remix将我们精确地放在了函数主体即将被执行的位置。

图1. 函数包装器将执行重定向到函数体(指令175的蓝色虚线)


图2. 函数体的执行,来自于函数包装器(指令251处的蓝色虚线)。

现在,如果你看一下堆栈,你会发现它最上面的值是我们调用balanceOf的地址。包装器已经完成了正确解包calldata的工作。所以我们准备通过指令251到290,即balanceOf函数体。

指令252推送了一个20字节的0xffffffffffff值,并使用AND操作码将32字节的地址 mask(掩码)为正确的类型(记住,以太坊的地址是20字节的,而堆栈的操作是32字节的字)。

在指令274至278中:

字节码将把地址从堆栈上传到内存。它需要这个地址用于即将到来的 SHA3 操作码。如果你看一下黄皮书,SHA3操作码有两个参数:计算哈希值的内存位置和哈希值的字节数。

但是,为什么代码会使用SHA3操作码?这个函数想从balances映射中读取。更确切地说,它想读取映射到的地址的值。如果你了解映射在存储中的布局,变量槽 (在这里是1)的哈希值,因为balances被定义为第二个变量(totalSupply_是第一个变量,在槽0),实际的键本身是地址,SHA3需要这两个值寻找的值在存储中的位置。

其槽位置计算方式大概可以表述为:

keccak(
  leftPadTo32Bytes(addr) ++ leftPadTo32Bytes(1)
) 
用 Solidity 代码表示:
bytes32 aliceBalanceSlot = keccak256(
    abi.encodePacked(uint256(uint160(address)), uint256(1))
);

所以,我们已经得到了内存中的地址,但现在我们需要内存中的插槽。这就是指令279和283之间接下来发生的事情:

数字0x01被存储在内存位置0x20。现在内存保存着第一个字的地址,即内存位置0x00,和第二个字的槽,即内存位置0x20。耶! 我们准备调用SHA3。

于是在指令284和287之间调用了它。

当287号指令调用SHA3时,堆栈包含0x00(SHA3的起始位置)和0x40(SHA3的长度),这基本上是告诉EVM在前两个32字节的字中对内存中的任何内容进行哈希。32个字节的十六进制是0x20,所以0x20+0x20等于0x40。

现在,SHA3在堆栈中留下了32字节的哈希值,这是一个非常长的十六进制数字,比以太坊地址长很多。这个哈希值是合约存储中的位置,传递给balanceOf的地址的余额就存储在这里。你可以使用Remix调试器中的Storage completely loaded面板来直观地看到这一点。你应该在第二个存储对象中找到一个匹配的位置。

在这个位置上存储了什么?数字10000,或者十六进制的0x2710。在第288条指令中,SLOAD接收了从存储位置(我们的哈希值)读取的参数,并将0x2710推到堆栈。

最后,在第289条指令中,SWAP1重新显示了函数包装器的JUMPDEST位置(0x70,或112),第290条指令中的JUMP将我们带回函数包装器的输出部分,它将重新包装0x2710以返回给用户。

我强烈建议你回顾一下我们刚才对balanceOf的调试过程,再对totalSupply和transfer函数的进行调试。前者非常简单,而后者要复杂得多,但基本上是由相同的结构块组成的。秘密在于理解如何从映射中读取数值和写入映射。真的没有什么更多的东西了。

现在让我们回到大解构图:

图3. 函数包装器之后的函数体。

正如我们之前所讨论的,函数体都集中在函数封装器之后。执行流从包装器中跳到它们,并在执行完每个函数的指令后返回到包装器。
如果你仔细看这张图,在函数体之后有一大块代码,叫做 "元数据哈希"。这是一个非常简单的结构,在下一篇文章我们将解析一下这个部分。

转载:https://learnblockchain.cn/article/5263

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

这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的Solidity合约EVM字节码

上一篇文章中,我们看到函数选择器在我们的BasicToken.sol合约中是如何充当枢纽作用。它位于合约的入口处,并将执行重定向到调用者想要运行的合约中的匹配函数。

图1. 来自函数选择器的重定向,查看解构图

如果被调用的是 totalSupply 函数,执行将被重定向到位置91,balanceOf 函数被重定向到 130,以此类推。

现在让我们像以前一样在Remix中启动一个新的调试会话,并再次调用totalSupply函数。请确保始终展开指令面板,这是Remix调试器的核心所在。正如我们之前看到的,Remix会把你放在指令246处,在那里函数的主体即将被执行。上一次,我们把交易滑块从这个位置拉回到指令0,因为我们想研究合约的入口点,以及它是如何从那里到达函数的入口点的。这一次,我们也要回去,但要回到指令91,而不是指令0,因为有这个东西,Solidity用来包裹一个函数的主体。别担心,我们会在下一篇文章中很快讲到函数的主体。我们就快到了,你的耐心会得到回报的。

所以,回到指令91,这是函数选择器指向的地方,因为函数ID与totalSupply(0x18160ddd)匹配。在这一点上,堆栈应该只包含函数的id。现在让我们从这里开始走一遍代码。

图2. non-payable 检查

如果交易中涉及到价值(即以太币),指令92到103 会被回退。同样,这也是Solidity编译器在一个函数不是 payable 时注入的一个非常常见的检查结构。我们看到这个完全相同的东西被用在构造函数中,在本系列的字节码一文,它也是一个不可支付的函数。这个 非payable 结构将检查CALLVALUE是否为零,如果是,将跳转到指令103(0x67),跳过指令102的REVERT操作码。

如果你在Remix上调用totalSupply而没有附加任何价值(ETH),将到达指令103。指令104清理了堆栈中剩余的一个零,然后112(十六进制0x0070)和245(十六进制0x00f5)被推到堆栈中。执行立即跳转到后一个位置:245号指令。注意,跳转发生在指令111,而之前推送的是112,所以想象一下代码跳去某个地方做什么,然后跳回来,也就是说,它将记住我们离开的地方(112),跳转,然后返回。

图3. 函数包装器跳入函数体(黄色虚线)。

让我们看看,通过跳转到那个神秘的245位置,是否真的会发生返回呢?

图4. totalSupply函数体。

如果你单步调试245到250,你会发现我们的分析确实是正确的。代码执行的这部分是实际的函数体,其内部工作原理现在对我们并不重要。对于本文的范围来说,重要的是代码是如何到达和离开这个 "主体" 的,也就是说,它是如何环绕它的。它跳进了的函数体,又跳出了的函数体。因此,我们看到250处的 "JUMP "将我们带回了112处,正如我们巧妙地预测的那样。

如果这是帝国时代,我们现在应该听到青铜时代的狂欢。没错,我们正在疯狂地使用JUMP和JUMPI在字节码中导航!这就是我们的目标。

但是!这次在堆栈中出现了新东西:数字10000(十六进制0x2710)。

我们刚刚执行的函数体很好心地为我们把它放在那里。记住,我们正在调用totalSupply函数。以某种方式,你需要把这个值从堆栈中拿到RETURN操作码中,这样它就可以被返回给用户。这正是代码在指令113到129之间所做的,在最后有一个实际的RETURN操作码。

图5. 一个uint256内存返回器结构

它首先会读取当前的空闲内存指针(指令113到116),然后将函数主体放在堆栈中的值复制到该空闲空间(指令117到119),最后在内存中存储数字10000(十六进制0x2710)。看到了吗,我们已经很擅长这个了! 如果你不相信我,就在Remix中浏览一下操作代码。听起来很复杂,其实不然。

最后,代码将计算出需要返回的数据的大小。我们接下来看一下:

图6. 内存指针的偏移?

它首先再次加载内存指针,并在指令120到124中使用减法与之前的内存指针进行比较,很可能是为了计算出需要返回的数据的大小。这个值似乎是在指令125中硬编码的,这似乎是多余的。这可能是优化器意识到返回数据的大小可以硬编码以节省一些Gas的结果,在应用优化后,一些残留的操作码被留下了。

这是一个奇怪的字节码的完美例子,它显然没有做任何相关的事情,或者看起来是多余的。忽略那些看起来没有任何作用的操作码是可以的,学会与它们共存(或者说 "通过它们")并简单地继续前进。随着你阅读越来越多的字节码,你会开始识别这些通用的、显然是空洞的结构的目的。

现在,这些深奥的废话已经足够了, 让我们回到地面上:

图7. 向用户返回数值

指令125将数字 32 (十六进制0x20)推到堆栈,并将其加上偏移量,将这些值交换,以符合RETURN消耗其值的顺序,用户有totalSupply值返回。

好的。所以,我们看到了代码是如何从函数选择器出发,进入这个包装结构,进入函数体,又从函数体出来,然后处理函数体产生的返回值,并打包这些数据返回给用户。那么,我们是否应该看看其他的函数,看看我们是否也能在其中观察到类似的模式?

如果你想先休息一下,这将是最佳时机。我们接下来要做的是简单地重复我们刚刚在其他两个函数中分析的结构,顺便在这里和那里加点眼色和一点魔法。

喝杯咖啡吧!

那么,这就是一个函数:第两个函数,让我们接下来看看balanceOf函数。

我们强烈建议你快速浏览一下解构图,以便直观地验证刚刚发生在totalSupply上的事情,并了解我们将在balanceOf上做什么。

函数选择器应该把我们带到指令130,也就是balanceOf的包装器,然后从那里把我们带入函数的主体,再从函数体出来,为用户打包返回值。然而,如果你注意到图中的情况,代码确实像预期的那样跳入了函数的主体,但是它返回到了totalSupply的包装器,而不是它自己的包装器。为什么?


图8. balanceOf的蓝色包装器跳回 totalSupply的黄色包装器。

一个诱人的原因是,由于totalSupply和balanceOf都返回一个uint256值,从堆栈中抓取一个uint256值并通过内存返回一个uint256的代码块是相同的,可以重复使用。Solidity编译器可能注意到为这两个包装器生成的部分代码是相同的,并决定重新使用这些代码以节省Gas。实际上,它就是这样做的,如果我们在编译合约时没有启用优化功能,我们就不会观察到这一点。让我们把这个被重用的结构称为 uint256内存返回器。一个很好的练习是在没有优化的情况下编译合约,并自己验证这一点。

Remix 时间, 让我们开始一个新的调试会话,使用我们部署合约的账户地址作为参数,调用balanceOf函数。它应该返回数字10000,因为代币的创建者最初持有所有的币。在Debug区域,退到指令142,也就是函数选择器离开的地方。


图9. balanceOf 函数包装器

在指令144,112(十六进制0x0070)被推入堆栈 -- 毫不奇怪,这就是我们刚才看到的uint256 内存返回器 所在的位置。代码即将跳转到balanceOf的主体,它正在记录主体执行后要跳回哪里。

然而,在指令175处跳入函数体的过程并没有立即发生。在它真正进行跳转之前,在指令147和172之间有一些事情正在发生:

图10. 解包 Calldata

在指令147中,一个带有40个f的十六进制数字(20个字节)被推到堆栈中,然后是推入 4。CALLDATALOAD被调用,以4为参数,其效果是在函数id之后从我们的calldata中读取第一个字(32字节)的数据。如果这听起来很奇怪,那么我建议你看一下系列函数选择器部分,在那里我们分析了calldata的工作原理。这个词是我们传入函数调用的参数,也就是我们在调用balanceOf时要检查其余额的地址。这个地址被大的0xffffffffffffffffffffffffff数字掩盖,用于类型检查/掩盖,然后在指令175中跳转到指令251(十六进制0x00fb)的目标函数体,从calldata读取的地址,舒服地放在堆栈中,准备供主体使用。

因此,我们可以看到,函数包装器的工作不仅是重定向到函数体,并为用户包装从函数体返回来的任何东西,而且还要包装供函数主体使用参数。这样,函数包装器的本质就完全展现在我们面前了!

函数包装器是一个中介,它为函数主体使用的calldata进行解包,将执行路由给它,然后为用户重新打包任何返回来的数据。这个包装器结构适用于所有属于 Solidity 合约公共接口的函数。

这种打包和解包是如何完成的,在以太坊的应用二进制接口规范中有细致的定义,它规定了函数调用中传入和传出的参数是如何编码的。

现在,让我们快速看看这3个函数包装器的整体情况:

图11. 在函数选择器之后的函数包装器。

很容易看到,在由Solidity编译的智能合约中,在函数选择器之后的一大块代码是函数包装器,一个接一个。是的,实际的函数体是在包装器之后的下一大块代码,在那之后有一个小的特别部分,叫做 "元数据哈希",我们在未来的文章中也会看到。

Solidity 编译器产生的 EVM 输出中看到一个宏大结构,在我们面前,它正慢慢变得不再神秘/混乱。当分析一个合约的字节码时,你将很快学会首先尝试看看你在这个宏大结构中的位置,然后再真正深入到字节码的步骤细节中。


图12. 大结构:函数选择器、包装器和函数体

正如我们在本系列的前几部分所做的那样,我们把对transfer函数的调用的调试工作留给你。你应该看到包装器这次是如何解压两个值的-- 接收者_to地址,以及转移的_value--将其发送给函数体,然后获取函数体的响应,再打包给用户。很有意义,对吗?

在本系列的下一部分中,我们将最终研究函数体。一旦我们做到了这一点,就没有什么其他的事情可做了......只有一些细节需要涵盖,我们就完成了。这种分而治之的策略真的开始让我们在本系列文章开始时要解决的问题,起初似乎不堪一击,但现在开始成为我们可以熟悉的模式。下次你看到一个操作码时,你将不会感到害怕。

转载:https://learnblockchain.cn/article/5212

解构 Solidity 合约 #2 - 函数选择器


这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的Solidity合约EVM字节码

上一篇文章中,我们发现有必要将合约的字节码分为创建时和运行时代码。在对创建部分进行了深入研究之后,现在是时候开始对运行时部分的探索了。

运行时代码

如果你看一下解构图,我们将从第二个大的部分开始,对应结构图标题为BasicToken.evm(runtime)的部分。

在一开始可能看起来有点吓人,因为运行时的代码看起来至少是创建代码的四倍!但不要担心,技能的学习是很重要的。在前面的文章中我们对EVM代码已经有所理解,结合无懈可击的分而治之的策略,使用系统化方式解决这个挑战,会把它变得容易。简单的开始查看代码,识别独立的结构,并继续分割,直到没有其他东西可以分割。

所以,为了开始,让我们回到Remix,用运行时字节码启动一个调试会话。我们怎么做呢?上一次,我们部署了合约并调试了部署交易。这一次,我们将与已部署的合约的接口交互,与它的一个函数交互,并对该交易进行调试。回顾一下我们的合约:
https://gist.github.com/ajsantander/dce951a95e7608bc29d7f5deeb6e2ecf#file-basictoken-sol

在Remix中使用Javascript虚拟机、启用了优化, 编译器0.4.24版以及10000作为初始发行量来部署它。一旦合约部署完毕,你应该看到它被列在Remix的DEPLOY & RUN面板的Deployed Contracts部分。点击它,展开合约的界面。就像专栏这篇文章那样进入调试。

展开合约的界面列出了合约的所有方法,这些方法要么是公共的(public),要么是外部的(external),也就是说,任何以太坊账户或合约都可以与之交互。私有的和内部的方法不会显示在这里,事实上,从 "外部世界 "是无法到达的。如何与合约的运行时代码的特定部分交互将是本文的重点。

入口检查

我们要不要试一下?点击Remix的Run面板上的totalSupply按钮。你应该马上看到按钮下面有一个响应。0: uint256: 10000,这是我们所期望的,因为我们以10000作为初始代币供应来部署合约。现在,在控制台面板上,点击调试(Debug)按钮,开始对这个特定的交易进行调试。注意,在Console面板上会有多个Debug按钮,请确保你使用的是最新的一个。

在这个例子中,我们不是在调试0x0地址的交易,它创建了一个合约, 正如我们在前面的文章中看到的。现在,我们要调试的是对合约本身的交易--也就是对其运行时代码的交易。

如果你打开调试面板,你应该能够验证Remix列出的指令与解构图BasicToken.evm(运行时)部分的指令是一致的。如果它们不匹配,就说明出了问题。试着重新开始,并确保你使用了上述的正确设置。

一切顺利吗?你可能注意到的第一件事是,调试器把你放在指令246处,交易滑块被定位在字节码的大约60%处。为什么呢?因为Remix是一个非常好的、慷慨的程序,它直接把你带到EVM刚要执行totalSupply函数的部分。然而,在这之前发生了很多事情,这些都是我们在这里要注意的。事实上,在这篇文章中,我们甚至不会去研究函数主体的执行。我们唯一关心的是Solidity生成的EVM代码如何引导进入的交易,我们将理解为合约的 "函数选择器 "的工作。

所以,抓住那个滑块,把它一直向左拖,这样我们就可以从指令0开始。正如我们之前所看到的,EVM总是从指令0开始执行代码,没有例外。让我们逐个操作码走过这个执行过程。

第一个出现的结构是我们以前见过的(实际上我们会看到很多)。

图1. 空闲内存指针。

这是Solidity生成的EVM代码, 在调用中总是在其他事情之前做的事情:在内存中保存一个位置以便以后使用。

让我们看看接下来会发生什么:

图2. Calldata长度检查。

如果你在Debug标签中打开Remix的Stack面板,走过指令5到7,你会看到堆栈现在包含数字4两次。如果你在阅读这些超长的数字时遇到困难,请注意调整Remix的Debug面板的宽度,使这些数字很好地融入单行。第一个数字来自普通的推送,但第二个数字是执行操作码CALLDATASIZE的结果,如黄皮书所述,它不需要参数,并返回 当前交易上下文环境中的输入数据 的大小,或者我们通常所说的calldata。

什么是calldata? 正如Solidity的文档ABI规范中所解释的那样,calldata是一个十六进制数字的编码块,它包含了关于我们想要调用合约的哪个函数的信息,以及它的参数或数据。简单地说,它由一个 "函数ID "组成,它是由函数的签名哈希值产生(截断到前四个字节)和打包的参数数据。如果你想的话,你可以详细研究一下文档链接,文档里有最详细的解释,也许一下子难以掌握,但先不要担心没法理解这种打包的工作方式,用实际的例子来理解要容易得多。

让我们看看这个calldata是什么。在Remix的调试器中打开Call Data面板,可以看到:0x18160ddd。这是四个字节,正是通过对字符串 totalSupply() 的函数签名应用 keccak256 算法,并进行前四个字节截断而产生的。由于这个特殊的函数不需要参数,它只是:一个四字节的函数ID。当CALLDATASIZE被调用时,它只是把第二个4推到堆栈上。

然后指令8使用LT来验证calldata的大小是否小于4。如果是,下面的两条指令表示跳转(JUMPI)到指令86(0x0056)。所以在此案例中,将不会有跳转,执行流程将继续到指令13。但在这之前,让我们想象一下,我们用空的calldata调用我们的合约--也就是用0x0而不是0x18160ddd。在Remix中你不能这样做,但如果你手动构建交易,你可以这样做。

在此案例中,我们会在86号指令中结束,它基本上是把几个0推到堆栈中,并把它们送入REVERT操作码。为什么呢?嗯,因为这个合约没有回退函数(fallback)。如果字节码不能识别传入的数据,它就会把数据流转到回退函数,如果没有回退函数 接住 这个调用,那么就会无情地终止执行。如果没有什么可以回退的,那么就没有什么可以做的,调用就会被完全退回(revert)。

现在,让我们做一些更有趣的事情。回到Remix的Run标签,复制Account地址,用它作为参数调用balanceOf而不是totalSupply,并调试该交易。这是一个全新的调试环节;现在我们先忘记totalSupply。导航到指令8,CALLDATASIZE现在将推送36(0x24)到堆栈。如果你看一下calldata,它现在是0x70a08231000000000000000000ca35b7d915458ef540ade6068dfe2f44e8fa733c。

这个新的calldata实际上是非常容易分解的:前四个字节70a08231是函数balanceOf(address) 签名的哈希值,而后面的32个字节包含我们作为参数传递的地址。好奇的读者可能会问,如果以太坊地址只有20个字节,为什么是32个字节?ABI总是使用32字节的 字 或 槽 来保存函数调用中使用的参数。

继续我们的balanceOf调用,让我们从第13条指令开始,这时堆栈中没有任何东西。第13条指令将0xffffffff推入堆栈,下一条指令将一个29字节长的0x000000001000...000数字推入堆栈。我们稍后会看到原因。现在,只需注意一个包含四个字节的f,另一个包含四个字节的0。

接下来CALLDATALOAD接收一个参数(第48条指令中推到堆栈的参数)并从该位置的Calldata中读取32字节的大块数据,在本例中Yul将是:

calldataload(0)

基本上是把我们的整个calldata推到堆栈中。现在是有趣的部分。DIV从堆栈中消耗了两个参数,把calldata除以那个奇怪的0x000000001000...000数字,有效地过滤了calldata中除了函数签名以外的所有东西,并把它单独留在堆栈中:0x000...000070a08231。下一条指令使用AND,它也消耗了堆栈中的两个元素:我们的函数ID和带有f的四个字节的数字。这是为了确保签名哈希值正好是8个字节的长度,掩盖了其他的东西(如果有任何东西存在的话)。我想这是Solidity使用的安全措施。

长话短说,我们已经简单地检查了calldata是否太短,如果是的话,就退回,然后把东西洗了一下,这样我们的函数ID就在堆栈里了:70a08231。

接下来的部分真的很容易理解:

图3. 函数选择器。

在指令53,代码将18160ddd(totalSuppy的函数ID)推入堆栈,然后使用DUP2来复制我们传入的calldata 70a08231值,目前在堆栈的第二个位置。为什么是DUP?因为指令59的EQ操作码将消耗堆栈中的两个值,我们想保留70a08231的值,因为我们已经费尽心思从calldata中提取它。

现在代码将尝试将calldata中的函数ID与一个已知的函数ID相匹配。由于堆中是 70a08231 ,它将不会与 18160ddd 匹配,跳过指令63的 JUMPI。但在接下来的检查中,它将与之匹配,并跳入指令74的JUMPI。

让我们花点时间观察一下,合约的每个公共或外部函数都有一个这样的匹配检查(EQ)。这是函数选择器的核心:作为某种开关语句,简单地将执行路由到代码的正确部分,它是我们的 "hub(枢纽)"。

因此,由于上一个案例是匹配的,执行流将我们带到130位置(0x82)的JUMPDEST,我们将在本系列下一部分看到它,它是balanceOf函数的ABI Wrapper(包装器)。这个包装器将负责对交易的数据进行解包,供函数主体使用。

继续,这次尝试调试transfer函数。函数选择器其实并不神秘。它是一个简单而有效的结构,位于每一个合约(至少是所有从 Solidity 编译的合约)的大门口,并将执行重定向到代码中的适当位置。它是Solidity赋予合约的字节码模拟多个入口点的能力的方式,因此也是一个接口。

看一下解构图,这就是我们刚刚解构的内容:

图4. 函数选择器和合约的运行时代码主入口点。
下一篇,我们继续解构 函数包装器。
转载:https://learnblockchain.cn/article/5197

解构Solidity合约 #1 - 字节码

解构Solidity合约 #1 - 字节码

专栏 理解 EVM - 探究Solidity 背后的秘密 继续加入OpenZepplin 解构合约系列文章。

你在路上,开着你那辆罕见的、完全修复的1969年野马马赫1快速行驶。阳光照射在所有原始的、华丽的镀金轮辋上,闪闪发光。只有你,道路,沙漠,以及对地平线的无尽追逐。完美无缺!

眨眼间,你的335马力的野兽被白烟吞没,仿佛变成了一个蒸汽机车,你被迫停在路边。带着决心,你打开引擎盖,才意识到你完全不知道自己在看什么。这台该死的机器是如何工作的?你抓住你的手机,发现你没有信号。

如果这听起来很熟悉,那么不用担心!本系列文章的目的是为了让你了解你的合约。我们将解构一个简单的 Solidity 合约,查看它的字节码,并将其分解为可识别的结构,直至最低层。我们将揭开 Solidity 的盖子。在本系列结束时,你在查看或调试EVM字节码时应该感到熟悉。这个系列的全部意义在于揭开由 Solidity 编译器产生的 EVM 字节码的神秘面纱。而且它真的比看起来要简单得多。

注意:这个系列的目的是针对那些已经熟悉 Solidity 合约开发有经验的开发者,但想了解事情是如何在一个稍深/较底层的工作 - 即 Solidity 编译器如何将 Solidity 翻译成 EVM 的字节码,以及 EVM 如何执行这种字节码。如果你还没到那一步,我推荐你阅读Solidity 合约开发专栏

下面是我们要解构的合约:

pragma solidity ^0.4.24;


contract BasicToken {

  uint256 totalSupply_;
  mapping(address => uint256) balances;

  constructor(uint256 _initialSupply) public {
    totalSupply_ = _initialSupply;
    balances[msg.sender] = _initialSupply;
  }


  function totalSupply() public view returns (uint256) {
    return totalSupply_;
  }


  function transfer(address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[msg.sender]);
    balances[msg.sender] = balances[msg.sender] - _value;
    balances[_to] = balances[_to] + _value;
    return true;
  }


  function balanceOf(address _owner) public view returns (uint256) {
    return balances[_owner];
  }
}

注意:这个合约很容易受到溢出攻击的影响,但我们在这里只是为了手头的工作而保持简单。

编译合约

为了编译合约,我们将使用Remix。点击文件浏览区上方左上角的+按钮,创建一个新的合约。将文件名设为BasicToken.sol。现在,将上述代码粘贴到编辑器部分。

在左侧编译 Tab部分,进入编译设置,确保选择了启用优化。另外,确认所选的Solidity编译器的版本是: "0.4.24+commit.e67f0147"。这两个细节非常重要,否则你将会看到与这里讨论的略有不同的字节码。

如果你进入Compile标签并点击Bytecode按钮,会复制Solidity编译器生成的所有东西,其中之一是一个名为BYTECODE的JSON对象,它有一个 "object "属性,是合约的编译代码。它看起来像这样:

608060405234801561001057600080fd5b5060405160208061021783398101604090815290516000818155338152600160205291909120556101d1806100466000396000f3006080604052600436106100565763ffffffff7c010000000000000000000000000000000000000000000000000000000060003504166318160ddd811461005b57806370a0823114610082578063a9059cbb146100b0575b600080fd5b34801561006757600080fd5b506100706100f5565b60408051918252519081900360200190f35b34801561008e57600080fd5b5061007073ffffffffffffffffffffffffffffffffffffffff600435166100fb565b3480156100bc57600080fd5b506100e173ffffffffffffffffffffffffffffffffffffffff60043516602435610123565b604080519115158252519081900360200190f35b60005490565b73ffffffffffffffffffffffffffffffffffffffff1660009081526001602052604090205490565b600073ffffffffffffffffffffffffffffffffffffffff8316151561014757600080fd5b3360009081526001602052604090205482111561016357600080fd5b503360009081526001602081905260408083208054859003905573ffffffffffffffffffffffffffffffffffffffff85168352909120805483019055929150505600a165627a7a72305820a5d999f4459642872a29be93a490575d345e40fc91a7cccb2cf29c88bcdaf3be0029

是的,这是完全不可读的(至少对一个正常人来说)。

部署合约

接下来,进入Remix中的DEPLOY & RUN部分。在顶部,确保你使用的是Remix VM。这基本上是一个嵌入式的Javascript EVM+ 网络,是我们理想的以太坊游乐场。确保BasicToken被选中,并在Deploy输入框中输入数字10000。接下来,点击部署按钮。这将部署一个BasicToken合约的实例,初始发行量为10000个代币,由顶部当前选择的账户拥有,它将持有我们的代币发行总量。

在部署的合约部分,你应该看到已部署的合约,并有字段与它的三个功能交互:transfer、balanceOf 和 totalSupply。在这里,我们将能够与我们刚刚部署的合约的实例进行交互。

但在此之前,让我们看看 "部署 "合约到底意味着什么。在控制台区域,你应该看到日志 "create of BasicToken pending...",然后是一个带有各种字段的交易条目:from、to、value、data、logs和 hash。点击这个条目来扩展交易的信息。即使是缩写,你也应该看到该交易的 input 数据是我们上面介绍的相同的字节码(加上参数)。这个交易被发送到0x0地址,结果是,一个新的合约实例被创建,有它自己的地址和代码。我们将在下一篇文章中详细研究这个过程.

拆解字节码

在交易数据的右边,仍然在控制台中,点击Debug(调试)按钮。这将激活Remix右侧区域的调试器标签。让我们来看看指令部分。如果你向下滚动,你应该看到以下内容。

000 PUSH1 80
002 PUSH1 40
004 MSTORE
005 CALLVALUE
006 DUP1
007 ISZERO
008 PUSH2 0010
011 JUMPI
012 PUSH1 00
014 DUP1
015 REVERT
016 JUMPDEST
017 POP
018 PUSH1 40
020 MLOAD
021 PUSH1 20
023 DUP1
024 PUSH2 0217
027 DUP4
028 CODECOPY
029 DUP2
030 ADD
031 PUSH1 40
033 SWAP1
034 DUP2
035 MSTORE
036 SWAP1
037 MLOAD
038 PUSH1 00
040 DUP2
041 DUP2
042 SSTORE
043 CALLER
044 DUP2
045 MSTORE
046 PUSH1 01
048 PUSH1 20
050 MSTORE
051 SWAP2
052 SWAP1
053 SWAP2
054 SHA3
055 SSTORE
056 PUSH2 01d1
059 DUP1
060 PUSH2 0046
063 PUSH1 00
065 CODECOPY
066 PUSH1 00
068 RETURN
069 STOP
070 PUSH1 80
072 PUSH1 40
074 MSTORE
075 PUSH1 04
077 CALLDATASIZE
078 LT
079 PUSH2 0056
082 JUMPI
083 PUSH4 ffffffff
088 PUSH29 0100000000000000000000000000000000000000000000000000000000
118 PUSH1 00
120 CALLDATALOAD
121 DIV
122 AND
123 PUSH4 18160ddd
128 DUP2
129 EQ
130 PUSH2 005b
133 JUMPI
134 DUP1
135 PUSH4 70a08231
140 EQ
141 PUSH2 0082
144 JUMPI
145 DUP1
146 PUSH4 a9059cbb
151 EQ
152 PUSH2 00b0
155 JUMPI
156 JUMPDEST
157 PUSH1 00
159 DUP1
160 REVERT
161 JUMPDEST
162 CALLVALUE
163 DUP1
164 ISZERO
165 PUSH2 0067
168 JUMPI
169 PUSH1 00
171 DUP1
172 REVERT
173 JUMPDEST
174 POP
175 PUSH2 0070
178 PUSH2 00f5
181 JUMP
182 JUMPDEST
183 PUSH1 40
185 DUP1
186 MLOAD
187 SWAP2
188 DUP3
189 MSTORE
190 MLOAD
191 SWAP1
192 DUP2
193 SWAP1
194 SUB
195 PUSH1 20
197 ADD
198 SWAP1
199 RETURN
200 JUMPDEST
201 CALLVALUE
202 DUP1
203 ISZERO
204 PUSH2 008e
207 JUMPI
208 PUSH1 00
210 DUP1
211 REVERT
212 JUMPDEST
213 POP
214 PUSH2 0070
217 PUSH20 ffffffffffffffffffffffffffffffffffffffff
238 PUSH1 04
240 CALLDATALOAD
241 AND
242 PUSH2 00fb
245 JUMP
246 JUMPDEST
247 CALLVALUE
248 DUP1
249 ISZERO
250 PUSH2 00bc
253 JUMPI
254 PUSH1 00
256 DUP1
257 REVERT
258 JUMPDEST
259 POP
260 PUSH2 00e1
263 PUSH20 ffffffffffffffffffffffffffffffffffffffff
284 PUSH1 04
286 CALLDATALOAD
287 AND
288 PUSH1 24
290 CALLDATALOAD
291 PUSH2 0123
294 JUMP
295 JUMPDEST
296 PUSH1 40
298 DUP1
299 MLOAD
300 SWAP2
301 ISZERO
302 ISZERO
303 DUP3
304 MSTORE
305 MLOAD
306 SWAP1
307 DUP2
308 SWAP1
309 SUB
310 PUSH1 20
312 ADD
313 SWAP1
314 RETURN
315 JUMPDEST
316 PUSH1 00
318 SLOAD
319 SWAP1
320 JUMP
321 JUMPDEST
322 PUSH20 ffffffffffffffffffffffffffffffffffffffff
343 AND
344 PUSH1 00
346 SWAP1
347 DUP2
348 MSTORE
349 PUSH1 01
351 PUSH1 20
353 MSTORE
354 PUSH1 40
356 SWAP1
357 SHA3
358 SLOAD
359 SWAP1
360 JUMP
361 JUMPDEST
362 PUSH1 00
364 PUSH20 ffffffffffffffffffffffffffffffffffffffff
385 DUP4
386 AND
387 ISZERO
388 ISZERO
389 PUSH2 0147
392 JUMPI
393 PUSH1 00
395 DUP1
396 REVERT
397 JUMPDEST
398 CALLER
399 PUSH1 00
401 SWAP1
402 DUP2
403 MSTORE
404 PUSH1 01
406 PUSH1 20
408 MSTORE
409 PUSH1 40
411 SWAP1
412 SHA3
413 SLOAD
414 DUP3
415 GT
416 ISZERO
417 PUSH2 0163
420 JUMPI
421 PUSH1 00
423 DUP1
424 REVERT
425 JUMPDEST
426 POP
427 CALLER
428 PUSH1 00
430 SWAP1
431 DUP2
432 MSTORE
433 PUSH1 01
435 PUSH1 20
437 DUP2
438 SWAP1
439 MSTORE
440 PUSH1 40
442 DUP1
443 DUP4
444 SHA3
445 DUP1
446 SLOAD
447 DUP6
448 SWAP1
449 SUB
450 SWAP1
451 SSTORE
452 PUSH20 ffffffffffffffffffffffffffffffffffffffff
473 DUP6
474 AND
475 DUP4
476 MSTORE
477 SWAP1
478 SWAP2
479 SHA3
480 DUP1
481 SLOAD
482 DUP4
483 ADD
484 SWAP1
485 SSTORE
486 SWAP3
487 SWAP2
488 POP
489 POP
490 JUMP
491 STOP
492 LOG1
493 PUSH6 627a7a723058
500 SHA3
501 INVALID
502 INVALID
503 SWAP10
504 DELEGATECALL
505 GASLIMIT
506 SWAP7
507 TIMESTAMP
508 DUP8
509 INVALID
510 INVALID
511 INVALID
512 SWAP4
513 LOG4
514 SWAP1
515 JUMPI
516 INVALID
517 CALLVALUE
518 INVALID
519 BLOCKHASH
520 INVALID
521 SWAP2
522 INVALID
523 INVALID
524 INVALID
525 INVALID
526 CALLCODE
527 SWAP13
528 DUP9
529 INVALID
530 INVALID
531 RETURN
532 INVALID
533 STOP
534 INVALID
535 STOP
536 STOP
537 STOP
538 STOP
539 STOP
540 STOP
541 STOP
542 STOP
543 STOP
544 STOP
545 STOP
546 STOP
547 STOP
548 STOP
549 STOP
550 STOP
551 STOP
552 STOP
553 STOP
554 STOP
555 STOP
556 STOP
557 STOP
558 STOP
559 STOP
560 STOP
561 STOP
562 STOP
563 STOP
564 STOP
565 INVALID
566 LT

为了确保你所遵循的是本系列中描述的同一套操作代码,请将你在Remix中看到的内容与本gist中的字节码进行比较。

这是合约的反汇编字节码。反汇编听起来挺吓人的,但其实很简单。如果你按字节扫描原始字节码(一次两个字符),EVM会识别出特定的操作码,并将其与特定的动作联系起来。比如说。

0x60 => PUSH
0x01 => ADD
0x02 => MUL
0x00 => STOP
...

拆解后的代码仍然非常低级,难以阅读,但正如你将看到的,我们可以开始对它进行理解。

操作码

在我们开始彻底解构字节码的雄心壮志之前,你将需要一套基本的工具来理解各个操作码,如PUSH、ADD、SWAP、DUP等。一个操作码,能进行的操作是从EVM的堆栈、内存或属于合约的存储中推入或消耗项目。

要查看EVM可以处理的所有可用的操作码,请查看EVM Codes。要了解每个操作码的作用和工作原理,Solidity的汇编文档是一个很好的参考。尽管它与原始操作码不是一对一的关系,但它非常接近(它实际上是Yul,Solidity和EVM字节码之间的一种中间语言)。最后,如果你讲究科学,总是有以太坊 Yellow Paper可以依靠。

现在从头到尾阅读这些资料是没有意义的;只是把它们放在身边作为参考。我们将在前进的过程中使用它们。

指令

上面拆解的代码中的每一行都是EVM要执行的指令。每条指令都包含一个操作码。例如,让我们来看看其中的一条指令,88号指令,它将数字4推到堆栈中。这个特殊的反汇编程序对指令的解释如下:

88 PUSH1 0x04
|  |     |     
|  |     要推入的值(16 进制)
|  指令
Instruction number.

尽管反汇编后的代码使我们更接近于理解正在发生的事情,但它仍然相当令人生畏。我们需要一个策略来解构整个代码,它有596条指令!我们需要一个策略来解构整个代码。

策略

那些一开始就显得不堪一击的问题通常会屈服于全能的 "分而治之 "策略,这个问题也不例外。我们将在拆解后的代码中找出分割点,然后一点一点地减少,直到最后得到可消化的小块代码,我们将在Remix的调试器中一步一步地完成。在下图中,我们可以看到我们可以对反汇编的代码进行第一次分割,我们将在下一篇文章中对其进行全面分析。

你可以在解构图中找到整个解构的最终结果。如果你一开始不理解这张图,不要担心。你不应该这样做。这个系列会一步一步地看完它。把它放在身边,这样你就可以在我们进行的过程中保持对大局的跟踪。

Creation vs. Runtime

让我们开始用分而治之的光剑来攻击我们合约。正如我们在前面介绍所看到的,这些反汇编的代码是非常低级的,但与原始字节码相比是可读性好了很多。请确保你已经跟随前面的内容,并且你已经在Remix中编译和部署了BasicToken合约。调试创建交易,打开指令面板。另外,在我们进行的过程中,请准备好解构图

现在,让我们专注于 "JUMP"、"JUMPI"、"JUMPDEST"、"RETURN "和 "STOP" 操作码,而忽略所有其他操作码。每当我们发现一个操作码不属于这些操作码时,我们将忽略它,并跳到下一条指令,假装没有任何干预。

当EVM执行代码时,它是自上而下执行的,没有任何例外--也就是说,代码没有其他入口点。它总是从顶部开始。它可以跳来跳去,是的,这正是JUMP和JUMPI的作用。JUMP从堆栈中获取最顶端的值,并将执行转移到该位置。但目标位置必须包含JUMPDEST操作码,否则执行会失败。这就是JUMPDEST的唯一目的:将一个位置标记为有效的跳转目标。JUMPI是完全相同的,但是在堆栈的第二个位置不能是 0,否则就不会有跳转。所以这是一个有条件的跳转。STOP完全停止了合约的执行,RETURN也停止了执行,但从EVM的一部分内存中返回数据,这很方便。

所以,让我们开始解释代码,记住所有这些。在Remix的调试器中,将交易滑块向左移动,查看指令部分。你可以用Step Into按钮(看起来像一个向下的小箭头的那个)来浏览这些指令。第一条指令可以忽略不计,但在第11条指令中我们发现了第一个 JUMPI。如果它不跳转,它将继续执行第12至15条指令,并以 REVERT 结束,这将停止执行。但是如果它跳转了,它将跳过这些指令到达16号位置(十六进制0x0010,在指令8中被推入堆栈)。指令16是一个JUMPDEST。到目前为止还不错。

继续浏览操作码,直到交易滑块一直向右移动。刚才发生了很多废话,但只有在第68个位置我们发现了一个 "RETURN" 操作码(还有一个 "STOP "操作码在指令69中,以备不时之需)。这是很奇怪的。如果你仔细想想,这个合约的控制流总是在指令15或68结束。我们刚刚浏览了一遍,确定没有其他可能的流程,那么剩下的指令是干什么用的呢?(如果你在指令面板下滑,你会看到代码在566位置结束)。

我们刚刚穿越的这组指令(0到69)就是所谓的合约的 "创建代码(creation code)"。它永远不会成为合约代码本身的一部分,而只是在创建合约的交易中由EVM执行一次。我们很快就会发现,这段代码负责设置创建的合约的初始状态,以及返回其运行时代码的副本。剩下的497条指令(70到566),正如我们所看到的,永远不会被执行流到达,正是将成为部署的合约的一部分的代码。

如果你打开解构图,你应该看到我们刚刚是如何进行第一次拆分:我们已经区分了创建时与运行时代码。

现在我们将深入研究代码的创建部分:

对BasicToken.sol的创建时EVM字节码的解构。

这是本文中需要理解的最重要的概念。创建代码在一个交易中被执行,它返回一个运行时代码的副本,也就是合约的实际代码。正如我们将看到的,构造函数是创建代码的一部分,而不是运行时代码的一部分。合约的构造函数是创建代码的一部分;一旦合约被部署,它就不会出现在合约的代码中。

这个魔术是如何发生的呢?这就是我们现在要一步步分析的:

好了。所以,现在我们的问题被简化为理解与创建时间代码相对应的这~70条指令。

让我们重新采取自上而下的方法,这次我们要边走边理解所有的指令,不要跳过任何一条。首先,让我们把重点放在指令0到2上,这些指令使用了PUSH1和MSTORE操作码:

空闲内存指针的EVM字节码结构

PUSH1简单地将一个字节推入堆栈顶部,MSTORE从堆栈中抓取最后两个项目并将其中一个存储到内存中:

mstore(0x40, 0x80)
   |     |
   |     What to store.
   Where to store.
(in memory)

注意:上面的代码片段是Yul-风格代码。注意它是如何从堆栈中从左到右消耗元素的,总是先消耗堆栈顶部的东西。

这基本上是将数字0x80(十进制128)存储到内存的位置0x40(十进制64)。为了什么? 肯定是有原因的(实际上我们以后会看到)。现在,在Remix的调试器标签中打开_Stack和Memory面板,以便在你来回踩踏这些指令时直观地看到这些值。

你可能想知道:指令1和3是什么?PUSH指令是唯一的EVM指令,实际上是由两个或多个字节组成。所以,PUSH 80实际上是两条指令。那么谜底就揭晓了:指令1是0x80,指令3是0x40。

接下来是指令5到15:

非payable的 EVM字节码结构。

这里我们有一堆新的操作码:CALLVALUE,DUP1,ISZERO,PUSH2,和REVERT。CALLVALUE推送了创建交易中涉及的Wei的数量,DUP1复制了堆栈中的第一个元素,ISZERO在堆栈的最上面的值为0时推送了一个1,PUSH2就像PUSH1,但它可以推送两个字节到堆栈,而不是只有一个,REVERT则停止执行。

那么这里发生了什么?在Solidity中,我们可以这样写这块汇编:

if(msg.value != 0) revert();

这段代码实际上不是我们原始 Solidity 源的一部分,而是由编译器注入的,因为我们没有声明构造函数是payable的。在Solidity的最新版本中,没有明确声明自己是可支付的函数不能接收以太币。回到汇编代码,第11条指令的JUMPI将跳过第12至15条指令,如果不涉及以太币,则跳到第16条。否则,REVERT将在两个参数都为0的情况下执行(意味着将不会返回任何有用的数据)。

好了! 咖啡时间。接下来的部分会有点棘手,所以休息几分钟可能是个好主意。来吧,给自己准备一杯好咖啡,同时唤起你的注意力。确保你理解我们到目前为止所看到的,因为接下来的部分有点复杂。

如果你想用另一种方式来可视化我们刚刚做的事情,可以试试我建立的这个简单的工具: solmap。它允许你实时编译Solidity代码,然后点击EVM操作码来突出相关的Solidity代码。反汇编与Remix的有点不同,但通过比较,你应该能理解它。
准备好继续前进了吗?很好! 接下来是指令16到37。继续用Remix的调试器进行跟踪。(记住,Remix是你的朋友^^)


EVM的字节码结构,用于从合约的字节码末尾附加的代码中检索构造函数参数。

前四条指令(17到20)读取内存中0x40位置的任何东西,并将其推到堆栈。如果你还记得刚才的情况,那应该是数字0x80。接下来的指令将0x20(十进制32)推入堆栈(指令21),复制该值(指令23),推入0x0217(十进制535)(指令24),最后复制第四个值(指令27),这应该是0x80。吁! 我在写这句话的时候差点没喘过气来。在看这样的EVM指令时,暂时不明白发生了什么也没关系。别担心,它会突然在你的脑海中闪现。

在第28条指令中,CODECOPY被执行,它需要三个参数:要复制代码的目标内存位置,要复制的指令号,以及要复制的代码字节数。因此,在此案例中,它的目标内存位置是0x80,从代码中的字节位置535开始,代码长度为32字节。为什么?

如果你看一下整个反汇编的代码,有566条指令。那么,为什么这段代码试图复制最后32个字节的代码?实际上,在部署一个构造函数包含参数的合约时,参数会作为原始十六进制数据附加到代码的末尾。向下滚动指令面板可以看到这一点。在此案例中,构造函数需要一个uint256参数,所以这段代码所做的就是把参数从附加在代码末尾的值复制到内存。这32条指令在分解后的代码中没有意义,但在原始十六进制中却有意义:0x000000000000000000000...00000000000000000002710。当然,这就是我们在部署合约时传递给构造函数的十进制值10000!

明白为什么你需要那杯咖啡了吗?再一次,请在Remix中一步一步地重复这一部分,确保你明白刚才发生了什么。最终的结果应该是,你看到数字0x00...002710存储在内存的0x80位置。

好的。对于下一部分,你可能想给自己准备两杯威士忌。这将变得很奇怪。

只是开玩笑。没有更多的魔法,我保证。从这里开始都是轻松的下坡路。

下一组指令(29到35)将存储在内存0x40位置的数值从0x80更新到0xa0:也就是说,它们将数值偏移了0x20(32)字节。现在我们可以开始理解指令0到2 Solidity记录了一个叫做 空闲内存指针 的东西:也就是说,在内存中我们可以用来存储东西,并保证没有人会覆盖它(当然,如果我们犯了一个错误,除了我们使用内联汇编或Yul)。因此,由于我们在旧的空闲内存位置存储了数字10000,我们通过向前移动32字节来更新空闲内存指针。

即使是有经验的Solidity开发者,当他们看到 空闲内存指针 的表述或代码mload(0x40, 0x80)时也会感到困惑。这些只是在说,我们将从这一点开始向内存写入,每次我们写入一个新的条目,并保留一个偏移量的记录。 Solidity中的每一个函数,当编译为EVM字节码时,将初始化这个指针。

在0x00到0x40之间的内存中有什么,你可能会问。没有什么。它只是Solidity为计算哈希值而保留的一大块内存,正如我们即将看到的,哈希值对于映射和其他类型的动态数据是必要的。

现在,在第37条指令中,MLOAD从内存的0x40位置读取,基本上是将我们的10000值从内存中下载到堆栈中,在那里它将是新鲜的,准备在下一组指令中使用。

这是由Solidity生成的EVM字节码的一个常见模式:在执行一个函数体之前,函数的参数被加载到堆栈中(只要有可能),这样接下来的代码就可以消耗它们--而这正是接下来要发生的。

让我们继续学习指令38至55:

这些指令不外乎是构造函数的主体:也就是 Solidity 代码:

totalSupply_ = _initialSupply;
balances[msg.sender] =  _initialSupply;

前四条指令是非常不言自明的(38到42)。首先,0被推到堆栈中,然后堆栈中的第二项被复制(这就是我们的10000号),然后数字0被复制并推到堆栈中,这就是totalSupply_的存储的位置槽。现在,SSTORE可以消耗这些值,并且仍然保留10000,以供将来使用。

sstore(0x00, 0x2710) 
   |     |
   |     What to store.
   Where to store.
(in storage)

Voila! 我们在变量 "totalSupply_"中存储了数字10000。这不是很令人吃惊吗?

请确保在Remix的调试器标签中也能看到这个值。你会在 加载完成的状态 面板中找到它。

下一组指令(43至54)有点令人不安,但基本上将处理在msg.sender的键的balances映射中存储10000。在继续前进之前,请确保你理解Solidity文档的这一部分,它解释了映射是如何保存在存储器中的。长话短说,它将把映射值的槽(在本例中是数字1,因为它是合约中声明的第二个变量)和使用的键(在本例中是msg.sender,用操作码CALLER获得)连接起来,然后用SHA3操作码哈希,把它作为存储的目标位置。最后,存储只是一个简单的字典或哈希表。

继续执行指令43到45,msg.sender地址被存储在内存中(这次是在0x00位置),然后在指令46到50,值1(映射的槽)被存储在内存0x20位置。最后,SHA3 操作码计算内存中从0x00位置到0x40位置的任何东西的Keccak256哈希值--即映射的槽/位置与所使用的key的连接。这正是10000值在我们的映射中的存储位置。

sstore(hash..., 0x2710)
   |        |
   |        What to store.
   Where to store.

就这样了。在这一点上,构造函数的主体已经被完全执行。

所有这些一开始可能有点令人不知所措,但这是 Solidity 中存储工作的一个基本部分。如果你不太明白,我建议你跟着Remix的调试器多看几遍,把Stack和Memory面板放在眼前。这种模式在Solidity生成的EVM字节码中随处可见,你将很快学会毫不费力地识别它。归根结底,它只不过是在存储中计算某个映射的某个键的值应该保存在哪里。

好了,我们在这里差不多完成了。如果你走到这一步,接下来的部分将是小菜一碟。

运行时的代码复制

在指令56到65中,我们再次进行代码复制。只是这一次,我们不是把代码的最后32个字节复制到内存中;我们要把0x01d1(十进制465)字节从0x0046(十进制70)位置开始复制到内存的0位置。

如果你再把交易滑块一直滑到右边,你会发现位置70就在我们的创建时EVM代码之后,执行停止了。运行时的字节码就包含在这465个字节中。这是代码的一部分,将被保存在区块链中作为合约的运行时代码,这将是每次与合约交互时执行的代码。(我们将在本系列的未来部分介绍运行时代码)。

而这正是指令66至69的作用:我们复制到内存中的代码被返回:

运行时代码返回EVM字节码结构。

RETURN抓取复制到内存中的代码并将其交给EVM。如果这个创建代码在交易的上下文中被执行到0x0地址,EVM将执行该代码并将返回值存储为创建的合约的运行时代码。

这就是了! 现在,我们的BasicToken合约实例将被创建和部署,其初始状态和运行时代码已准备好使用。如果你退一步看一下空闲指针图:

你会发现我们分析的所有EVM字节码结构都是通用的,除了紫色高亮显示的那个:也就是说,它们将在Solidity编译器生成的创建时字节码中。各个构造函数的不同之处仅仅在于紫色部分--构造函数的实际主体。嵌入在字节码末尾的获取参数的结构,以及复制运行时代码并返回的结构,可以被认为是模板代码和通用的EVM操作码结构。你现在应该可以看任何一个构造函数了,在逐条研究它的指令之前,你应该对构成它的组件有一个大致的概念。

在下一篇文章中,我们将研究实际的运行时代码,首先是你如何在不同的入口点与合约的EVM代码进行交互。现在,给自己一个当之无愧的掌声,因为你刚刚消化了本系列中最困难的部分。你也应该培养了很强的能力来阅读和调试EVM字节码,理解泛型结构,最重要的是,理解创建时和运行时EVM字节码之间的区别。这就是Solidity中合约的构造函数的特殊之处。

转载:https://learnblockchain.cn/article/5190

Gas 技巧:Solidity 中利用位图大幅节省Gas费

有过合约开发经验的同学都可能知道的,以太坊中最昂贵的操作是存储数据(SSTORE)。所以大家也应该一直寻找方法来减少存储需求。让我们来探讨一个特别有用的方法:位图
注:在 Uniswap 的代码中,有很多使用位图来优化 gas 的技巧。

如何实现一个简单的位图

假设我们想存储10个布尔值。通常,我们会用一个简单的布尔数组来实现这一点,例如:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    bool[10] implementationWithBool;

    function setDataWithBoolArray(bool[10] memory data) external {
        implementationWithBool = data;
    }

    function readWithBoolArray(uint256 index) external returns (bool) {
        return implementationWithBool[index];
    }
}

而使用 Bitmap 位图,可以用一个 uint10代替bool数组来实现。uint10将在存储中用10 位(bits 比特位)表示。
例如,这里有一些用比特(bit)表示的十进制数字:

  • 0: 0000000000
  • 1: 0000000001
  • 512: 0100000000
  • 729: 1011011001
  • 1023: 1111111111

我们可以用一些额外的数学方法来利用这种位表示法。为了得到这个整数的第n位,我们可以使用位运算

让我们来看看729这个数字,在常规方式下,用一个bool数组来读取第4个bool值,它只是一个array[4]。对于位图,我们可以通过使用左移运算符<<将1向左移,来代替创建第二个数字。

1 << 4 = 0000000001 << 4 = 0000010000

现在使用位和运算符&,我们可以得到第n位的值(从0开始计算)。

729 & (1 << 4) = 1011011001 & 0000010000

其结果是

  • 1011011001 &
  • 0000010000 =
  • 0000010000

只要这个结果 大于0 ,原数的第n位就是1,所以现在我们可以实现位图:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    uint256 implementationWithBitmap;

    function setDataWithBitmap(uint256 data) external {
        implementationWithBitmap = data;
    }

    function readWithBitmap(uint256 indexFromRight) external returns (bool) {
        uint256 bitAtIndex = implementationWithBitmap & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

选择位图大小

你可能已经注意到,我们在上面的实现中选择了uint256。虽然uint10在技术上是足够的,但这实际上会导致比使用uint256更高的Gas成本。这是因为EVM在32个字节的寄存器(256位)上操作,任何低于这个数字的都需要额外的转换。

所以你应该总是选择 uint256 吗?

也不是,这取决于你的使用情况。用一个uint256,你可以表示256位。那么你想存储的数据是否适合一个256位的布尔数组?如果是,那么就继续使用单个uint256。

如果不能,例如布尔数组可以任意增长,那么就把位图本身打包成一个数组。我将在最后用一个例子来探讨这两种选择。

比较Gas成本

让我们先来看看10位例子中的Gas成本差异。用原来的布尔数组,交易的执行成本是:

  • setDataWithBoolArray: 140,583 gas
  • ReadWithBoolArray: 1,281 gas

现在有了位图,我们可以大大改善这个情况:

  • setDataWithBitmap: 78,043 gas
  • readWithBitmap: 1,129 gas

使用场景1:设置布尔开关

现在来看看第一个使用场景: 布尔开关通常被用来激活系统中的某些选项。
比方说,你建立了一个像Uniswap一样的DEX,你可以自动触发的交易。你可以根据交易的来源来激活某些设置。例如,你可能有如下开关

  • NO_FEES (无交易费)
  • ...
  • SENDING_FEES_TO_GOVERNANCE (发送费用到治理)
  • DELAY_TRADE_EXECUTION (延迟交易执行)

这些选项可能不会超过256个,所以你可以很容易地将这些选项存储在一个uint256中。

使用场景2:参与者的名单

你可能想向任何参与过你的合约的人支付奖励。这可能是一个任意的大列表。你可以在一个映射中保存每个参与者,或者用一个uint256数组来代替位图。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract ParticipatedWithBitmap {
    uint256[] public participantsBitmap;

    function setParticipants(uint256[] memory participantsBitmap_) external onlyOwner {
        participantsBitmap = participantsBitmap_;
    }

    function hasParticipated(uint256 bitmapIndex, uint256 indexFromRight) external view returns (bool) {
        uint256 bitAtIndex = participantsBitmap[bitmapIndex] & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

欢迎订阅专栏 学习更多 Solidity 高阶优化技巧。

转载:https://learnblockchain.cn/article/5287