深入理解 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 在合约部署时的运行逻辑。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.