本篇重点介绍编译后的字节码以及其如何被 EVM 执行的。
By: Flush@慢雾安全团队
在智能合约世界中,“以太坊虚拟机(EVM)”及其算法和数据结构是首要原则。我们创建的智能合约就是建立在这个基础之上的。不管是想要成为一名出色的 Solidity 智能合约开发人员还是安全人员都必须对 EVM 有深入的了解。
此系列我们将引介翻译 noxx 的文章,深入探讨 EVM 的基础知识。
中文翻译系列:EVM 深入探讨
在阅读本篇文章之前,你需要了解一些智能合约相关基础知识以及如何将智能合约代码部署到以太坊链上。正如我们所知,智能合约在部署到以太坊网络之前需要先将 Solidity 代码编译成字节码,EVM 会根据编译后的字节码执行相应的操作。本篇重点介绍编译后的字节码以及其如何被 EVM 执行的。
智能合约被部署后编译生成的字节码代表了整个合约的内容,其中存在多个可调用的函数。那么 EVM 是如何知道不同函数所对应的字节码是哪个呢?下面我们将通过一个 Solidity 智能合约及其字节码和操作码来向大家演示 EVM 在执行代码时是如何在字节码中选择对应的函数的。
我们使用在线 Solidity IDE 工具 Remix 来编译 Storage 合约。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/**
* @title Storage
* @dev Store & retrieve value in a variable
*/
contract Storage {
uint256 number;
/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}
/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}
此合约中存在两个函数 store() 和 retrieve(),在进行函数调用时 EVM 需要判断我们调用的是哪个函数。我们可以通过 remix 看到整个合约编译后的字节码。
下面这段字节码是我们需要重点关注的,这段就是 EVM 判断被调用函数的选择器。与其对应的是 EVM 操作码及输入值。
我们可以通过 Ethervm.io 来查看 EVM 操作码列表。一个操作码长度为 1 个字节(byte),这使得它可以存在 256 种不同的操作码。但 EVM 仅使用其中的 140 个操作码。
下面是我们将上述字节码解析成与其对应的操作码。这些操作码会由 EVM 在调用栈上按顺序执行。
在深入研究操作码之前,我们需要快速了解如何调用合约中的函数。调用智能合约中的函数有以下方式:
abi.encode(...) returns (bytes):计算参数的 ABI 编码。
abi.encodePacked(...) returns (bytes):计算参数的紧密打包编码。
abi. encodeWithSelector(bytes4 selector, ...) returns (bytes):计算函数选择器和参数的 ABI 编码。
abi.encodeWithSignature(string signature, ...) returns (bytes):等价于 abi.encodeWithSelector(bytes4(keccak256(signature), ...)。
abi.encodeCall(function functionPointer, (...)) returns (bytes memory):使用 tuple 类型参数 ABI 编码调用 functionPointer()。执行完整的类型检查,确保类型匹配函数签名。结果和 abi.encodeWithSelector(functionPointer.selector, (...)) 一致。
这里我们以第四种为例,调用 store() 并传入参数 10:
下面是通过 abi.encodeWithSignature (" store (uint256)",10) 编码后的内容:
这段数据就是编码后的函数签名。
我们可以使用在线工具 来查看 store(uint256) 和 retrieve() 哈希后的结果。
也可以通过以太坊函数签名数据库 进行反查。
再回到上面的那组函数签名数据,其中前 4 个字节对应的是 store(uint256)。而剩余的 32 个字节则对应的是一个十六进制的值 “a”,也就是我们调用函数时传入的 uint256 类型的 10。
6057361d = function signature (4 bytes)
000000000000000000000000000000000000000000000000000000000000000a = uint256 input (32 bytes)
这里我们可以得到一个结论,通过 abi.encodeWithSignature() 编码后得到的数据,共 36 个字节。这 36 个字节的数据就是函数签名,其中前 4 个字节为函数选择器,它将指引 EVM 去选择我们调用的目标函数,后 32 个字节的数据则是我们调用函数时传入的参数。
这里相信大家已经大致了解了智能合约中函数调用的原理了,下面我们将通过解读每个操作码的作用及其对栈调用的影响。如果你不熟悉栈数据结构的工作原理,可以观看此视频来快速入门:https://www.youtube.com/watch?v=FNZ5o9S9prU
我们将得到的字节码分解成相对应的操作码后依次开始分析。
当前输入值为 0 也就是没有偏移量(从栈中弹出的值是前一个 PUSH1 的值 0),因此 calldata 的前 32 个字节会被推送到调用栈。
还记得之前所获取到的函数签名吗?如果要传入这 36 个字节,这就意味着后面的 4 个字节“0000000a”将会丢失。如果想访问这个 uint256 类型的参数,需要设置 4 的偏移量来省略函数签名,这样就可以保证参数的完整性。
如果对于位移的工作原理不熟悉的小伙伴,可以查看这个视频了解:https://www.youtube.com/watch?v=fDKUq38H2jk&t=176s
如果你好奇是这个值是如何获得的,那是因为 solidity 代码被编译成字节码中。编译器可以从字节码中获取所有函数名称和参数类型的信息。
调用栈中有一个叫做程序计数器的东西,它会指定下一个执行命令在字节码中的位置。这里的 59,是通过 retrieve() 字节码的开始位置所得到的。
如果条件为真,程序计数器将被更新,执行将跳转到该位置。但我们的例子中条件为假的,程序计数器没有改变并且继续执行。
有了它,在执行此操作码后,将被带到 store(uint256) 对应的字节码的位置,并且函数的执行将继续。虽然这个合约只有 2 个函数,但基础原理都是相同的。
通过上面的例子我们知道了 EVM 是如何根据合约函数调用来确定它需要执行的函数字节码的位置。简单来说就是由合约中每个函数及其跳转位置所组成的一组简单的“if 语句”。
这是一个 EVM Playground 测试平台,在平台上我们可以设置刚刚运行的字节码。就能够通过交互方式来查看栈的变化,并且传入 JUMPDEST(注:可能跳转的目标元数据),可以看到 JUMPI 之后会发生什么。
EVM Playgrpund 还能有助于我们理解程序计数器的运行,每条命令旁都能看到相对应的注释以及偏移量所代表的程序计数器的位置,同时在左边框内还能看到 calldata 的输入。当点击运行指令,可以通过右上角的箭头单步调试每个操作码例如更改为 retrieve() 调用数据 0x2e64cec1 来查看执行的变化。
敬请期待《EVM 深入探讨-Part 2》,让我们共同探索合约内存是什么以及它在 EVM 下的工作方式。
本文首发于:https://mp.weixin.qq.com/s/KX_mVRb2uyO0hLsONCOcaA 原文:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy?utm_source=url&s=r
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!