解构 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. 函数包装器之后的函数体。
正如我们之前所讨论的,函数体都集中在函数封装器之后。执行流从包装器中跳到它们,并在执行完每个函数的指令后返回到包装器。
如果你仔细看这张图,在函数体之后有一大块代码,叫做 "元数据哈希"。这是一个非常简单的结构,在下一篇文章我们将解析一下这个部分。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!