在智能合约世界中,以太坊虚拟机及其算法和数据结构就是第一性原理。Solidity 和我们创建的智能合约就是建立在这个基础之上的组件。要成为一名出色的 Solidity 开发人员,必须要对 EVM 有深入的了解。
作者:noxx
译者:Kurt Pan
第一性原理思维是我们经常听到的一个术语。它侧重于深入理解一个主题的基本概念,以便更好地思考构建在上面的组件的设计空间。
在智能合约世界中,以太坊虚拟机 及其算法和数据结构就是第一性原理。Solidity 和我们创建的智能合约就是建立在这个基础之上的组件。要成为一名出色的 Solidity 开发人员,必须要对 EVM 有深入的了解。
在开始之前,本文假设你对 Solidity 以及如何将其部署到以太坊区块链上有一些基本的知识,我们将简要介绍这些主题。也请参阅这篇文章。
如你所知,在部署到以太坊网络之前,你的 Solidity 代码需要编译成字节码 (bytecode)。此字节码对应于由EVM 解释的一系列操作码 (opcode)指令。
本系列不同部分将重点介绍已编译字节码的特定部分并阐明它们的工作原理。在每部分的最后,你应该可以对每个组件的功能有了更清晰的了解。在此过程中,你将学习许多与 EVM 相关的基础概念。
第一部分中我们将看一个基本的solidity合约及其字节码/操作码的代码片段,以演示EVM如何选择函数。
从solidity合约创建的运行时字节码是整个合约的表示。在合约中,你可能有多个函数,一旦部署就可以调用它们。一个常见的问题是,EVM 如何根据调用合约中的哪个函数知道要执行哪个字节码。这也是我们将用来帮助理解 EVM 的底层机制,以及如何处理这种具体情况所问的第一个问题。
对于我们的演示,我们将使用 1_Storage.sol
合约,它是在线solidity IDE Remix 中的默认合约之一。
合约有 2 个函数 store(uint256)
和 retrieve()
,EVM 必须在一个函数调用到来时决定好使用哪个函数。下面是整个合约的编译后的运行时字节码。
608060405234801561001057600080fd5b506004361061003657**60003560e01c80632e64cec11461003b5780636057361d1461005957**5b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033
我们将重点关注下面的字节码片段。此片段表示函数选择器逻辑。(在上述字节码中用*框出)
60003560e01c80632e64cec11461003b5780636057361d1461005957
此字节码对应于一组 EVM 操作码及其输入值。
你可以在此处查看 EVM 操作码列表: https://www.ethervm.io/
操作码长度为 1 个字节,于是有256 种可能的不同操作码。EVM 仅使用其中的 140 个操作码。
下面展示了被分解成相应操作码命令的字节码片段。这些由 EVM 在调用栈上按顺序运行。你可以去上面的链接验证一下操作码编号60 = PUSH1
等等。到本文结尾时,你将完全了解这些操作码的作用。
60 00 = PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI
在深入研究操作码之前,我们需要快速了解一下如何调用合约函数。
当我们调用合约函数时,我们会包含一些calldata ,这些calldata指定我们正在调用的函数签名以及需要传入的任何参数。在solidity中这可以通过以下方式完成。
在这里,我们使用参数 10
对 store
函数进行合约调用。我们使用 abi.encodeWithSignature()
来获取所需格式的calldata。emit
记录我们的calldata以进行测试。
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
以上是 abi.encodeWithSignature("store(uint256)", 10)
返回的内容。
前面我提到了函数签名 ,现在让我们仔细看看它们是什么。
函数签名被定义为,规范表示后的函数签名的 Keccak 哈希 的前四个字节。
函数签名的规范表示即是函数名以及函数参数类型,例如store(uint256)
和 retrieve()
。可以自己尝试一下去哈希 store(uint256)
来验证该结果。
keccak256(“store(uint256)”) → 前四个字节 = 6057361d
keccak256(“retrieve()”) → 前四个字节 = 2e64cec1
查看上面的 calldata可以看到,我们有 36 个字节的 calldata,前 4 个字节对应于我们刚刚为 store(uint256)
函数计算的函数选择器。
剩余的 32 个字节对应于我们的 uint256
输入参数。我们有一个十六进制值a
,它等于十进制的 10
。
6057361d = 函数签名 (4 字节)
000000000000000000000000000000000000000000000000000000000000000a = uint256 输入 (32 字节)
你也可以在上面的操作码部分里找到该函数签名6057361d
。
现在,我们拥有了开始深入研究“在函数选择期间在 EVM 级别发生的事情”所需的一切。
我们将遍历每个操作码命令,看看它们的作用以及它们如何影响调用栈。
如果你不熟悉栈数据结构的工作原理,请观看此视频作为快速入门。
我们从 PUSH1
开始,它告诉 EVM 将下一个字节的数据 0x00
(十进制的 0)推入到调用栈。为什么我们这样做将在下一个操作码中变得显而易见。
接下来,我们有 CALLDATALOAD
,它将栈中的第一个值 (0) 弹出作为输入。
此操作码使用“输入”作为偏移量将calldata加载到栈中。栈中一项大小为 32 字节,但我们的 calldata 为 36 字节。推入的值是 msg.data[i:i+32]
其中“i”是这个输入。这确保只有 32 个字节被推入到栈,但使我们能够访问calldata的任何部分。
在我们的这个情况下,没有偏移(从栈中弹出的值是前一个 PUSH1 的 0),因此我们将 calldata 的前 32 个字节推送到调用栈。
之前我们通过一个emit
来记录我们的calldata,它等于0x6057361d000000000000000000000000000000000000000000000000000000000000000a
。
这意味着后面的 4 个字节(0000000a
)丢失了。如果我们想访问 uint256
变量,我们将使用偏移量 4 来省略函数签名,以包含完整变量。
这次是另一个 PUSH1
,十六进制值为 0xe0
,十进制值为 224。记住函数签名是 4 字节长或 32 位。我们加载的calldata是 32 字节长或 256 位。
接下来,我们有 SHR
,右移一位。它从栈中取出第一项 (224) 作为要移位多少的输入,从栈中取出第二项 (0x6057361d0…0a
) 表示需要移位的内容。我们可以看到,在这个操作之后,我们在调用栈上有了 4 字节的函数选择器。
如果你不熟悉位移的工作原理,请查看此短视频。
接下来是 DUP1
,一个简单的操作码,它获取栈顶值并复制。
PUSH4
将retrieve() (0x2e64cec1)<span> </span>
的4 字节函数签名推入调用栈。
如果你想知道它是如何知道这个值的,请记住这是在从solidity 代码编译的字节码中。因此,编译器拥有所有函数名称和参数类型的信息。
EQ
从栈中弹出 2 个值,在本例中为 0x2e64cec1
和 0x6057361d
并检查它们是否相等。如果是,则将 1 推回栈,如果不是推回 0。
PUSH2
将 2 个字节的数据推送到十六进制的调用栈 0x003b
中,等于十进制的 59。
调用栈有一个叫做程序计数器 的东西,它指定下一个执行命令在字节码中的位置。这里我们设置了 59,因为这是retrieve()
字节码的开始位置。(请注意,下面的 EVM Playground 部分将有助于明确其工作原理)
你可以通过与 Solidity 代码中的行号位置类似的方式查看程序计数器位置。如果函数是在第 59 行定义的,你可以使用行号来告诉机器在哪里可以找到该函数的代码。
JUMPI
代表“跳,如果”。它从栈中弹出 2 个值作为输入,第一个 (59) 是跳转位置,第二个 (0) 是是否应该执行此跳转的布尔值。其中 1 = 真,0 = 假。
如果为真,程序计数器将被更新,执行将跳转到该位置。在我们的例子中它是假的,程序计数器没有改变并且执行正常继续。
再次 DUP1
。
PUSH4
将 store(uint256) (0x6057361d)
的 4 字节函数签名推送到调用栈上。
再次进行 EQ
,但这次结果为真,因为函数签名匹配。
PUSH2
,推送store(uint256)
字节码的程序计数器位置,十六进制的0x0059
等于十进制的89。
JUMPI
,这次 bool 检查为真,表示跳转执行。这会将程序计数器更新为 89,这会将执行移动到字节码的不同部分。
在这个位置,会有一个 JUMPDEST
操作码,如果没有这个操作码在目的地,JUMPI
就会失败。
在执行此操作码后,你将被带到 store(uint156)<span> </span>
字节码的位置,并且函数的执行将照常继续。
虽然该合约只有 2 个函数,但相同的原则适用于具有 20 多个函数的合约。
你现在知道 EVM 如何根据合约函数调用确定它需要执行的函数字节码的位置。它实际上只是合约中每个函数带有跳转位置的一组简单的“if 语句”。
我强烈建议你点击阅读原文访问一个链接,这是一个 EVM 游乐场,我在其中设置了我们刚刚运行的那些字节码。你将能够以交互方式查看栈的变化,并且我已经包含了 JUMPDEST
,因此你可以看到最终 JUMPI
之后会发生什么。
EVM 游乐场还将帮助你理解程序计数器,在代码中,你将在每个命令旁边看到注释,其偏移量代表其程序计数器位置。
你还将在 Run 按钮的左侧看到 calldata 输入,尝试将其更改为 retrieve()
calldata 0x2e64cec1
以查看执行的变化。只需单击“Run”,然后单击右上角的“step into”(卷曲箭头)按钮,即可逐步每个操作码。
接下来,在本系列中,我们将在第二部分中探索EVM“内存”通道。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!