来自 Openzeppelin 的经典文章。
这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的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. 函数选择器和合约的运行时代码主入口点。
下一篇,我们继续解构 函数包装器。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!