今天我们来聊聊 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;
}
}
我们平时开发项目用的都是 hardhat
,forge
这种框架,他们的底层都是通过 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)。它的作用其实是分隔符,它将字节码分成了三部分:
那么我们前面编译的字节码就被分成了三部分:
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 个操作符:
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 在合约部署时的运行逻辑。
欢迎和我交流