解构 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
--将其发送给函数体,然后获取函数体的响应,再打包给用户。很有意义,对吗?
在本系列的下一部分中,我们将最终研究函数体。一旦我们做到了这一点,就没有什么其他的事情可做了......只有一些细节需要涵盖,我们就完成了。这种分而治之的策略真的开始让我们在本系列文章开始时要解决的问题,起初似乎不堪一击,但现在开始成为我们可以熟悉的模式。下次你看到一个操作码时,你将不会感到害怕。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!