这是多部分系列文章的第一篇:“每个区块链开发者应该了解的EVM内部原理”。
本系列的目标是帮助开发者超越框架和Solidity语法,真正理解以太坊虚拟机(EVM)如何执行智能合约。我们将涵盖EVM的核心组件,gas如何在操作码级别被消耗,函数调用和回滚期间会发生什么,以及如何调试工具通常隐藏的底层问题。
在这第一篇文章中,我们将分解EVM的架构和执行环境:
1.以太坊中的gas是什么
2. 什么是智能合约
3. 什么是以太坊虚拟机(EVM)
4.EVM架构:堆栈、内存、存储和Calldata解释
5. 例子:从源代码到字节码
以太坊中的gas是什么?
Gas是用于衡量以太坊虚拟机(EVM)中一个操作需要多少计算工作的单位。要在以太坊上执行一个交易,你必须支付一个叫做 gas 的费用。 Gas 涵盖两件事:
-
执行交易的计算成本(例如写入存储,调用合约) -
将你的交易纳入区块的包含成本
EVM中的每个操作,从简单数学运算到存储数据,都会消耗特定数量的gas。 Gas 费用较高的交易会被验证者优先处理,这意味着你愿意支付的越多,你的交易就越有可能被包含在一个区块中。
Gas 费用通常以区块链的原生代币(以太坊上的 ETH)支付,并确保计算资源的价格公平,防止垃圾邮件,并保持网络安全。
下表取自以太坊黄皮书,提供了特定操作码消耗多少gas的大致概念。
Gas 费用是用户为补偿以太坊网络完成的计算工作而支付的费用。就像Gas驱动汽车一样,gas驱动智能合约执行和交易。交易越复杂,需要的gas就越多。
Gas 费用还可以保护网络免受垃圾邮件和滥用,并激励开发者编写高效、优化的代码。
Gas 以 Gwei 衡量,Gwei 是 ETH 的一个很小的单位
(1 ETH = 10 亿 (1,000,000,000) Gwei)。
什么是智能合约?
智能合约是存储在区块链上的自执行程序。它在满足预定义条件时自动运行,无需中介,无需手动批准。可以将其视为强制执行各方之间协议的数字逻辑。
智能合约通过依赖代码而不是中央机构,实现参与者之间无需信任、透明的交易。在以太坊上,大多数智能合约都是用 Solidity 编写的,Solidity 是一种专门为 EVM 设计的高级语言。
EVM 确保每个合约在每个节点上以相同的方式运行,从而在整个网络中维护单一的全局状态。它定义了从一个有效的区块链状态移动到下一个状态的规则,一次一个区块。
EVM 在沙盒环境中运行智能合约,这意味着字节码在完全隔离的情况下执行,无法访问主机网络,文件系统或进程。这确保了整个网络的安全性和确定性。
合约中的每个指令都有一个预定义的 gas 成本,并且当 EVM 处理每个指令时,它会跟踪总消耗的gas。要运行这些指令,用户必须附加足够的原生货币(ETH)来支付作为 gas 费 支付的执行成本。
什么是以太坊虚拟机(EVM)?
以太坊虚拟机(EVM) 是以太坊执行智能合约的核心组件。它运行称为 操作码 的低级指令,这些指令在以太坊的正式规范(黄皮书)中定义。虽然其中许多类似于传统的虚拟机指令,但以太坊还包括自定义操作码,这些操作码可以实现链上逻辑,存储访问和智能合约行为。
每个以太坊执行客户端,如 Geth、Nethermind 等…(这些将在后面介绍)都包含其自己的 EVM 实现,但都遵循相同的核心规则。EVM 的工作是获取交易calldata和合约字节码,逐步执行它,并相应地更新以太坊的 世界状态。
EVM 在以太坊中扮演两个关键角色:区块处理 和 交易执行。
-
在区块创建期间,EVM 帮助管理以太坊的全局状态,即所有账户、余额和合约存储的完整记录。 -
在交易执行期间,EVM 通过解释称为 操作码 的低级指令来运行智能合约。这些指令被编码为 字节码,这是编译用高级语言(如 Solidity)编写的智能合约后得到的结果。
(更多例子在后面)
当用户发送交易以与合约交互时(如调用函数),EVM 会读取字节码,逐步执行指令,并在有足够的 gas 来支付计算的情况下更新状态。Gas 限制确保合约不会永远运行,并且每个操作都有成本。
EVM 作为一个 基于堆栈的虚拟机 运行,由几个重要的组件组成:
-
堆栈:一个后进先出(LIFO)的 256 位值列表,用于在指令之间传递输入和输出。 -
内存:在执行期间使用的临时线性内存。它在每次调用结束时重置。 -
存储:持久的、每个合约的键值存储。每个 32 字节的槽存储诸如余额或合约状态之类的值。 -
代码:与合约一起存储的不可变的字节码,EVM 在执行期间逐步执行它。 -
程序计数器(PC):跟踪当前正在执行的指令。 -
Gas:每个指令都有定义的 gas 成本。这限制了可以执行的计算量,并确保网络保持安全。
其中一些组件(如堆栈和内存)仅在执行期间存在,而其他组件(如存储和代码)则跨区块持久存在。它们共同构成了以太坊上每个智能合约运行的隔离的、确定性的环境。
EVM 架构(来源:EVM Illustrated)
EVM架构:堆栈、内存、存储和Calldata解释
堆栈:
堆栈 是 EVM 执行模型的核心部分。它是一个 易失性的,后进先出(LIFO)的结构,用于在交易执行期间在操作码之间传递数据。每个堆栈项是 256 位(32 字节),并且堆栈最多可以容纳 1024 项。
它用于诸如 PUSH
,ADD
,POP
等基本操作。如果你尝试使用超过允许的堆栈空间,或者在执行后留下错误数量的项目,则交易将因堆栈错误而 回滚。
堆栈无法存储诸如数组或映射之类的复杂类型,因此 内存 用于此目的。并且像 EVM 中的所有其他内容一样,堆栈操作会消耗gas。
EVM 架构(来源:EVM Illustrated)
内存:
EVM 中的 内存 是 临时的 和 线性的。它仅在单个交易期间存在,并在之后清除。它用于存储不适合堆栈的数据,例如数组,字符串或复杂计算中的中间值。
内存以 32 字节 字 运行,可以使用诸如 MSTORE
和 MLOAD
之类的操作码进行访问。虽然内存成本随大小而增加,但它仍然比 存储便宜,使其成为执行期间短时间数据的首选。
EVM 架构(来源:EVM Illustrated)
存储:
存储 是以太坊的 持久状态。它保存着在单个交易之外存在的合约数据。它将 256 位密钥映射到 256 位值,并存储诸如余额,变量和合约状态之类的内容。
与内存和堆栈不同,存储是 非易失性的。它存储在一种称为 Merkle Patricia Trie 的特殊结构中,该结构允许以太坊有效地跟踪和验证全局状态。
存储写入的 gas 成本很高,因此仅在需要长期保存数据时才使用。
EVM 架构(来源:EVM Illustrated)
Calldata:
Calldata 是 EVM 中的一个 只读 数据位置,其中包含外部函数调用的输入参数。它随交易一起传递,并且在执行期间无法修改。
当交易发送到智能合约时,交易中的 data
字段 告诉合约要运行哪个函数以及使用什么参数。
data
字段的前 4 个字节 称为 函数选择器。此值告诉 EVM 要调用合约中的哪个函数。
(你可以在4byte-directory中搜索著名的选择器)
如何计算函数选择器:
函数选择器是函数签名的 Keccak-256 哈希的前 4 个字节。
例子:
function set(uint256 x)
-
函数签名(作为字符串): "set(uint256)"
-
哈希:
keccak256("set(uint256)")
→ 0x60fe47b1...
-
前 4 个字节(8 个十六进制字符): 0x60fe47b1
→ 这是函数选择器
因此,如果你在 calldata 的开头看到 0x60fe47b1
,你就知道这是对 set(uint256)
的调用。
示例交易 Calldata:
假设你使用值 69420
调用此函数。
set(69420)
Calldata 看起来像:
0x60fe47b1
0000000000000000000000000000000000000000000000000000000000010f2c
分解:
-
0x60fe47b1
→ 函数选择器(set(uint256)
) -
接下来的 32 个字节: 0x...010f2c
→69420
,编码为带填充的 uint256
通用 Calldata 布局:
对于任何函数调用:
<4 bytes> 函数选择器(keccak256 的前 4 个字节)
<32 bytes> 参数 1(已填充)
<32 bytes> 参数 2(已填充)
...
需要了解的陷阱
-
选择器必须_完全_匹配,否则调用将使用 fallback()
或receive()
回滚。 -
Solidity 使用 ABI 编码,它是标准化的——多种工具(例如, ethers.js
,Foundry)可以解码/编码它。 -
当手动与合约交互时(例如,通过 eth_sendTransaction
),你必须自己构建此 calldata。
Foundry 示例
cast calldata "set(uint256)" 69420
## Output:
## 0x60fe47b1000000000000000000000000000000000000000000000000000000010f2c
内存、存储和Calldata 总结示例:
contract CalldataExample {
string public storedName;
function setName(string calldata _name) external {
// _name 是只读的,位于 calldata 中
string memory tempName = _name; // 复制到内存以便在需要时进行操作
storedName = tempName; // 保存到持久存储
}
}
这里发生了什么:
-
_name
通过 calldata 传入,它是只读的,无法直接更改。 -
使用 string memory tempName = _name;
将其复制到 内存 中,以便我们可以使用它。 -
最后,使用 storedName = tempName;
将其存储在合约的 存储 中。
例子:从源代码到字节码
当你用 Solidity 编写智能合约时,你真正创建的是一个高级蓝图,以太坊虚拟机(EVM)最终会将其作为称为 操作码 的低级指令执行。
让我们逐步了解 Solidity 代码是如何编译的,以及 EVM 如何处理它的。
pragma solidity >=0.4.16 <0.9.0;
contract MiniExample {
uint data;
function set(uint x) public {
data = x;
}
function get() public view returns (uint) {
return data;
}
}
这很容易阅读,但 EVM 不理解 Solidity。它需要由 Solidity 编译器(solc
)生成的 字节码。编译过程产生:
-
包含原始字节码的 .bin 文件 -
定义与合约交互的接口的 ABI 文件
如果合约具有构造函数,则该逻辑将捆绑到 部署字节码 中,该字节码仅运行一次。部署后,链上存储的内容称为 运行时字节码。
字节码是什么样的?
上面合约的编译字节码像这样开始
6080604052348015600e575f5ffd5b506101298061001c5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c806360fe47b11460345780636d4ce63c14604c575b5f5ffd5b604a60048036038101906046919060a9565b6066565b005b6052606f565b604051605d919060dc565b60405180910390f35b805f8190555050565b5f5f54905090565b5f5ffd5b5f819050919050565b608b81607b565b81146094575f5ffd5b50565b5f8135905060a3816084565b92915050565b5f6020828403121560bb5760ba6077565b5b5f60c6848285016097565b91505092915050565b60d681607b565b82525050565b5f60208201905060ed5f83018460cf565b9291505056fea2646970667358221220ec163686bf86159ebb242a8ca38f68fe4e9bf9be12def4ec8af94737310b0c6364736f6c634300081e0033
这个十六进制字符串是合约逻辑的直接表示。EVM 将其解释为操作码列表。例如:
-
60
→PUSH1
-
80
→ 将值0x80
推送到堆栈上 -
52
→MSTORE
(将值存储在内存中)
所以前几个操作是:
[00] PUSH1 80 // 将 1 字节的值 80 推送到堆栈上
[02] PUSH1 40 // 将 1 字节的值 40 推送到堆栈上
[04] MSTORE // 内存存储
[05] CALLVALUE // 从调用中获取已存入的值
[06] DUP1
[07] ISZERO // 一个条件操作码
[08] PUSH1 0xR // 推送 2 字节
[0b] JUMPI // 跳转到堆栈上的另一个位置
...
...
[138]
每个操作码都是由 EVM 执行的简单指令,并且 程序计数器(PC) 逐个遍历列表。
当你发送交易时会发生什么:
智能合约调用是通过 交易 进行的,该交易包括以下字段:
{
"to": "0x8a19ba...",
"from": "0xf9db21...",
"value": "0x0",
"gasPrice": 700000,
"gasLimit": 210000,
"data": "0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c"
}
以下是 EVM 内部发生的事情:
-
接收并解码交易。 -
使用 v
、r
和s
值验证签名。 -
EVM 设置一个隔离的环境,其中包含新的 堆栈 和 内存 上下文。 -
它逐条指令地执行字节码,更新堆栈、内存,并可能更新存储。 -
如果执行成功完成,它会返回结果或更新状态。如果没有,它会回滚。
Calldata 在此流程中如何工作:
让我们看一下交易中的 data
字段:
0x60fe47b10000000000000000000000000000000000000000000000000000000000010f2c
-
前 4 个字节: 0x60fe47b1
→ 这是 函数选择器,set(uint256)
的哈希
-
剩余的 32 字节:
→ 编码后的输入:0x...010f2c
,等于十进制的 69420
此数据告诉 EVM:“使用值 69420
调用 set
函数。”
在合约的字节码中,函数选择器被匹配,并且执行从正确的代码段开始。
原文链接:https://medium.com/@andrey_obruchkov/what-every-blockchain-developer-should-know-about-evm-internals-part-1-83a93c618257 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
登链社区始于 2017 年,通过构建高质量的技术内容平台和线下空间,助力开发者成为更好的 Web3 Builder。
登链社区网站 : learnblockchain.cn
开发者技能认证 : decert.me
B站 : space.bilibili.com/581611011
YouTube : www.youtube.com/@upchain