概述

以太坊虚拟机或 EVM 是以太坊中智能合约的运行时环境。

它不仅是沙盒,而且实际上是完全隔离的,这意味着在 EVM 内运行的代码无法访问网络、文件系统或其他进程。

智能合约甚至对其他智能合约的访问权限有限。

账户 Accounts

以太坊中有两种账户共享相同的地址空间:由公钥-私钥对(即人类)控制的外部账户和由与账户一起存储的代码控制的合约账户。

外部账户的地址由公钥确定,而合约的地址在合约创建时确定(它来源于创建者地址和从该地址发送的交易数量,即所谓的“ 随机数”)。

无论账户是否存储代码,EVM 都会平等对待这两种类型。

每个帐户都有一个持久的键值存储,将 256 位字映射到 256 位字,称为存储。

此外,每个账户都有一个 Ether 余额(准确地说是“Wei”,1 ether 是 10**18 wei),可以通过发送包含 Ether 的交易来修改。

交易 Transactions

交易是从一个帐户发送到另一个帐户的消息(可能相同或为空,见下文)。

它可以包括二进制数据(称为“有效负载”)和以太币。

如果目标帐户包含代码,则执行该代码并将有效负载作为输入数据提供。

如果没有设置目标账户(交易没有收款人或收款人设置为空),则交易创建一个新合约。

如前所述,该合约的地址不是零地址,而是从发送者及其发送的交易数量(“nonce”)派生的地址。

这种合约创建交易的有效负载被视为 EVM 字节码并被执行。

此执行的输出数据作为合约代码永久存储。

这意味着,为了创建合约,您无需发送合约的实际代码,而是发送实际执行时返回该代码的代码。

  • 注意

在创建合约时,其代码仍然是空的。

因此,在构造函数完成执行之前,您不应该回调正在构建的合约。

GAS

在创建时,每笔交易都会被收取一定数量的 GAS,该GAS必须由交易的发起者 (tx.origin) 支付。

在 EVM 执行交易的同时,gas 根据特定规则逐渐耗尽。

如果GAS在任何时候用完(即它是负数),就会触发一个GAS不足异常,这会结束执行并恢复对当前调用帧中状态所做的所有修改。

这种机制激励了 EVM 执行时间的经济使用,并且还补偿了 EVM 执行者(即矿工/质押者)的工作。

由于每个块都有最大量的GAS,它也限制了验证块所需的工作量。

gas 价格是由交易发起者设定的值,他必须预先向 EVM 执行者支付 gas_price * gas

如果执行后剩余一些gas,则将其退还给交易发起者。

如果发生恢复更改的异常,已用完的 gas 将不予退还。

由于 EVM 执行者可以选择是否包含交易,交易发送者不能通过设置低 gas 价格来滥用系统。

存储、内存和堆栈

以太坊虚拟机具有三个可以存储数据的区域:存储、内存和堆栈。

每个帐户都有一个称为存储的数据区域,它在函数调用和事务之间是持久的。

存储是将 256 位字映射到 256 位字的键值存储。无法从合约中枚举存储,读取成本相对较高,初始化和修改存储的成本更高。

由于这个成本,您应该将存储在持久存储中的内容最小化为合约需要运行的内容。在合约之外存储衍生计算、缓存和聚合等数据。

合约既不能读取也不能写入除了自己的存储之外的任何存储。

第二个数据区域称为内存,合约在每个消息调用中获取一个新清除的实例。

内存是线性的,可以在字节级别寻址,但读取的宽度限制为 256 位,而写入的宽度可以是 8 位或 256 位。

当访问(读取或写入)以前未触及的内存字(即字内的任何偏移量)时,内存会扩展一个字(256 位)。扩张时,必须支付gas费用。内存越大,它的成本就越高(它以二次方缩放)。

EVM 不是寄存器机,而是堆栈机,因此所有计算都在称为堆栈的数据区域上执行。它的最大大小为 1024 个元素并包含 256 位的字。

通过以下方式对堆栈的访问仅限于顶端:可以将最顶端的 16 个元素之一复制到堆栈的顶部,或者将最顶端的元素与它下面的 16 个元素之一交换。

所有其他操作从堆栈中获取最顶部的两个(或一个或多个,取决于操作)元素并将结果压入堆栈。

当然,可以将堆栈元素移动到存储或内存中,以便更深入地访问堆栈,但不可能在不首先移除堆栈顶部的情况下访问堆栈中更深的任意元素。

指令集(Instruction Set)

EVM 的指令集保持最小化,以避免可能导致共识问题的不正确或不一致的实现。

所有指令都对基本数据类型、256 位字或内存片(或其他字节数组)进行操作。

存在通常的算术、位、逻辑和比较操作。

有条件的和无条件的跳转是可能的。

此外,合约可以访问当前区块的相关属性,例如其编号和时间戳。

如需完整列表,请参阅作为内联汇编文档一部分的操作码列表

消息调用 Message Calls

合约可以通过消息调用的方式调用其他合约或向非合约账户发送以太币。

消息调用类似于事务,因为它们具有源、目标、数据有效负载、以太币、gas 和返回数据。实际上,每个事务都包含一个顶级消息调用,而该调用又可以创建进一步的消息调用。

合约可以决定应该通过内部消息调用发送多少剩余的gas,以及它想要保留多少。如果在内部调用(或任何其他异常)中发生气体不足异常,则会通过放入堆栈的错误值发出信号。在这种情况下,只有与调用一起发送的 gas 被用完。

在 Solidity 中,调用合约在这种情况下默认会导致手动异常,因此异常会“冒泡”调用堆栈。

如前所述,被调用的合约(可以与调用者相同)将接收一个新清除的内存实例,并可以访问调用有效负载 - 这将在称为 calldata 的单独区域中提供。

完成执行后,它可以返回数据,这些数据将存储在调用者预先分配的调用者内存中的某个位置。所有此类调用都是完全同步的。

调用被限制在 1024 的深度,这意味着对于更复杂的操作,循环应该优先于递归调用。

此外,在消息调用中只能转发 63/64 的气体,这导致实际深度限制略小于 1000。

Delegatecall / Callcode 和库

存在一个消息调用的特殊变体,名为 delegatecall,它与消息调用相同,除了目标地址的代码在调用合约和 msg.sender 的上下文(即地址)中执行之外 msg.value 不会改变它们的值。

这意味着合约可以在运行时从不同的地址动态加载代码。

存储、当前地址和余额仍然是调用合约,只是代码取自被调用地址。

这使得在 Solidity 中实现“库”功能成为可能:可重用的库代码,可应用于合约的存储,例如 为了实现复杂的数据结构。

日志

可以将数据存储在一个特殊索引的数据结构中,该结构一直映射到块级别。

Solidity 使用这个称为日志的特性来实现事件。

合约创建后无法访问日志数据,但可以从区块链外部有效访问。

由于部分日志数据存储在布隆过滤器中,因此可以以一种高效且加密安全的方式搜索这些数据,因此不下载整个区块链的网络对等方(所谓的“轻客户端”)仍然可以 找到这些日志。

创建

合约甚至可以使用特殊的操作码创建其他合约(即它们不会像交易那样简单地调用零地址)。

这些创建调用和普通消息调用之间的唯一区别是执行有效负载数据并将结果存储为代码,调用者/创建者接收堆栈上新合约的地址。

停用和自毁 Deactivate and Self-destruct

从区块链中删除代码的唯一方法是该地址的合约执行自毁操作。

存储在该地址的剩余以太币被发送到指定目标,然后从状态中删除存储和代码。

理论上移除合约听起来是个好主意,但它有潜在的危险,就像有人将以太币发送给移除的合约一样,以太币将永远丢失。

  • 警告

即使合约被 selfdestruct 删除,它仍然是区块链历史的一部分,并且可能被大多数以太坊节点保留。

所以使用 selfdestruct 和从硬盘中删除数据是不一样的。

  • NOTE

即使合约的代码不包含对 selfdestruct 的调用,它仍然可以使用 delegatecall 或 callcode 执行该操作。

如果你想停用你的合约,你应该通过改变一些导致所有功能恢复的内部状态来禁用它们。

这使得无法使用合约,因为它会立即返回 Ether。

预编译合约 Precompiled Contracts

有一小部分特别的合约地址:1 到(包括)8 之间的地址范围包含“预编译合约”,可以像任何其他合约一样调用,但它们的行为(以及它们的 gas 消耗)不是由 EVM 代码定义的 存储在该地址(它们不包含代码),而是在 EVM 执行环境本身中实现。

不同的 EVM 兼容链可能使用不同的预编译合约集。

未来也有可能将新的预编译合约添加到以太坊主链中,但您可以合理地期望它们始终在 1 到 0xffff(含)之间的范围内。

参考资料

https://docs.soliditylang.org/en/latest/introduction-to-smart-contracts.html#index-6