EVM 源码解析

2023-02-09 10:33:45 浏览数 (1)

1 EVM

以太坊虚拟机 (Ethereum Virtual Machine, EVM) 负责执行交易和更新区块链状态。

EVM 是一个状态执行的机器,输入是 solidity 编译后的二进制指令和节点的状态数据,输出是节点状态的改变。

像 VirtualBox 或 QEMU 是计算机的虚拟,KVM 是整个操作系统实例的虚拟,他们分别提供了硬件、系统调用和其它内核功能的软件抽象。EVM 则只是一个计算引擎,提供了计算和存储的抽象。EVM 没有调度能力,因为执行顺序是外部组织的。以太坊客户端通过验证的区块交易来确定哪些智能合约需要执行以及执行顺序。从这个意义上讲,以太坊世界计算机是单线程的,就像 JavaScript 一样。EVM 也没有任何“系统接口”处理或“硬件支持”——没有与之交互的物理机器。

EVM 可以访问账户信息(比如地址和余额)和区块链信息(比如 block number 和 gas 费用)。

EVM 的位置:

EVM 位置EVM 位置

EVM 架构:

EVM 架构EVM 架构

EVM 由程序计数器 (Program Counter)、堆栈 (Stack)、内存 (Memory) 和外部存储(Storage)组成。

EVM 存储:

EVM 存储EVM 存储

EVM 中数据可以在三个地方进行存储,分别是栈,临时存储,永久存储。

  • stack,后进先出结构,256 bits * 1024。因为栈的限制,因此栈上的临时变量的使用会受限制。
  • memory,一个可无限扩展的字节数组。临时内存存储在每个 VM 实例中,并在合约执行完后消失。
  • storage,k/v 结构,存储合约状态。不像 stack 和 memroy,计算结束后会被重置,永久内存存储在区块链的状态层

EVM 运行:

EVM 运行EVM 运行

当 EVM 运行时,它的状态可被定义为 (block_state, transaction, message, code, memory, stack, pc, gas),block_state 包含所有帐户的全局状态,包括余额和存储。每一轮执行开始时,通过 code 的第 pc 个字节获取当前指令,每条指令都有自己的定义,并且影响着状态。例如,ADD 从栈中弹出两项并将它们的总和压栈,将 gas 减 1 并将 pc 加 1。SSTORE 将栈中的前两项压栈,并将第二项按照第一项的索引插入合约存储中。

code 是 solidity 编译后的二进制指令/字节码,EVM 会将 code 分解为 Opcode。理论上共有 256 个长度为 1 字节的 Opcode,但 EVM 只使用了 140 种。code 示例如下:

代码语言:json复制
60003560e01c80632e64cec11461003b5780636057361d1461005957

上面示例中的每个字节都指向不同的 Opcode。例如,第一字节(例如 60)是 PUSH1 操作码,下一字节(例如 00)是正被 push 的数据,第三字节(60)是 PUSH2 操作码,而下一字节是其输入(例如 e0)。

启动的每个 EVM 实例都是为了运行一段字节码。因此,字节码就像是 EVM 实例的 ROM,是不能修改的。

一个合约可以调用另一个合约,每次调用都会导致一个新的 EVM 实例化,如下图所示。

合约调用合约调用

Gas:

gasgas

每次执行指令时,内部计数器会记录需要向用户收取的佣金。当用户发起交易时,钱包中需要留有少量资金来支付这些佣金。

gas 有两个功能:

  • 经济激励。确保为矿工预付报酬,即使执行失败。
  • 避免网络攻击。确保代码执行的时间不能超过预付的时间。

2 SputnikVM

源码:SputnikVM

2.1 特性

  • 独立 —— 可以作为独立进程启动,也可以集成到其他应用程序中
  • 普适 —— 支持不同的以太坊区块链,如 ETC,ETH 或私有链
  • 无状态 —— 虚拟机本身不保存任何状态,只是将其连接到外部状态储存的执行环境中
  • 高效 —— 运行的高效性是该项目的设计初衷
  • 兼容物联网 —— 可以很方便地在嵌入式硬件设备中运行和使用

2.2 代码结构

  • src 供外部使用的库,包括
    • backend,存储 VM 状态信息(Storage),并暴露给 runtime;获取 block 相关信息。
    • executor,将 gasometer 和 core 连在一起,并处理 stack。
  • core EVM 的核心,定义了基本的执行规则,如 Machine,Memory,Opcode,Stack 等。
  • gasometer 计算 gas。
  • runtime 包含 Machine,支持返回数据和上下文。 与 block,transaction 和 storage 交互。

2.3 代码阅读

benches/loop.rs

SputnikVM 组成:

SputnikVM 组成SputnikVM 组成

2.3.1 创建 EVM

代码语言:javascript复制
let backend = MemoryBackend::new(&vicinity, state);

let metadata = StackSubstateMetadata::new(u64::MAX, &config);
let state = MemoryStackState::new(metadata, &backend);

let precompiles = BTreeMap::new();
let mut executor = StackExecutor::new_with_precompiles(state, &config, &precompiles);
  1. 创建 Backend。
    1. 传入 vicinity,是为了 mock block 信息,生产环境中可以替换为 block header。
    2. 传入 state,是为了保存 VM 状态信息(Storage),生产环境中可以替换为 RocksDB。
  2. 创建 StackSubstateMetadata,会默认创建一个 gasometer 以跟踪 gas 用量。
  3. 创建 StackState,包含 Backend 和 MemoryStackSubstate。
    1. 参数 StackSubstateMetadata 用来创建 MemoryStackSubstate。
    2. 子状态:交易的执行过程中会累积产生一些特定的信息,我们称为交易子状态,包括
      1. 自毁集合,一组应该在交易完成后被删除的账户。
      2. 交易接触过的账户集合,其中的空账户可以在交易结束时删除。
      3. 应该返还的余额,当使用 SSTORE 指令将非 0 的合约 storage 重置为 0 时,该余额会增加。
      4. 日志,针对 VM 代码执行的归档化、可索引的“检查点”,允许以太坊外部的旁观者简单地跟踪合约调用。
代码语言:javascript复制
pub struct MemoryStackSubstate<'config> {
	metadata: StackSubstateMetadata<'config>,
	parent: Option<Box<MemoryStackSubstate<'config>>>,
  storages: BTreeMap<(H160, H256), H256>,
	logs: Vec<Log>,  // 日志
	accounts: BTreeMap<H160, MemoryStackAccount>,  // 交易所接触过的账户集合
	deletes: BTreeSet<H160>,  // 自毁集合
}
  1. 创建 StackExecutor,包含 StackState 和 precompiles 等。 precompiles 是已编译的智能合约,提供了通用功能,使得以太坊节点高效运行。

2.3.2 执行 CALL 指令

通过给定的 caller,target address,value,data 和 gas limit 执行 CALL 指令。最后一个参数 access_list 是 Ethereum Berlin hard fork 后引入的。

代码语言:javascript复制
let (exit_reason, data) = executor.transact_call(
		H160::from_str("0xf000000000000000000000000000000000000000").unwrap(),
		H160::from_str("0x1000000000000000000000000000000000000000").unwrap(),
		U256::zero(),
		hex::decode("0f14a4060000000000000000000000000000000000000000000000000000000000b71b00")
			.unwrap(),
		u64::MAX,
		Vec::new(),
	);
  1. gasometer 根据 tx.data 和 accest list 计算并记录实际需要消耗的 gas,如果预付的 gas 不够会异常。
  2. caller 所在账户的 nonce 加 1。
  3. 加载 code:查看 accounts 中包含了 target address 的账户,如果包含,则返回该账户的 code;否则,从 backend 中根据 target address 获取账户,进而得到 code。
  4. 进入子状态:根据当前 executor 创建一个拥有子状态的 executor,接下来的操作在子状态 executor 上进行。
  5. 将 target address 所属的 account 添加到交易接触过的账户集合 accounts 中,并标记 reset 为 false。
  6. transfer:如果 caller 账户的余额小于 value,则异常。caller 的余额 -= value,target address 的余额 = value。
  7. 如果 target address 是 precompile 的,则执行 precompile 并记录消耗的 gas,退出子状态,结束;否则,继续下一步。
  8. 根据 code,data 等创建 Runtime,Runtime 会创建 Machine。
  9. 创建调用栈,将 Runtime 压栈。
  10. 循环执行,直到调用栈为空。
    1. 查看当前栈顶的 Runtime,如果类型为 Call,则执行如下步骤。
    2. 检查 gas 是否够。
    3. 循环运行 Machine,直到 code 中的指令都执行完:根据 pc 计数器从 code 中获取当前指令,解释为 Opcode 并执行,执行后更新 pc 计数器。
    4. 如果 Machine 运行正常退出,则执行如下步骤。
    5. 从 runtime.machine.memory 中获取 Machine 执行结果的返回值 return data。
    6. 退出子状态:将子状态更新到当前 executor,如 gas,logs,accounts,deletes 等,从内存中删除 reset 为 true 的 account。
    7. 将当前 Runtime 弹出调用栈。
    8. 从调用栈中获取下一个 Runtime,runtime.return_data_buffer 指向 return data。如果 Runtime 的类型是 Call,将 return data 拷贝到 runtime.machine.memory 中,拷贝成功则在 runtime.machine.stack 中压入 1,否则压入 0。

3 REVM

源码:REVM

3.1 特性

  • EVM 兼容性和稳定性 —— 在区块链行业,稳定性是任何系统最需要的属性。
  • 速度 —— 是最重要的事情之一,大多数决策都是为了补充这一点。
  • 简单 —— 简化内部结构,使其易于理解和扩展,并且接口易于使用或集成到其他项目中。
  • 接口 —— 以便用作 wasm-lib,并在需要时与 JavaScript 和 cpp 绑定集成。

3.2 代码结构

  • crates
    • revm -> EVM 主库
    • revm-primitives -> 数据结构定义,如 Bytecode,Account,DB,Storage,Log,Env 等
    • revm-interpreter -> 指令循环执行
    • revm-precompile -> EVM 预编译合约
  • bins:
    • revme:CLI,用于运行状态测试
    • revm-test:主要用于检查性能

3.3 代码阅读

bins/revm-test/src/bin/snailtracer.rs

REVM 组成:

REVM 组成REVM 组成

.3.1 创建 EVM

代码语言:javascript复制
let mut evm = revm::new();

// BenchmarkDB is dummy state that implements Database trait.
let bytecode = to_analysed::<BerlinSpec>(Bytecode::new_raw(contract_data));
evm.database(BenchmarkDB::new_bytecode(bytecode.clone()));

3.3.2 执行 CALL 指令

EVM 输入:

代码语言:javascript复制
// execution globals block hash/gas_limit/coinbase/timestamp..
evm.env.tx.caller = "0x1000000000000000000000000000000000000000"
    .parse()
    .unwrap();
evm.env.tx.transact_to = TransactTo::Call(
    "0x0000000000000000000000000000000000000000"
        .parse()
        .unwrap(),
);
evm.env.tx.data = Bytes::from(hex::decode("30627b7c").unwrap());

EVM 运行:

代码语言:javascript复制
evm.transact().unwrap();
  1. 创建 EvmImpl。
  2. 将 caller 账户从 DB 中加载到内存 state 中。
  3. caller 账户的余额减去 tx.gas_limit * tx.gas_price,余额不足则报错。
  4. 创建 Gas,根据 tx.data 和 access list 计算并记录初始 gas 消耗。
  5. caller 账户的 nonce 加一。
  6. 加载 code:根据 target address 从 DB 中加载 account 到内存 state 中,根据 account 的 code hash 从 DB 中加载 code,并存放到 account.code 中。
  7. 进入子程序:调用栈深度 depth 加 1。如果 depth 超限则报错。
  8. 将 target address 账户标记为 touched。对于较新的硬分叉,这意味着它可以从 state 中删除。
  9. transfer:caller 的余额 -= value,target address 的余额 = value。caller 和 target address 账户均标记为不被删除。
  10. 如果 target address 是 precompile 的,则执行 precompile 并记录消耗的 gas,调用栈深度 depth 减 1,结束;否则,继续下一步。
  11. 根据 code 和 input 等创建 Interpreter。
  12. 检查 gas 是否够。
  13. 循环运行 Interpreter,直到 code 中的指令都执行完:根据 pc 计数器从 code 中获取当前指令,解释为 Opcode 并执行,执行后更新 pc 计数器。
  14. 退出子程序:从内存中删除被标记为 touched 的 account。调用栈深度 depth 减 1。返回消耗的 gas 和计算结果。
  15. 更新并返回当前程序的 gas 和 state。

4 SputnikVM vs REVM

  • 代码结构 相近之处:
    • SputnikVM / src vs REVM / revm,都是供外部使用的库。
    • SputnikVM / core vs REVM / primitives,都会定义核心数据结构。
    • SputnikVM / runtime vs REVM / interpreter,都定义了指令执行。

    不同之处:

    • SputnikVM::PrecompileSet 定义在 src/executor/stack/executor.rs 中;REVM::Precompiles 定义在 precompiles 中,是单独的目录,并提供了一些 precompile 的实现。
    • SputnikVM::Gasometer 定义在 gasometer 中,是单独的目录;REVM::Gas 定义在 interpreter/src/gas.rs 中,被认为是 Interpreter 的一部分。
  • 数据结构 两者有比较相近的数据结构,如:
    • SputnikVM::Machine vs REVM::Interpreter,都用来执行 code。不过 REVM::Interpreter 包含了 gas 计费功能,而在 SputnikVM 中,该部分功能移到了 Machine 外部。
    • SputnikVM::Backend vs REVM::DB,都用于持久化区块链状态。不过 SputnikVM::Backend 中还包含了 REVM::Env 功能。
    • SputnikVM::MemoryStackState vs REVM::JournaledState,都用来维护交易执行过程中的一些状态。不过 SputnikVM::MemoryStackState 还会维护 gas 状态。
  • EVM 创建 REVM 更简单。
  • CALL 指令运行 两者的 CALL 指令运行逻辑基本一致。不过 SputnikVM 会显式维护一个调用栈,而 REVM 采用递归方式。
  • 兼容性 REVM 兼容了更多的 hard fork 情况。
    • REVM — Frontier,Homestead,Tangerine,Spurious,Byzantium,Constantinople,Petersburg,Istanbul,MuirGlacier,Berlin,London,Merge,MergeEOF
    • SputnikVM — Frontier,Istanbul,London,Merge
  • GitHub 上所宣传的优势 共同的优势:高效,理论上当 call 层级较多时,SputnikVM 会更高效一些。 各自的优势:
    • SputnikVM
      • 普适 —— 支持不同的以太坊区块链,如 ETC,ETH 或私有链
      • 兼容物联网 —— 可以很方便地在嵌入式硬件设备中运行和使用
    • REVM
      • 简单 —— 简化内部结构,使其易于理解和扩展,并且接口易于使用或集成到其他项目中。
      • 接口 —— 以便用作 wasm-lib,并在需要时与 JavaScript 和 cpp 绑定集成。

    本人不确定 REVM 是否支持不同的以太坊区块链,是否兼容物联网;不确定 SputnikVM 是否可以用作 wasm-lib。 两者的逻辑相近,个人感觉 REVM 的结构会简单一些。

  • GitHub 指标 SputnikVM 更优。

SputnikVM

REVM

star

837

546

used by

5.1k

219

first release

2017.06.13

2021.09.29

参考

Ethereum EVM illustrated

以太坊虚拟机 (EVM)

What is the Ethereum Virtual Machine (EVM)?

The Ethereum Virtual Machine

The Ethereum Virtual Machine (EVM) - What Is It and How to Make Business on It?

Ethereum Whitepaper

Ethereum Yellow Paper

SputnikVM

REVM

0 人点赞