xyyme.eth

Posted on Jul 20, 2022Read on Mirror.xyz

深入理解 EVM(一)

今天我们来聊聊 EVM,那么什么是 EVM?EVM 其实就是执行 bytecode(字节码)的机器,它的全称是 Ethereum Virtual Machine(以太坊虚拟机),和 Java 的 JVM 很类似。我们平时写合约都是用 Solidity (或者 Vyper)编写的,但是这种语言机器是没有办法理解的,我们需要先使用编译器进行编译,编译后的结果是一串二进制码,EVM 可以理解这些二进制的东西,因此它就可以执行这些代码,从而完成一笔交易。

合约编译

我们用一个简单的图示来解释这个过程:

我们从一个很简单的例子开始(文件命名为 Demo.sol):

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

contract Demo {
    uint256 a;
    constructor() {
      a = 1;
    }
}

我们平时开发项目用的都是 hardhatforge 这种框架,他们的底层都是通过 solc 来进行编译。这次我们就直接使用编译工具 solc 来进行编译,可以参考这里进行安装。这里列出 Mac 的安装方法:

brew update
brew upgrade
brew tap ethereum/ethereum
brew install solidity

安装完成后,我们来编译试试,使用下面的命令:

solc Demo.sol --bin

输出以下内容:

======= Demo.sol:Demo ======= Binary: 6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3fe6080604052600080fdfea26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033

我们看到在结果中输出了一串 16 进制字符,这就是上面的合约经过编译过后的字节码。我们前面说过,字节码是一串二进制的字符,这里显示为 16 进制方便阅读。注意到我们在命令中指定了 --bin 参数,因此输出的是 16 进制。

第一眼看见这串字符是不是已经懵了,别慌,我们慢慢来研究。首先,我们需要知道,EVM 的核心实际上是一个 stack machine(栈机器),它会接受操作符和操作数,学过数据结构的朋友应该都了解栈的原理。其次,上面这些字符都是由操作符和操作数组成的。例如开头的 60 代表的是 PUSH1,也就是将后面的一个字节(这里是 80)压入栈中。后面又是一个 60,接着是 40,即代表将 40 压入栈中。后面是 52,代表 MSTORE,它需要消耗两个操作数,需要从栈中获取。也就是说,上面的 6080604052,就代表着:

PUSH1 0x80
PUSH1 0x40
MSTORE

MSTORE 的两个操作数分别为 0x40、0x80,即 MSTORE(0x40, 0x80),也就是在内存中地址 0x40 处存储了数据 0x80。(这一句不明白没关系,我们先往下看)

现在我们已经明白了字节码的基本逻辑,它就是由操作符和操作数组成的。其中两个字符代表一个字节,操作符都是一个字节,但是操作数可能有多个字节。像我们前面看到的 PUSH1,是将后面的一个字节压入栈中,如果是 PUSH4,就是将后面的四个字节压入栈中。一般将操作符称为 Opcodes,这里可以看到所有的 Opcodes。需要注意的是操作符和操作数有可能重复,例如判断 60 是操作符还是操作数,取决于它在字节码中的位置,并不是绝对的。例如 6060,前面的 60 是操作符,后面的就是操作数,代表 PUSH1 60

字节码构造

接下来我们看看字节码的构造,我们在上面的字节码中搜索 fe,可以看到其中有两个 fe,同时查询 Opcodes 对照表,可知 fe 是无效操作符(INVALID)。它的作用其实是分隔符,它将字节码分成了三部分:

  1. init bytecode(初始化字节码)
  2. runtime bytecode (运行时字节码)
  3. metadata hash(合约的一些 meta 信息哈希)

那么我们前面编译的字节码就被分成了三部分:

6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3(init bytecode)

6080604052600080fd(runtime bytecode)

a26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033(metadata hash)

我们先来看看最后这里的 metadata hash,它默认是合约 metadata 文件的 IPFS 哈希值,我们可以使用:

solc Demo.sol --metadata

来获取到其 metadata:

{
    "compiler": {
        "version": "0.8.15+commit.e14f2714"
    },
    "language": "Solidity",
    "output": {
        "abi": [
            {
                "inputs": [],
                "stateMutability": "nonpayable",
                "type": "constructor"
            }
        ],
        "devdoc": {
            "kind": "dev",
            "methods": {},
            "version": 1
        },
        "userdoc": {
            "kind": "user",
            "methods": {},
            "version": 1
        }
    },
    "settings": {
        "compilationTarget": {
            "Demo.sol": "Demo"
        },
        "evmVersion": "london",
        "libraries": {},
        "metadata": {
            "bytecodeHash": "ipfs"
        },
        "optimizer": {
            "enabled": false,
            "runs": 200
        },
        "remappings": []
    },
    "sources": {
        "Demo.sol": {
            "keccak256": "0xf6e99f20fac61b16466088a9996227f35c4ca82119a846ba19a83698e8e126b1",
            "license": "UNLICENSED",
            "urls": [
                "bzz-raw://3fda90691cfa365f68a59d5b6bb76f8a0189153cebf93143879521ea309751b8",
                "dweb:/ipfs/QmP2dVkfPWy3tAriX17tar9phGzC8gqYzutmhEur6dwdiw"
            ]
        }
    },
    "version": 1
}

可以看到其中主要包含了编译器版本,ABI,IPFS 等信息。这部分了解即可,我们平时也用不到这些,详细内容可以查看文档

我们前面提到了一些操作数,例如 0x40、0x80,这些数字是什么意思呢。要了解这些,我们就得先明白 EVM 中的内存布局。前面的文章中,我们讲解过内存布局,但是当时讲的实际上是 Storage Layout,也就是状态变量的布局结构。而我们现在要讲的是 Memory Layout。区别在于 Storage 的数据是永久存在于区块链上的,类似于计算机的硬盘数据。而 Memory 的数据只有在发起交易的时候才有,交易完毕,数据全部消失,类似于计算机的内存数据。

合约内存

Memory 的数据结构就是一个简单的字节数组,数据可以以 1 字节(8 位)或者 32 字节(256 位)为单位进行存储,读取时只能以 32 字节为单位读取,但是读取时可以从任意字节处开始读取,不限定于 32 的倍数字节。图示:

用于操作内存的一共有 3 个操作符:

  • MSTORE (x, y) - 在内存 x 处开始存储 32 字节的数据 y
  • MLOAD (x) - 将内存 x 处开始的 32 字节数据加载到栈中
  • MSTORE8 (x, y) - 在内存 x 处存储 1 字节数据 y(32字节栈值中的最低有效字节)

Solidity 中预留了 4 个 32 字节的插槽(slot),分别是:

  • 0x00 - 0x3f (64 字节): 哈希方法的暂存空间
  • 0x40 - 0x5f (32 字节): 当前已分配内存大小 (也称为空闲内存指针)
  • 0x60 - 0x7f (32 字节): 零槽,用作动态内存数组的初始值,永远不能写入值

这里面最重要的就是中间这一项,也就是空闲指针。它会指向空闲空间的开始位置,也就是说,要将一个新变量写入内存,给它分配的位置就是空闲指针所指向的位置。需要注意的是,Solidity 中的内存是不会被释放(free)的。

对于空闲指针,它的更新遵守了很简单的原则:

新的空闲指针位置 = 旧的空闲指针位置 + 分配的数据大小

上图中我们看到,Solidity 的预留空间已经占据了 128 个字节,因此空闲指针的起始位置就只能从 0x80(128字节) 开始。空闲指针本身是存在于 0x40 位置的。由于我们在函数中的操作均需要在内存中进行,因此首要任务就是要通过空闲指针分配内存,所以我们前面才需要使用 6080604052,也就是 MSTORE(0x40, 0x80),来加载空闲指针。此时是不是已经有些明白为什么所有的合约都是以 6080604052 开头了(有些老版本合约以 6060604052 开头)。

小结

这篇文章我们就先介绍到这里,我们学习了合约的编译过程,字节码的构造,以及合约的内存分布。可以多看几遍消化消化。下篇文章我们将介绍合约的部署,也就是 init bytecode 部分,了解 EVM 在合约部署时的运行逻辑。

关于我

欢迎和我交流

参考

https://leftasexercise.com/2021/09/08/a-deep-dive-into-solidity-function-selectors-encoding-and-state-variables/

https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b