文章目录

1. EVM 概述
以太坊虚拟机(Ethereum Virtual Machine,EVM)是以太坊区块链的核心计算环境,负责执行智能合约和处理交易。它不仅是沙盒化(sandboxed)的,而且是完全隔离的,这意味着在 EVM 内运行的代码无法访问网络、文件系统或其他进程。
EVM 让所有以太坊节点能够达成共识,并在去中心化网络中保持一致的计算结果。
EVM 的作用
1.所有以太坊智能合约代码都在 EVM 内部运行,不依赖外部环境,确保代码执行的确定性。
2.EVM 运行在沙箱环境中,合约代码无法直接访问外部系统资源,确保安全性。
3.EVM 通过存储机制(Storage & Memory)记录智能合约的数据,所有交易最终会影响全局状态。
4.EVM 通过 Gas 费用防止恶意计算资源滥用,提高网络稳定性。
EVM 如何执行智能合约
1.EOA(外部拥有账户)发起交易,交易可以:
- 向另一个 EOA 或合约账户发送 ETH。
- 向合约账户发送数据,执行智能合约中的某个函数。
2.交易进入等待队列(内存池,Mempool),由矿工(PoW)或验证者(PoS)选择并打包进区块。
3.交易被矿工/验证者执行
- 交易执行时,EVM 读取合约代码,并根据操作码(Opcode)逐步执行。
- 操作码可能涉及计算、存储变更、资金转移等。
- 每个操作码都需要消耗 Gas,如果 Gas 不足,交易失败。
4.交易完成 & 状态更新
- 交易执行完毕后,EVM 将最终结果提交到全局状态数据库,并存入区块链。
- 交易的执行结果是不可逆的,区块链上的数据无法修改。
账户类型
以太坊有两种账户类型,EOA(外部拥有账户)与合约账户。
1.EOA(外部拥有账户,Externally Owned Account)
由用户控制,需要私钥进行签名。
可以发送交易(ETH 转账、调用智能合约)。
无法执行合约代码。
2.CA(合约账户,Contract Account)
由智能合约代码控制,存储在区块链上。
不能主动发起交易,只能被 EOA 或其他合约调用。
代码执行后,可以修改状态(如存储变量)或调用其他合约。

无论账户是否存储代码,EVM 对这两种账户的处理方式都是相同的。
每个账户都有一个持久化的键值存储,用于映射 256 位字到256 位字,这称为存储(storage)。
此外,每个账户还拥有一个以太(Ether)余额(确切来说是以 “Wei” 作为单位,1 Ether = 10¹⁸ Wei),可以通过发送包含以太的交易来修改账户余额。
交易
交易(Transaction)是从一个账户发送到另一个账户的消息(可以是相同账户,也可以是空账户)。交易可以包含二进制数据(称为“payload”)和以太(Ether)。
-
如果目标账户包含代码,那么该代码将会被执行,并且交易的 payload 将作为输入数据提供给该代码。
-
如果目标账户未设置(即交易没有接收者,或者接收者被设为 null),则该交易会创建一个新合约。
在合约创建交易中,合约的地址不是 0x0,而是由发送者的地址和其 nonce 计算得出。这种交易的 payload 实际上是EVM 字节码(bytecode),并会被执行,执行的输出数据将被永久存储为该合约的代码。
这意味着,创建合约时,你发送的并不是合约的实际代码,而是一个在执行时返回该代码的代码段。
注意
在合约创建的过程中,其代码仍然是空的。因此,在构造函数(constructor)执行完毕之前,不应在合约内部回调该合约,否则会导致错误。
示例:EOA 发送 ETH
用户 A(EOA)向用户 B(EOA)发送 1 ETH。交易签名后广播到以太坊网络,矿工打包后执行转账。
示例:EOA 调用智能合约
用户 A(EOA)调用一个 DEX 智能合约,要求兑换 ETH → USDT。交易发送到合约账户,合约代码执行兑换逻辑,完成交易。
示例:合约之间的交互
Uniswap 智能合约可以调用另一个合约(如 ERC-20 代币合约)来完成代币交易。
2. EVM 体系结构
在 EVM(以太坊虚拟机)的体系结构中,栈、内存、存储和 Gas 机制是四个非常关键的组件,它们共同影响了合约执行的效率和成本。
栈(Stack)
EVM 基于栈(Stack)运算,而非寄存器(Register)。栈是一种后进先出(LIFO)的数据结构,用于存储临时的计算结果,操作数和结果的进出都是通过栈完成的。每当执行一条指令时,它可能会从栈中弹出操作数并将结果推入栈中。栈最多只能容纳 1024 个元素,每个元素占 256-bit。
同时,栈只能访问顶部的 16 个元素,不能直接访问深层数据。在栈中,我们只能通过 DUP(复制)和 SWAP(交换)操作,在前 16 个元素范围内调整顺序。
内存(Memory)
EVM 中的内存是一个临时的存储区域,EVM 在每次执行消息调用(Message Call)时,都会提供一个全新的内存实例。
内存用于存储合约执行过程中需要的临时数据,比如数组、字符串等,通常用于函数调用的中间数据存储。内存的内容在交易完成后会被清除。
特点
1.内存的大小是动态变化的,可以根据需求扩展。访问未使用的内存时,会导致Gas 费用增加,且扩展成本呈二次方增长(Quadratic Scaling)。
2.内存访问速度相对较快,但需要消耗 Gas。
3.由于它是临时的,所以不会像存储那样在交易完成后保留。
4.存储结构是线性的,可以按字节寻址。
5.读取操作固定为 256-bit,写入操作可以是 8-bit 或 256-bit。
存储(Storage)
存储是合约的持久化存储区域,所有合约的状态变量都存储在这里。
存储用于保存合约的数据,并且存储的内容在交易结束后不会被清除。这意味着合约的状态变量(例如账户余额、合同设定等)会被保留,并在之后的交易中继续使用。
特点
1.持久化存储,每个账户都有一个 Storage 区域(键值存储,256-bit 到 256-bit)。
2.存储数据永久存在,即使交易结束后仍可访问。
3.不能在智能合约内部遍历 Storage(不能获取所有的键或值)。
4.读取 Storage 的成本较高,修改 Storage 的成本更高。因此,为了降低 Gas 费用,应尽可能减少 Storage 操作。例如,计算结果、缓存数据、聚合信息等,最好存储在链下或其他更高效的数据结构中。
5.每个合约只能访问自己的 Storage,不能读取或修改其他合约的 Storage。
还有一种存储方式称为瞬时存储(Transient Storage),它与 Storage 类似,但它的数据不会持久化,仅在当前交易(Transaction)执行期间有效。交易结束时,Transient Storage 会被自动清空,即数据不会存入区块链中。同时,它的访问成本比 Storage 低,适合存储临时数据。
应用场景:Transient Storage 可用于跨函数调用共享数据,但不会影响链上状态,适用于减少链上存储成本的优化方案。
其他数据区域
1、Calldata 是智能合约交易中发送的数据。外部函数(external)的参数最初存储在 Calldata 中,并以 ABI 编码格式传递。
注意:Calldata 是不可变的(immutable),无法修改。
2、当智能合约执行完成后,返回值会被存储在 Returndata 区域。具体而言,Solidity 通过 return 关键字,将返回值 ABI 编码,并存储在 Returndata 中。
staticcall 和 call 操作码可用于读取 Returndata。
3、Code(代码区域),用于存储智能合约的 EVM 字节码(Bytecode)。
Code 是合约账户的持久化数据,存储在合约的 state field(状态字段)。
immutable 和 constant 变量存储在 Code 区域:
- immutable 变量在编译时会替换为固定值。
- constant 变量的表达式会被直接内联到代码中,避免额外的存储开销。
Gas 机制
Gas 是 EVM 中用于衡量计算和存储操作成本的单位。它确保智能合约的执行不会因为无限循环或资源消耗过度而影响网络的稳定性。
每个操作(无论是计算、存储、还是其他合约交互)都会消耗一定的 Gas。在创建交易时,交易的发起者(tx.origin)需要预先支付一定数量的 Gas。当 EVM 执行交易时,Gas 会按照特定规则逐步消耗。如果在执行过程中 Gas 消耗殆尽(即 Gas 变为负数),则会触发“Out-of-Gas”异常,导致交易执行终止,并回滚当前调用帧中对状态的所有修改。
Gas 机制主要有两个作用:
1.防止资源滥用,鼓励开发者优化智能合约的执行效率。
2.补偿 EVM 执行者(矿工或验证者),激励他们处理交易。
由于每个区块都有 Gas 上限,这也间接限制了验证一个区块所需的计算量。
Gas 价格(Gas Price)由交易发起者设定,其需要提前支付 gas_price * gas 给 EVM 执行者(矿工或质押者)。
-
如果执行结束后仍有剩余的 Gas,则会退还给交易发起者。
-
如果交易失败并回滚(例如遇到 require(false)),已消耗的Gas不会退还。
由于 EVM 执行者(矿工或验证者)可以自由选择是否打包某笔交易,因此交易发送者没办法滥用系统,比如设置超低的 Gas 价格以试图“免费”执行交易。
特点
1.每个交易都有一个最大 Gas 限制,确保交易在消耗预定 Gas 后结束。
2.每个 Gas 单位都有一个价格,由交易发起者设定。
3.不同的操作消耗不同量的 Gas。例如,简单的数学运算消耗少量 Gas,而存储操作则消耗大量 Gas。
3. EVM 运行流程
1.用户或智能合约发起交易,并使用私钥对交易进行签名。交易包括目标地址、金额、Gas 限额、输入数据等信息。签名后的交易被广播到以太坊网络中的所有节点,进入内存池等待处理。
2.每个节点会验证交易的有效性,具体验证步骤包括:
- 签名验证,确保交易是由合法账户发起的。
- 确认发送方账户余额足够支付交易金额和 Gas 费用。
- 确保交易的 Gas 限额足够执行。
- 确保交易的 nonce(交易序号)正确,防止重放攻击。
3.经过验证的交易被矿工或验证节点打包到新区块。矿工根据交易的 Gas 价格来决定交易的优先级。每个区块的大小有限,因此矿工会优先选择 Gas 价格更高的交易。
4.当交易中的目标是智能合约时,EVM 会执行该交易。
- EVM 从区块链中读取并加载目标合约的字节码,按照合约的字节码逐条执行指令,指令包括算术操作、条件跳转、存储操作等。
- 栈用于存储临时计算结果,每次执行指令时会从栈中弹出或推入数据。
- 内存用于存储临时数据,如函数调用中的变量等,交易结束后会被清空。
- 存储用于存储合约的持久化状态(例如账户余额、合约设置等),更新时会消耗 Gas。
5.每执行一条指令,EVM 会消耗一定量的 Gas。Gas 消耗的数量取决于操作的复杂性(例如,存储操作消耗的 Gas 比简单的算术运算要多)。如果 Gas 不足,交易会被回滚,合约执行不会生效,所有修改都会撤销。
6.如果合约的状态发生了变化(如修改了状态变量),EVM 会更新存储中的数据。如果合约触发了事件,EVM 会记录这些事件日志,这些日志可以在区块链外部查询到。
7.新区块被成功挖掘并添加到区块链中,交易被认为已被确认。
8.区块通过网络同步到其他节点,所有节点都会更新自己的本地区块链,并执行新区块中的交易。
9.用户和其他应用可以通过区块浏览器或其他工具查询交易的结果。
4. 智能合约执行流程:部署、调用、状态变化
1.合约部署是将合约代码(字节码)上传到以太坊网络,创建一个新的合约实例。
合约字节码是 Solidity 或其他智能合约语言编写的代码经过编译后生成的机器可执行格式,包含了合约的所有逻辑。
用户通过发起包含合约字节码的交易将合约代码部署到区块链上。合约部署后,EVM 会根据合约字节码生成一个新的合约地址,并分配相应的存储空间。
一旦合约被部署,它会获得一个唯一的合约地址。用户或其他合约可以通过该地址与合约交互。
2.合约调用是对已部署合约的函数进行调用,调用可以是外部调用或内部调用。外部调用是由用户或其他合约发起的。内部调用是由合约内部触发的其他函数调用,通常不会直接涉及到外部的账户。合约调用可能会改变合约的状态(例如修改状态变量)。
3.合约调用可能会更改合约的存储数据,存储操作消耗 Gas,并且在执行完毕后会永久存储在区块链上。
5. EVM 指令集(Opcodes)
EVM 的指令集被设计得尽可能精简,以避免由于实现错误或不一致性而导致共识问题(Consensus Issues)。
-
所有指令操作的基本数据类型都是256-bit 字(word),或是内存片段(slices of memory)和其他字节数组(byte arrays)。
-
指令集包含常见的算术、位运算、逻辑运算和比较操作。
-
指令集支持条件跳转和无条件跳转,使得智能合约可以执行复杂的控制流逻辑。
-
合约可以访问当前区块的相关属性,如 区块号(block number)和时间戳(timestamp)。
算术运算指令
ADD:加法运算。将栈顶的两个值相加,结果推入栈中。
操作:PUSH A, B -> A + B
MUL:乘法运算。将栈顶的两个值相乘,结果推入栈中。
操作:PUSH A, B -> A * B
SUB:减法运算。将栈顶的两个值相减,结果推入栈中。
操作:PUSH A, B -> A - B
DIV:除法运算。将栈顶的两个值相除,结果推入栈中。
操作:PUSH A, B -> A / B
MOD:取余运算。将栈顶的两个值相除,结果为余数,推入栈中。
操作:PUSH A, B -> A % B
逻辑运算指令
AND:按位与运算。对栈顶的两个值执行按位与(AND)操作,结果推入栈中。
操作:PUSH A, B -> A & B
OR:按位或运算。对栈顶的两个值执行按位或(OR)操作,结果推入栈中。
操作:PUSH A, B -> A | B
XOR:按位异或运算。对栈顶的两个值执行按位异或(XOR)操作,结果推入栈中。
操作:PUSH A, B -> A ^ B
NOT:按位非运算。对栈顶的值执行按位非(NOT)操作,结果推入栈中。
操作:PUSH A -> ~A
SLOAD:从合约存储中加载数据。该指令从合约的存储中加载一个值并将其推入栈中。
操作:PUSH A -> SLOAD A
SSTORE:将数据存储到合约存储中。该指令将栈顶值存储到指定的合约存储位置。
操作:PUSH A, B -> SSTORE A = B
MLOAD:从内存加载数据。该指令从 EVM 内存中加载数据并将其推入栈中。
操作:PUSH A -> MLOAD A
MSTORE:将数据存储到内存中。该指令将栈顶的值存储到指定的内存位置。
操作:PUSH A, B -> MSTORE A = B
JUMP:跳转到指定的指令位置。该指令会将程序执行的控制流跳转到栈顶指定的地址。
操作:PUSH A -> JUMP A(跳转到地址 A)
JUMPI:条件跳转。若栈顶值为真,则跳转到指定的地址;否则继续执行。
操作:PUSH A, B -> JUMPI A(如果 B 为真,则跳转到地址 A)
PC:获取当前指令地址。该指令会将当前程序计数器(PC)推入栈中,用于获取执行位置。
操作:PC -> PUSH A
STOP:停止执行。该指令会立即停止当前的智能合约执行。
操作:STOP(停止合约执行)
RETURN:返回数据并停止执行。该指令用于从合约返回一段数据,之后停止合约执行。
操作:PUSH A, B -> RETURN(从 A 地址返回 B 长度的数据)
CALL:合约调用。该指令用于调用其他合约的函数,并可以传递数据和一定数量的 Gas。
操作:PUSH A, B, C, D -> CALL A, B, C, D(调用合约 A 的地址,传递 B 的 Gas,C 的数据和 D 的参数)
DELEGATECALL:委托调用。与 CALL 类似,但是在 DELEGATECALL 中,目标合约的存储是由调用合约控制的。它允许调用合约的上下文(存储、msg.sender 等)保持不变,而只是执行目标合约的代码。
操作:PUSH A, B, C, D -> DELEGATECALL A, B, C, D
STATICCALL:静态调用。与 CALL 相似,但是不允许修改合约状态(不允许发送 Ether 和修改存储)。这种调用通常用于读取合约状态。
操作:PUSH A, B, C, D -> STATICCALL A, B, C, D(执行目标合约的函数,只能读取数据)
6. Gas 计算
Gas 是 EVM 计算成本的衡量单位,每个 EVM 操作(Opcode)都会消耗一定数量的 Gas。
Gas 机制的主要目的是:
1.防止恶意代码无限执行(如死循环)
2.保障区块链资源的公平分配
3.为矿工提供计算资源的激励
交易执行时,每个指令的执行都需要消耗 Gas。用户在发起交易时需要指定 gas limit(最大 Gas 消耗)和 gas price(Gas 单价)。如果交易执行过程中 Gas 消耗超过 gas limit,交易会被回滚,但已消耗的 Gas 不会退还。
7. 消息调用(Message Calls)
智能合约可以通过消息调用调用其他合约,或向非合约账户发送 Ether。
消息调用类似于交易(Transaction),它包含来源地址(Source)、目标地址(Target)、数据载荷(Data Payload)、转移的以太币(Ether)、Gas 费用(Gas)和返回数据(Return Data)。实际上,每笔交易都包含一个顶级消息调用(Top-Level Message Call),而这个调用可以进一步触发更多的消息调用。
智能合约可以决定在内部消息调用时转移多少 Gas 以及保留多少 Gas 供自己使用。如果内部调用耗尽 Gas(或发生其他异常),EVM 会将错误值压入调用方的堆栈(Stack)通知调用失败,并且仅扣除被调用合约消耗的 Gas,调用方剩余的 Gas 不会被消耗殆尽。在 Solidity 中,默认情况下调用失败会抛出异常(Exception),异常会沿调用栈(Call Stack)向上传播。
如前所述,被调用合约(可以是自己)会获得一片全新的内存(Memory),不会继承调用方的数据,并且可以访问调用数据(Call Payload),存放在 Calldata 区域。执行完成后,可以返回数据,返回数据将被存储在调用方预分配的内存中。所有调用都是同步的,调用方必须等待被调用合约执行完成后才能继续执行。
调用是存在深度限制的,EVM 限制消息调用的最大深度为 1024 层,超过此深度会导致调用失败。因此,建议使用循环(Loops)而不是递归(Recursive Calls)来避免深度超限。此外,最多只能转发 63/64 的 Gas,这意味着实际调用深度通常低于 1000 层。
下面举个例子说明消息调用。
我们创建两个合约:
1.合约 B 具有一个 receiveEther 函数,可以接收以太币,并存储收到的金额。
// 合约B
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract B {
uint public receivedAmount;
// 接收以太币并存储金额
function receiveEther() public payable {
receivedAmount += msg.value;
}
// 查询合约余额
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
2.合约 A 通过 callContractB 函数调用合约 B,并向其发送以太币。
// 合约A
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract A {
event CallSuccess(bool success);
// 使用 call 调用 B 合约的 receiveEther 函数,并发送以太币
function callContractB(address payable _contractB) public payable {
(bool success, ) = _contractB.call{value: msg.value}(
abi.encodeWithSignature("receiveEther()")
);
emit CallSuccess(success); // 记录调用是否成功
}
// 查询合约余额
function getBalance() public view returns (uint) {
return address(this).balance;
}
// 允许合约接收以太币
receive() external payable {}
}
1.部署合约B,获取其合约地址(contractB)。
2.部署合约A。
3.在合约A上调用 callContractB(contractB),并附带一定的 msg.value(例如 1 ETH)。
4.在合约B上调用 getBalance(),可以看到余额增加了 1 ETH。
注意:
(bool success, ) = contractB.call{value: msg.value}(
abi.encodeWithSignature("someFunction()")
);
这里 call 不会限制 Gas,默认把所有剩余 Gas 发送给 contractB。
如果 contractB.someFunction() 消耗过多 Gas,可能导致整个交易失败(out-of-gas)。
(bool success, ) = contractB.call{gas: 100000, value: msg.value}(
abi.encodeWithSignature("someFunction()")
);
这里 call 只会提供最多 100,000 Gas 给 contractB.someFunction(),gas 的提高在一定程度上会加快交易的实现。
在正常情况下,我们应当设置适当的 Gas 限制,以防止被调用的合约恶意消耗所有剩余的 Gas。若不显式设置 Gas,默认会将所有剩余 Gas 发送给目标合约,这可能带来不必要的风险。
8. 合约销毁(Deactivate 和 Self-destruct)
区块链上的合约代码唯一的删除方式是调用 selfdestruct 操作码。当合约执行 selfdestruct 时:
1.该地址存储的剩余 Ether 会被转移到指定的目标地址。
2.该合约的存储数据和代码会被从状态中永久移除。
虽然销毁合约听起来是一个清理区块链的好方法,但它也带来一定的风险。如果有人向一个已销毁的合约地址发送 Ether,那么这些 Ether 将永久丢失,无法找回。因此,在使用 selfdestruct 之前,必须确保不会影响未来的资金流动。
2152





