前言:第一部分主要还是关于 solidity 函数调用时,EVM 底层发生的逻辑关系

Solidity-> 字节码 -> 操作码

智能合约在部署到以太坊以太坊网络之前需要先将 Solidity 代码翻译成字节码,EVM 会根据编译后的字节码执行相应的操作。

智能合约在被部署后编译生成的字节码代表了整个合约的内容,其中存在多个可调用的函数。下面,通过一个 Solidity 智能合约及其字节码和操作码来向大家演示 EVM 在执行代码时是如何在字节吗中选择对应的函数的。

Storage.sol Breakdown

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

/**
* @title Storage
* @dev Storage & retrieve value in a variable
*/
contract Storage{
	uint256 number;
	
	/**
	* @dev Storage 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 看到整个合约部署后的字节码

image-20231001182834952

下面这段字节码是我们需要重点关注的,这段就是 EVM 判断被调用函数的选择器。与其对应的是 EVM 操作码及输入值。

图片

通过 Ethervm.io 来查看 EVM 操作码列表,一个操作码长度为 1 个字节(byte),这使得它可以存在 256 种不同的操作码,但 EVM 仅使用其中的 140 个操作码。

将上述字节码解析成其对应的操作码,这些操作码会由 EVM 在调用栈上按顺序执行。

智能合约函数调用

在深入研究操作码之前,我们需要快速了解如何调用合约中的函数,调用智能合约中的函数有以下方式:

  • abi.encode(...) returns (bytes):计算参数的 ABI 编码
  • abi.encodePacked(...) returns (bytes):计算参数的紧密打包编码
  • abi.encodeWithSignature(string signature, ...) returns (bytes):等价于 abi.encodeWithSelector(bytes4(keccak256(signature),...))
  • abii.encodeCall(function functionPointer, (...)) returns (bytes memory):使用 tuple 类型参数 ABI 编码调用 functionPointer()。执行完整的类型检查,确保类型匹配函数签名。结果和abi.encodeWithSelector(functionPointer.selector,(...))一致

下面以第四种为例,调用store()并传入参数 10:

图片

下面是通过abi.encodeWithSignature("store(uint256)",10)编码后的内容:

图片

这段数据就是编码后的函数签名:

图片

通过在线工具(“https://emn178.github.io/online-tools/keccak_256.html”)来查看`store(uint256)`和`retrieve()`哈希后的结果。

图片

图片

也可以通过以太坊函数签名数据库(https://www.4byte.directory/signatures/ )进行反查。

图片

图片

再回到上面的那组函数签名数据,其中前 4 个字节对应的是 store(uint256)。而剩余的 32 个字节则对应的是一个十六进制的值”a”,也就是我们调用函数时传入的 uint256 类型的10

所以,我们能得到以下的结论:通过abi.encodeWithSignature()编码后得到的数据,共 36 个字节。这 36 个字节的数据就是函数签名,其中前 4 个字节为函数选择器,它将指引 EVM 去选择我们调用的目标函数,后 32 个字节的数据则是我们调用函数时传入的参数。

操作码和调用栈

下面解读每个操作码的作用及其对栈的调用映像。(栈数据结构工作原理快速入门:https://www.youtube.com/watch?v=FNZ5o9S9prU

将得到的字节码分解成相应的操作码后依次开始分析:

  • PUSH1操作:将一个 1 字节的值压入栈,他会告诉 EVM 将下一个数据字节 0x00 (也就是十进制的 0)压入栈中。
    图片
  • CALLDATALOAD操作:从消息数据中读取 32 个字节的值,其中使用“输入”值作为偏移量将calldata加载到栈中,栈顶大小为 32 字节,但是当前我们的calldata有 36 个字节。推送的值是msg.data[i:i+32]其中 “i” 是输入值,此操作确保只有 32 个字节被推送到栈,同时也能保证我们能够访问calldata中的任何部分

当前输入值为 0 ,也就是没有偏移量(从栈中弹出的值是前一个 PUSH1 的值),因此calldata的前 32 字节会被推送到调用栈。

前面的函数签名为 36 个字节,这就意味着后面的 4 个字节 0000000a将会丢失。如果想 访问这个 uint256 类型的参数,需要设置 4 的偏移量来省略函数签名,这样就可以保证参数的完整性。

图片

  • 第二次进行 PUSH1 的操作将传入十六进制的数据 0xe0,也就是十进制 224。我们上面提到过,函数签名是 4 个字节也就是 32 位。我么加载的 calldata 是 32 个字节,也就是 256 位,而 256 - 32 = 224 正好满足。
    图片

  • SHR,是向右移位指令。他从栈中获取第一项 224 表示要位移的位数,从栈中获取第二项(0x6057…00)表示需要移位的内容。在这个操作之后调用栈上有了 4 个字节的函数选择器。
    图片
    对于位移的工作原理,可以查看这个视频了解:https://www.youtube.com/watch?v=fDKUq38H2jk&t=176s

  • 接下来的操作码,DUP1用来获取并复制栈顶的值。
    图片

  • PUSH4retrieve()(0x2e64cec1)的 4 个字节函数签名推入调用栈。
    solidity 代码被编译成字节码中,编译器可以从字节码中获取所有函数名称和参数类型的信息。
    图片

  • EQ用于判断从栈中弹出的 2 个值,当前示例中为0x2e64cec10x6057361d并检查他们是否相等。如果相等,则将1 推回栈,如果不相等则为 0。
    图片

  • PUSH2将 2 字节的十六进制数据0x003b,十进制值为 59,推送到调用栈中。

    调用栈中有一个叫做程序计数器的东西,他会指定下一个执行命令在字节码中的位置,这里的 59 ,是通过retrieve()字节码的开始位置所得到的。
    图片

  • JUMP1代表,如果条件为真,则跳转。他从栈中弹出 2 个值作为输入,第一个 59 表示的是跳转位置,第二个 0 是 是否应该执行此跳转条件的布尔值。其中 1 为真,0 为假。

    如果条件为真,程序计数器将被更新,执行将跳转到该位置。但我们的例子中条件为假,程序计数器没有改变并且继续执行。
    图片

  • 再次进行DUP1
    图片

  • PUSH4store(uint256)(0x6057361d)的 4 个字节函数签名推送到调用栈上。
    图片

  • 再次进行EQ,但这次结果为真,因为函数签名相同。
    图片

  • 执行JUMP1,此处 bool 值为真,执行跳转,因此会将程序计数器更新为 89,这会将执行移动到字节码的不同部分。在这个位置,会有一个 JUMPDEST操作码,如果没有这个操作码在这里的话,JUMP1操作就会失败
    图片

    有了这个,在执行此操作码后,将被带到store(uint256)对应的字节码的位置,并且函数的执行将继续,虽然这个合约只有两个函数,但基础原理都是相同的。

通过上面的例子我们知道了 EVM 是如何根据合约函数调用来确定它需要执行的函数字节码的位置。简单来说就是由合约中每个函数及其跳转位置所组成的一组简单的“if 语句”。

EVM Playground

这是一个 EVM Playground(https://www.evm.codes/playground )测试平台,

在平台上我们可以设置刚刚运行的字节码。就能够通过交互方式来查看栈的变化,并且传入 JUMPDEST(注:可能跳转的目标元数据),可以看到 JUMPI 之后会发生什么。

图片

EVM Playgrpund 还能有助于我们理解程序计数器的运行,每条命令旁都能看到相对应的注释以及偏移量所代表的程序计数器的位置,同时在左边框内还能看到 calldata 的输入。当点击运行指令,可以通过右上角的箭头单步调试每个操作码例如更改为 retrieve() 调用数据 0x2e64cec1 来查看执行的变化。

来源:慢雾科技公众号

链接:

https://mp.weixin.qq.com/s?__biz=MzU4ODQ3NTM2OA==&mid=2247496252&idx=1&sn=f21662916829d32dfb36b50cec5faeff&chksm=fdde8cbbcaa905adf384a9b788ad2dea063f12029970328a3955d12012251d5fbe083454e717&scene=178&cur_album_id=1378673890158936067#rd