深入以太坊虚拟机,查看了 EVM 如何执行字节码。研究了 Gas,EVM 的记账机制。
最经典介绍 EVM 的文章,原文链接:https://github.com/ethereumbook/ethereumbook/blob/develop/13evm.asciidoc 原文翻译如下:
在以太坊协议和操作的核心是以太坊虚拟机,简称 EVM。从名称上可以猜到,它是一个计算引擎,与微软的.NET Framework 的虚拟机或其他字节码编译的编程语言的解释器并没有太大的不同。在本章中,我们将详细了解 EVM,包括其指令集、结构和操作,以及在以太坊状态更新的背景下。
EVM 是处理智能合约部署和执行的以太坊的一部分。从实际上讲,简单的从一个 EOA 向另一个 EOA 的价值转移交易不需要涉及它,但其他所有操作都将涉及由 EVM 计算的状态更新。在高层次上,运行在以太坊区块链上的 EVM 可以被看作是一个全局分布的计算机,包含数百万个可执行对象,每个对象都有自己的永久数据存储。
EVM 是一个准图灵完备的状态机;“准”是因为所有执行过程都受到给定智能合约执行的 gas 数量限制的计算步骤的限制。因此,停机问题被“解决”(所有程序执行都将停止),并且避免了执行可能(意外地或恶意地)永远运行的情况,从而使以太坊平台完全停止。
EVM 具有基于堆栈的架构,将所有内存值存储在堆栈上。它使用 256 位的字长(主要是为了便于原生哈希和椭圆曲线运算),并具有几个可寻址的数据组件:
在执行过程中还有一组环境变量和数据可用。我们将在本章后面更详细地介绍这些内容。
下面 EVM 架构图 展示了 EVM 的架构和执行上下文。
以太坊虚拟机(EVM)架构和执行上下文
术语“虚拟机”通常用于实际计算机的虚拟化,通常由“hypervisor”(如 VirtualBox 或 QEMU)实现,或者整个操作系统实例的虚拟化,例如 Linux 的 KVM。这些必须提供软件抽象,分别是实际硬件的抽象,以及系统调用和其他内核功能的抽象。
EVM 在一个更有限的领域中运行:它只是一个计算引擎,因此提供了计算和存储的抽象,类似于 Java 虚拟机(JVM)规范。从高层次的观点来看,JVM 旨在提供一个运行时环境,它对底层主机操作系统或硬件是不可知的,从而实现跨各种系统的兼容性。高级编程语言(如使用 JVM 的 Java 或 Scala,或使用.NET 的 C#)被编译成各自虚拟机的字节码指令集。同样,EVM 执行其自己的字节码指令集(在下一节中描述),而高级智能合约编程语言(如 LLL、Serpent、Mutan 或 Solidity)被编译成这个指令集。
因此,EVM 没有调度能力,因为执行顺序是在外部组织的——以太坊客户端通过验证的区块交易来确定需要执行哪些智能合约以及执行顺序。从这个意义上说,以太坊世界计算机是单线程的,就像 JavaScript 一样。EVM 也没有任何“系统接口”处理或“硬件支持”——没有物理机器可以进行接口。以太坊世界计算机是完全虚拟的。
EVM 指令集提供了大多数你可能期望的操作,包括:
除了典型的字节码操作外,EVM 还可以访问帐户信息(例如地址和余额)和区块信息(例如区块号和当前 gas 价格)。
让我们通过查看可用操作码及其功能来更详细地探索 EVM。正如你所期望的,所有操作数都来自堆栈,并且结果(如果适用)通常会被放回堆栈顶部。
你可以在 evm_opcodes 中找到完整的操作码列表及其对应的 gas 成本。
可用的操作码可以分为以下类别:
算术操作码: 算术操作码指令:
ADD //将堆栈顶部的两个项相加
MUL //将堆栈顶部的两个项相乘
SUB //减去堆栈顶部的两个项
DIV //整数除法
SDIV //有符号整数除法
MOD //取模(余数)操作
SMOD //有符号取模操作
ADDMOD //对任意数字进行加法取模
MULMOD //对任意数字进行乘法取模
EXP //指数运算
SIGNEXTEND //扩展二进制补码有符号整数的长度
SHA3 //计算内存块的 Keccak-256 哈希
请注意,所有算术操作都是模 $$2^{256}$$(除非另有说明),并且零的零次幂,$$0^0$$,被视为 1。
堆栈操作码 堆栈、内存和存储管理指令:
POP //从堆栈中移除顶部项
MLOAD //从内存中加载一个字
MSTORE //将一个字保存到内存中
MSTORE8 //将一个字节保存到内存中
SLOAD //从存储中加载一个字
SSTORE //将一个字保存到存储中
MSIZE //获取活动内存的大小(以字节为单位)
PUSHx //将 x 字节项目放入堆栈,其中 x 可以是从 1 到 32(完整字)的任何整数
DUPx //复制第 x 个堆栈项,其中 x 可以是从 1 到 16 的任何整数
SWAPx //交换第 1 个和第(x +1)个堆栈项,其中 x 可以是从 1 到 16 的任何整数
流程控制操作码 控制流指令:
STOP //停止执行
JUMP //将程序计数器设置为任何值
JUMPI //有条件地更改程序计数器
PC //获取程序计数器的值(在增量之前对应于此指令)
JUMPDEST //标记跳转的有效目的地
系统操作码 用于执行程序的系统操作码:
LOGx //附加具有 x 个主题的日志记录,其中 x 是从 0 到 4 的任何整数
CREATE //创建一个带有关联代码的新帐户
CALL //消息调用到另一个帐户,即运行另一个帐户的代码
CALLCODE //消息调用到此帐户与另一个帐户的代码
RETURN //停止执行并返回输出数据
DELEGATECALL //使用替代帐户的代码向此帐户发送消息调用,但保留当前发送者和价值的值
STATICCALL //静态消息调用到一个帐户
REVERT //停止执行,恢复状态更改但返回数据和剩余 gas
INVALID //指定的无效指令
SELFDESTRUCT //停止执行并注册帐户以进行删除逻辑操作:用于比较和位逻辑的操作码:
逻辑运算码 比较和位逻辑的操作码:
LT //小于比较
GT //大于比较
SLT //有符号小于比较
SGT //有符号大于比较
EQ //相等比较
ISZERO //简单的非运算
AND //按位与操作
OR //按位或操作
XOR //按位异或操作
NOT //按位非操作
BYTE //从完整的 256 位宽字中检索单个字节
环境操作码 :处理执行环境信息的操作码:
GAS //获取可用 gas 的数量(在减少此指令的 gas 后)
ADDRESS //获取当前执行账户的地址
BALANCE //获取任何给定账户的账户余额
ORIGIN //获取启动此 EVM 执行的 EOA 的地址
CALLER //获取立即负责此执行的调用者的地址
CALLVALUE //获取由负责此执行的调用者存入的以太币金额
CALLDATALOAD //获取由负责此执行的调用者发送的输入数据
CALLDATASIZE //获取输入数据的大小
CALLDATACOPY //将输入数据复制到内存
CODESIZE //获取当前环境中运行的代码大小
CODECOPY //将当前环境中运行的代码复制到内存
GASPRICE //获取由发起交易指定的 gas 价格
EXTCODESIZE //获取任何账户的代码大小
EXTCODECOPY //将任何账户的代码复制到内存
RETURNDATASIZE //获取当前环境中上一次调用的输出数据大小
RETURNDATACOPY //将上一次调用的数据输出复制到内存
区块操作码 用于访问当前区块信息的操作码:
BLOCKHASH //获取最近完成的 256 个区块之一的哈希
COINBASE //获取区块奖励的区块受益人地址
TIMESTAMP //获取区块的时间戳
NUMBER //获取区块的编号
DIFFICULTY //获取区块的难度
GASLIMIT //获取区块的 gas 限制
EVM 的工作是通过计算智能合约代码执行的有效状态转换来更新以太坊状态,这是由以太坊协议定义的。这一方面导致了以太坊被描述为“基于交易的状态机”,这反映了外部参与者(即账户持有者和矿工)通过创建、接受和排序交易来启动状态转换。此时考虑构成以太坊状态的内容是很有用的。
在顶层,我们有以太坊“世界状态”。世界状态是以太坊地址(160 位值)到“账户”映射的集合。在较低级别,每个以太坊地址代表一个账户,包括以太余额(以 wei 数量存储,由账户拥有)、一个 nonce(表示成功从该账户发送的交易数量,如果是 EOA,或者由它创建的合约数量,如果是合约账户)、账户的存储(这是一个永久数据存储,仅由智能合约使用)、以及账户的程序代码(如果账户是智能合约账户的话)。EOA 将始终没有代码和存储为空。
当交易导致智能合约代码执行时,会实例化一个 EVM,其中包含有关正在创建的当前区块和正在处理的特定交易的所有所需信息。特别是,EVM 的程序代码 ROM 装载了被调用的合约账户的代码,程序计数器设置为零,存储从合约账户的存储中加载,内存设置为全零,并设置所有块和环境变量。一个关键变量是此执行的 gas 供应,它设置为发送方在交易开始时支付的 gas 量(有关更多详细信息,请参阅 gas)。随着代码执行的进行,gas 供应会根据执行的操作的 gas 成本而减少。如果在任何时候 gas 供应减少到零,我们会得到一个“燃尽 Gas”(OOG)异常;执行立即停止,交易被放弃。除了发送方的 nonce 被递增和他们的以太余额减少以支付执行代码到停止点所使用的资源的区块受益人之外,不会应用对以太坊状态的任何更改。此时,你可以将 EVM 视为在以太坊世界状态的沙盒副本上运行,如果执行无法完成,则此沙盒版本将被完全丢弃。但是,如果执行成功完成,则真实世界状态将更新以匹配沙盒版本,包括对被调用合约的存储数据的任何更改、任何新创建的合约以及任何启动的以太余额转移。
请注意,由于智能合约本身可以有效地启动交易,代码执行是一个递归过程。合约可以调用其他合约,每次调用都会导致在调用目标周围实例化另一个 EVM。每个实例化都将其沙盒世界状态从上一级 EVM 的沙盒初始化。每个实例化还将为其 gas 供应分配指定数量的 gas(当然不超过上一级剩余的 gas 量),因此可能由于给予的 gas 过少而自行停止并出现异常。同样,在这种情况下,沙盒状态将被丢弃,执行将返回到上一级的 EVM。
将 Solidity 源文件编译为 EVM 字节码可以通过几种方法实现。我们可以使用在线 Remix 编译器。在本章中,我们将在命令行中使用 solc
可执行文件。要获取选项列表,请运行以下命令:
$ solc --help
通过 --opcodes
命令行选项轻松生成 Solidity 源文件的原始操作码流。此操作码流省略了一些信息(--asm
选项生成完整信息),但对于本讨论而言已足够。例如,编译一个名为 Example.sol
的示例 Solidity 文件,并将操作码输出发送到名为 BytecodeDir
的目录,可以使用以下命令完成:
solc -o BytecodeDir --opcodes Example.sol
或:
solc -o BytecodeDir --asm Example.sol
以下命令将为我们的示例程序生成字节码二进制文件:
solc -o BytecodeDir --bin Example.sol
生成的操作码文件取决于 Solidity 源文件中包含的具体合约。我们简单的 Solidity 文件 Example.sol 只有一个名为的合约
pragma solidity ^0.4.19;
contract example {
address contractOwner;
function example() {
contractOwner = msg.sender;
}
}
如你所见,这个合约只是保存一个持久状态变量,该变量设置为运行此合约的最后一个账户的地址。如果你查看 BytecodeDir
目录,你将看到名为 example.opcode
的操作码文件,其中包含 example
合约的 EVM 操作码指令。在文本编辑器中打开 example.opcode
文件将显示以下内容:
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1
REVERT JUMPDEST CALLER PUSH1 0x0 DUP1 PUSH2 0x100 EXP DUP2 SLOAD DUP2 PUSH20
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF MUL NOT AND SWAP1 DUP4 PUSH20
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF AND MUL OR SWAP1 SSTORE POP PUSH1
0x35 DUP1 PUSH1 0x5B PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1
0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 JUMP
0xb9 SWAP14 0xcb 0x1e 0xdd RETURNDATACOPY 0xec 0xe0 0x1f 0x27 0xc9 PUSH5
0x9C5ABCC14A NUMBER 0x5e INVALID EXTCODESIZE 0xdb 0xcf EXTCODESIZE 0x27
EXTCODESIZE 0xe2 0xb8 SWAP10 0xed 0x
使用 --asm
选项编译示例会在我们的 BytecodeDir 目录中生成名为 example.evm 的文件。其中包含 EVM 字节码指令的稍微高级描述,以及一些有用的注释:
/* "Example.sol":26:132 contract example {... */
mstore(0x40, 0x60)
/* "Example.sol":74:130 function example() {... */
jumpi(tag_1, iszero(callvalue))
0x0
dup1
revert
tag_1:
/* "Example.sol":115:125 msg.sender */
caller
/* "Example.sol":99:112 contractOwner */
0x0
dup1
/* "Example.sol":99:125 contractOwner = msg.sender */
0x100
exp
dup2
sload
dup2
0xffffffffffffffffffffffffffffffffffffffff
mul
not
and
swap1
dup4
0xffffffffffffffffffffffffffffffffffffffff
and
mul
or
swap1
sstore
pop
/* "Example.sol":26:132 contract example {... */
dataSize(sub_0)
dup1
dataOffset(sub_0)
0x0
codecopy
0x0
return
stop
sub_0: assembly {
/* "Example.sol":26:132 contract example {... */
mstore(0x40, 0x60)
0x0
dup1
revert
auxdata: 0xa165627a7a7230582056b99dcb1edd3eece01f27c9649c5abcc14a435efe3b...
}
使用 --bin-runtime
选项会生成可读的十六进制字节码:
60606040523415600e57600080fd5b336000806101000a81548173
ffffffffffffffffffffffffffffffffffffffff
021916908373
ffffffffffffffffffffffffffffffffffffffff
160217905550603580605b6000396000f3006060604052600080fd00a165627a7a7230582056b...
你可以使用前面提供的操作码列表详细了解这里发生的情况。但这是一项相当繁重的任务,所以让我们从检查前四条指令开始:
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE
这里有 PUSH1
后跟着值为 0x60
的原始字节。这个 EVM 指令将程序代码中操作码后的单个字节(作为文字值)推送到堆栈上。可以将大小最多为 32 字节的值推送到堆栈上,如下所示:
PUSH32 0x436f6e67726174756c6174696f6e732120536f6f6e20746f206d617374657221
来自 example.opcode
的第二个 PUSH1
操作码将 0x40
存储在堆栈顶部(将已经存在的 0x60
推到下一个Slot)。
接下来是 MSTORE
,这是一个将值保存到 EVM 内存的内存存储操作。它接受两个参数,并像大多数 EVM 操作一样,从堆栈中获取它们。对于 MSTORE
的每个参数,堆栈都会被移除,即从堆栈中取出顶部值,并将堆栈上的所有其他值向上移动一个位置。MSTORE
的第一个参数是要保存值的内存中的字地址。对于此程序,我们在堆栈顶部有 0x40
,因此将其从堆栈中移除并用作内存地址。第二个参数是要保存的值,在这里是 0x60
。执行 MSTORE
操作后,我们的堆栈再次为空,但是我们在内存位置 0x40
处有值 0x60
(十进制为 96
)。
接下来的操作码是 CALLVALUE
,这是一个环境操作码,将发起此执行的消息调用中发送的以 wei 为单位的以太币数量(以 wei 为单位)推送到堆栈顶部。
我们可以继续以这种方式逐步执行此程序,直到完全了解此代码产生的低级状态更改,但在这个阶段这并不会帮助我们。我们将在本章后面再回头看这个问题。
在创建和部署以太坊平台上的新合约时,使用的代码与合约本身的代码之间存在重要但微妙的区别。为了创建新合约,需要一个特殊的交易,其中 to
字段设置为特殊的 0x0
地址,data
字段设置为合约的 初始化代码。当处理此类合约创建交易时,新合约账户的代码 不是 交易的 data
字段中的代码。相反,会实例化一个 EVM,并将加载到其程序代码 ROM 中的交易 data
字段中的代码作为新合约账户的代码。这样可以使用部署时的以太坊世界状态对新合约进行程序化初始化,设置合约存储中的值,甚至发送以太币或创建进一步的新合约。
在离线编译合约时,例如使用命令行上的 solc
,可以获取 部署字节码 或 运行时字节码 。
"部署字节码" - 部署字节码用于新合约账户的所有初始化,包括实际执行交易调用此新合约时将执行的字节码(即运行时字节码)以及基于合约构造函数初始化一切的代码。
"运行时字节码" 是在调用新合约时实际执行的字节码,没有其他内容;它不包括在部署期间初始化合约所需的字节码。
让我们以我们之前创建的简单 Faucet.sol 合约为例:
// Solidity 编译器版本
pragma solidity ^0.4.19;
// 我们的第一个合约是一个水龙头!
contract Faucet {
// 给任何请求的人提供以太币
function withdraw(uint withdraw_amount) public {
// 限制提款金额
require(withdraw_amount <= 100000000000000000);
// 将金额发送到请求它的地址
msg.sender.transfer(withdraw_amount);
}
// 接受任何传入金额
function () external payable {}
}
要获取部署字节码,我们将运行 solc --bin Faucet.sol
。如果我们想要只获取运行时字节码,我们将运行 solc --bin-runtime Faucet.sol
。
如果你比较这些命令的输出,你将看到运行时字节码是部署字节码的子集。换句话说,运行时字节码完全包含在部署字节码中。
反汇编 EVM 字节码是了解高级 Solidity 在 EVM 中的作用的好方法。你可以使用几个反汇编器来执行此操作:
解析 Faucet 运行时字节码
当你向一个 ABI 兼容的智能合约发送交易时(你可以假设所有合约都是兼容的),该交易首先会与该智能合约的 dispatcher
进行交互。调度程序读取交易的 data
字段,并将相关部分发送到适当的函数。我们可以在我们反汇编的 Faucet.sol
运行时字节码的开头看到一个调度程序的示例。在熟悉的 MSTORE
指令之后,我们看到以下指令:
PUSH1 0x4
CALLDATASIZE
LT
PUSH1 0x3f
JUMPI
正如我们所见,PUSH1 0x4
将 0x4
放入栈的顶部,栈在其他情况下为空。CALLDATASIZE
获取与交易一起发送的数据(称为 calldata)的字节数,并将该数字推送到栈上。执行这些操作后,栈如下所示:
栈 |
---|
<来自交易的 calldata 长度> |
0x4 |
接下来的指令是 LT
,即“小于”。LT
指令检查栈顶项是否小于栈中的下一个项。在我们的情况下,它检查 CALLDATASIZE
的结果是否小于 4 字节。
为什么 EVM 要检查交易的 calldata 至少为 4 字节?这与函数标识符的工作方式有关。每个函数由其 Keccak-256 哈希的前 4 个字节标识。通过将函数的名称和其参数放入 keccak256
哈希函数中,我们可以推断出其函数标识符。在我们的例子中,我们有:
keccak256("withdraw(uint256)") = 0x2e1a7d4d...
因此,withdraw(uint256)
函数的函数标识符是 0x2e1a7d4d
,因为这些是结果哈希的前 4 个字节。函数标识符始终为 4 个字节长,因此,如果发送到合约的交易的整个 data
字段小于 4 个字节,则交易可能无法与任何函数通信,除非定义了 fallback 函数。因为我们在 Faucet.sol 中实现了这样一个回退函数,所以当 calldata 的长度小于 4 个字节时,EVM 会跳转到此函数。
LT
弹出栈顶的两个值,如果交易的 data
字段小于 4 个字节,则将 1
推送到栈上。否则,它推送 0
。在我们的示例中,假设发送到我们合约的交易的 data
字段确实少于 4 个字节。
PUSH1 0x3f
指令将字节 0x3f
推送到栈上。执行此指令后,栈如下所示:
栈 |
---|
0x3f |
1 |
接下来的指令是 JUMPI
,表示“如果跳转”。它的工作方式如下:
jumpi(label, cond) // 如果 "cond" 为真,则跳转到 "label"
在我们的情况下,label
是 0x3f
,这是我们智能合约中回退函数的位置。cond
参数是 1
,这是之前 LT
指令的结果。简而言之,如果交易数据少于 4 个字节,合约将跳转到回退函数。
在 0x3f
处,只有一个 STOP
指令,因为尽管我们声明了一个回退函数,但我们将其保留为空。正如你在 <<Faucet_jumpi_instruction>> 中所看到的,如果我们没有实现回退函数,合约将抛出异常。
JUMPI 指令导致跳转到回退函数
让我们检查调度程序的中央块。假设我们收到的 calldata 长度 大于 4 个字节,JUMPI
指令将不会跳转到回退函数。相反,代码执行将继续执行以下指令:
PUSH1 0x0
CALLDATALOAD
PUSH29 0x1000000...
SWAP1
DIV
PUSH4 0xffffffff
AND
DUP1
PUSH4 0x2e1a7d4d
EQ
PUSH1 0x41
JUMPI
PUSH1 0x0
将 0
推送到栈上,栈现在再次为空。CALLDATALOAD
接受一个在发送到智能合约的 calldata 中的索引,并从该索引读取 32 字节,如下所示:
calldataload(p) // 从字节位置 p 开始加载 32 字节的 calldata
由于从 PUSH1 0x0
命令传递给它的索引是 0
,CALLDATALOAD
从字节 0 开始读取 32 字节的 calldata,然后将其推送到栈顶(在弹出原始 0x0
之后)。在 PUSH29 0x1000000
... 指令之后,栈如下:
栈 |
---|
0x1000000…(长度为 29 字节) |
<从字节 0 开始的 32 字节 calldata> |
SWAP1
将栈顶元素与其后的第 i 个元素交换。在这种情况下,它将 0x1000000
... 与 calldata 交换。新栈如下:
栈 |
---|
<从字节 0 开始的 32 字节 calldata> |
0x1000000…(长度为 29 字节) |
接下来的指令是 DIV
,其工作方式如下:
div(x, y) // 整数除法 x / y
在这种情况下,x
= 从字节 0 开始的 32 字节 calldata,y
= 0x100000000
...(总共 29 个字节)。你能想到调度程序为什么要进行除法吗?这里有一个提示:我们之前从 calldata 中读取了从索引 0 开始的 32 字节。该 calldata 的前 4 个字节是函数标识符。
我们之前推送的 0x100000000
... 是 29 个字节长,以 1
开头,后跟所有 0
。将我们的 32 字节 calldata 除以这个值将使我们仅保留 calldata 载入的最顶部 4 个字节,从索引 0 开始。这 4 个字节——从索引 0 开始的 calldata 中的前 4 个字节——是函数标识符,这就是 EVM 提取该字段的方式。
如果这部分对你不清楚,请这样考虑:在十进制中,1234000 / 1000 = 1234。在十六进制中,这没有区别。每个位置不是 10 的倍数,而是 16 的倍数。就像在我们的较小示例中除以 10^3^(1000)保留了最顶部的数字一样,将我们的 32 字节十六进制值除以 16^29^ 也是如此。
DIV
的结果(函数标识符)被推送到栈上,我们的栈现在是:
栈 |
---|
<发送的数据中的函数标识符> |
由于 PUSH4 0xffffffff 和 AND 指令是多余的,我们可以完全忽略它们,因为在执行完它们后,栈将保持不变。DUP1 指令复制栈上的第一个项目,即函数标识符。接下来的指令 PUSH4 0x2e1a7d4d 将函数的预计算函数标识符推送到栈上。栈现在是: |
栈 |
---|---|
0x2e1a7d4d |
|
<在data 中发送的函数标识符> |
|
<在data 中发送的函数标识符> |
下一条指令是EQ
,它弹出栈顶的两个项并进行比较。这是调度程序的主要任务:它比较交易的msg.data
字段中发送的函数标识符是否与withdraw(uint256)
的函数标识符匹配。如果它们相等,EQ
将1
推送到栈上,最终将用于跳转到 withdraw 函数。否则,EQ
将0
推送到栈上。
假设发送到我们合约的交易确实以withdraw(uint256)
的函数标识符开头,我们的栈变成了:
栈 |
---|
1 |
<在data 中发送的函数标识符> (现在已知为 0x2e1a7d4d) |
接下来是PUSH1 0x41
,这是withdraw(uint256)
函数在合约中的地址。执行此指令后,栈如下所示:
栈 |
---|
0x41 |
1 |
在 msg.data 中发送的函数标识符 |
接下来是JUMPI
指令,它再次将栈顶的两个元素作为参数。在这种情况下,我们有jumpi(0x41, 1)
,这告诉 EVM 执行跳转到withdraw(uint256)
函数的位置,并且该函数的代码执行可以继续。
正如我们已经提到的,简单来说,如果一个系统或编程语言可以运行任何程序,则称其为图灵完备。然而,这种能力伴随着一个非常重要的警告:有些程序需要永远运行。这个问题的一个重要方面是,我们无法仅通过查看程序就知道它是否需要永远执行。我们必须实际执行程序并等待其完成才能找出。"停机问题"当然,如果执行需要永远运行,我们将不得不永远等待才能找出。这被称为 停机问题,如果不加以解决,将对以太坊构成巨大问题。
由于停机问题,以太坊世界计算机有可能被要求执行永不停止的程序。这可能是出于意外或恶意。我们已经讨论过,以太坊就像是一个单线程的机器,没有任何调度程序,因此如果陷入无限循环,这将意味着它将变得不可用。
然而,通过 Gas,有了一个解决方案:如果在执行了预定的最大计算量之后,执行没有结束,那么 EVM 将停止程序的执行。这使得 EVM 成为一个准图灵完备的机器:它可以运行你输入的任何程序,但前提是程序在特定的计算量内终止。这个限制在以太坊中并不是固定的—你可以支付以增加它直到达到最大值(称为“区块 gas 限制”),每个人都可以同意随着时间的推移增加该最大值。然而,在任何时候,都有一个限制,执行时消耗太多 gas 的交易将被停止执行。
在接下来的部分中,我们将深入研究 gas 并详细探讨其工作原理。
Gas 是以太坊用于衡量在以太坊区块链上执行操作所需的计算和存储资源的单位。与比特币不同,比特币的交易费仅考虑交易的大小(以千字节为单位),以太坊必须考虑交易和智能合约代码执行中执行的每个计算步骤。
交易或合约执行的每个操作都需要固定数量的 gas。以下是来自以太坊黄皮书的一些示例:
Gas 是以太坊的一个关键组成部分,发挥着双重作用:作为以太坊价格(波动性)和矿工为其工作获得的奖励之间的缓冲,以及防御拒绝服务攻击。为了防止网络中的意外或恶意无限循环或其他计算浪费,每个交易的发起者都必须为他们愿意支付的计算量设置一个限制。因此,gas 系统使攻击者不愿发送“垃圾邮件”交易,因为他们必须按比例支付他们消耗的计算、带宽和存储资源。
当需要完成一个交易时,首先将给予 EVM 的 gas 供应量设置为交易中的 gas 限制。每个执行的操作都有一个 gas 成本,因此随着 EVM 在程序中执行步骤,其 gas 供应量会减少。在每个操作之前,EVM 都会检查是否有足够的 gas 来支付操作的执行。如果没有足够的 gas,执行将被停止,交易将被回滚。
如果 EVM 成功执行到最后,而没有耗尽 gas,那么使用的 gas 成本将作为交易费用支付给矿工,根据交易中指定的 gas 价格转换为以太币:
在 EIP1559 升级后,Gas price 分为了两部分:基础费和小费。
矿工费用 = gas 成本 * gas 价格
剩余 gas 供应中的 gas 将退还给发送者,再次根据交易中指定的 gas 价格转换为以太币:
剩余 gas = gas 限制 - gas 成本
退还的以太币 = 剩余 gas * gas 价格
如果在执行过程中交易“耗尽了 gas”,操作将立即终止,引发“gas 不足”异常。交易将被回滚,所有对状态的更改都将被撤销。
尽管交易未成功,但发送者将被收取交易费,因为矿工已经执行了到那一点的计算工作,必须得到补偿。
精心选择了 EVM 可以执行的各种操作的相对 gas 成本,以最好地保护以太坊区块链免受攻击。你可以在evm opcodes 表中查看不同 EVM 操作码的 gas 成本的详细表格。
更具计算密集性的操作成本更高。例如,执行SHA3
函数的成本比ADD
操作(3 gas)昂贵 10 倍(30 gas)。更重要的是,一些操作,如EXP
,需要根据操作数的大小额外支付。在 EVM 内存中使用和在合约的链上存储数据也需要 gas 成本。
在 2016 年,当攻击者发现并利用成本不匹配时,资源成本与实际资源成本匹配的重要性得到了证明。该攻击生成的交易非常消耗计算资源,使以太坊主网几乎陷入停滞。这种不匹配通过硬分叉(代号“Tangerine Whistle”)解决了,该分叉调整了相对 gas 成本。
在以太坊虚拟机(EVM)中,gas cost 是衡量计算和存储使用量的指标(数量单位),gas price 也有以太币衡量的 价格。在执行交易时,发送方指定他们愿意为每单位 gas 支付的 gas 价格(以太币),从而让市场决定以太币价格和计算操作成本(以 gas price)之间的关系:
交易费用 = 总 gas 使用量 * 支付的 gas 价格(以太币)
在构建新区块时,以太坊网络上的矿工可以从待处理的交易中选择,选择那些愿意支付更高 gas 价格的交易。提供更高的 gas 价格将激励矿工包含你的交易并更快确认。
实际操作中,交易发送方将设置一个高于或等于预期使用 gas 量的 gas 限制。如果 gas 限制高于实际消耗的 gas 量,发送方将收到多余金额的退款,因为矿工只会获得他们实际执行的工作的补偿。
清楚区分 gas cost 和 gas price 是很重要的。回顾一下:
Gas cost 是执行特定操作所需的 gas 单位数。
Gas price 是你在将交易发送到以太坊网络时愿意支付的每单位 gas 的以太币金额。
提示: 虽然 gas 有价格,但它不能被“拥有”或“花费”。Gas 只存在于 EVM 内部,作为正在执行的计算工作量的计数。发送方将以太币支付交易费用,然后将其转换为 gas 以进行 EVM 记账,最终再转换回以太币支付给矿工作为交易费用。
负 gas 成本
以太坊鼓励通过退还在合约执行期间使用的一部分 gas 来删除已使用的存储变量和账户。
译者备注:以太坊已经 不在鼓励 gas 退还。
EVM 中有两个具有负 gas 成本的操作:
SELFDESTRUCT
)值得退还 24,000 gas。SSTORE[x] = 0
)值得退还 15,000 gas。为避免滥用退款机制,交易的最大退款额被设定为总 gas 使用量的一半(向下取整)。
区块 gas 限制是区块中所有交易可能消耗的最大 gas 量,限制了可以容纳多少交易进入一个区块。
例如,假设我们有 5 笔交易,它们的 gas 限制分别设置为 30,000、30,000、40,000、50,000 和 50,000。如果区块 gas 限制为 180,000,那么这些交易中的任意四笔可以容纳在一个区块中,而第五笔将需要等待未来的区块。正如之前讨论的,矿工决定哪些交易包含在一个区块中。不同的矿工可能选择不同的组合,主要是因为他们以不同的顺序从网络接收交易。
如果矿工试图包含一个需要的 gas 超过当前区块 gas 限制的交易,该区块将被网络拒绝。大多数以太坊客户端会通过类似“交易超出区块 gas 限制”的警告来阻止你发出这样的交易。根据 https://etherscan.io ,以太坊主网的区块 gas 限制在撰写本文时为 8 百万 gas,这意味着大约可以容纳 380 笔基本交易(每笔消耗 21,000 gas)进入一个区块。
本文翻译时:区块限制为 3千万 gas
谁决定区块 gas 限制是多少?
网络上的矿工共同决定区块 gas 限制。想要在以太坊网络上进行挖矿的个人使用挖矿程序,如 Ethminer,它连接到 Geth 或 Parity 以太坊客户端。以太坊协议内置了一个机制,矿工可以投票决定 gas 限制,以便在随后的区块中增加或减少容量。一个区块的矿工可以通过 1/1,024(0.0976%)的因子来调整区块 gas 限制。其结果是一个根据网络需求调整的可调整区块大小。这一机制与默认的挖矿策略相结合,矿工投票决定至少为 4.7 百万 gas 的 gas 限制,但目标是最近每个区块总 gas 使用量的 150%(使用 1,024 个区块的指数移动平均值)。
在本章中,我们探讨了以太坊虚拟机,追踪了各种智能合约的执行,并查看了 EVM 如何执行字节码。我们还研究了 Gas,EVM 的记账机制,并看到它如何解决停机问题并保护以太坊免受拒绝服务攻击。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!