EVM_Opcodes(1)
源文章:https://www.wtf.academy/evm-opcodes-101/
Hello Opcodes
Opcodes 简介
Opcodes(操作码) 是以太坊智能合约的基本单元。大家写的 Solidity 智能合约会被编译为字节码(bytecode),然后才能在 EVM(以太坊虚拟机)上运行。而字节码就是由一系列 Opcodes 组成的。当用户在 EVM 调用这个智能合约的函数时,EVM 会解析并执行这些 Opcodes。实现合约逻辑。
首先先看几个常见的 Opcodes:
PUSH1
:将一个字节的数据压入堆栈。如:PUSH1 0x60
就是将 0x60 压入堆栈DUP1
:复制堆栈顶部的一个元素SWAP1
:交换堆栈顶部的前两个元素
EVM 基础
Opcodes 直接操作 EVM 的资源,比如堆栈、内存、存储,因此了解 EVM 基础很重要。
类似于 Java 的 JVM,以太坊智能合约的运行时环境就是 EVM。EVM 的基本架构主要包括堆栈,内存,存储,EVM字节码和 Gas 费用。
堆栈 Stack
EVM 是基于堆栈的,这意味着他处理数据的方式是使用堆栈数据结构进行大多数计算的。后进先出(LIFO),高效、简介。
在堆栈中,每个元素长度为 256 位(32字节),最大深度为 1024 元素。但是每个操作只能操作堆栈顶的 16 个元素。这也是为什么有时候 Solidity 会报Stack too deep
错误。
内存 Memory
堆栈虽然计算高效,但存储能力有限,因此EVM 使用内存来支持交易执行期间的数据存储和读取。EVM 的内存是一个线性寻址存储器,可以理解为一个动态字节数组,可以根据需要动态拓展。它支持以 8 或 256 bit 写入(MSTORE8
/MSTORE
),但只支持以 256 bit 读取(MLOAD
)。
需要注意,EVM 的内存是“易失性”的:交易开始时,所有内存设置的值均为 0;交易执行期间,值被更新;交易结束时,内存中的所有数据都会被清除,不会持久化。如果需要永久保存数据,就需要使用 EVM 的存储。
存储 Storage
EVM的账户存储(Account Storage)是一种映射(mapping,键值对存储),每个键和值都是256 bit的数据,它支持256 bit的读和写。这种存储在每个合约账户上都存在,并且是持久的,它的数据会保持在区块链上,直到被明确地修改。
对存储的读取(SLOAD
)和写入(SSTORE
)都需要 gas,并且比内存操作更昂贵。这样设计可以防止滥用存储资源,因为所有的存储数据都需要在每个以太坊节点上保存。
EVM 字节码
我们之前提到,Solidity智能合约会被编译为EVM字节码,然后才能在EVM上运行。这个字节码是由一系列的Opcodes 组成的,通常表现为一串十六进制的数字。EVM字节码在执行的时候,会按照顺序一个一个地读取并执行每个Opcode。
例如,字节码6001600101
可以被解码为:
1 | PUSH1 0x01 |
这段Opcodes的含义是将两个1相加,得到结果2。
Gas
Gas是以太坊中执行交易和运行合约的”燃料”。每个交易或合约调用都需要消耗一定数量的Gas,这个数量取决于它们进行的计算的复杂性和数据存储的大小。
EVM上每笔交易的gas是如何计算的呢?其实是通过opcodes。以太坊规定了每个opcode的gas消耗,复杂度越高的opcodes消耗越多的gas,比如:
ADD
操作消耗3 gasSSTORE
操作消耗20000 gasSLOAD
操作消耗200 Gas
一笔交易的gas消耗等于其中所有opcodes的gas成本总和。当你调用一个合约函数时,你需要预估这个函数执行所需要的Gas,并在交易中提供足够的Gas。如果提供的Gas不够,那么函数执行会在中途停止,已经消耗的Gas不会退回。
执行模型
最后,咱们串联一下以上的内容,介绍EVM的执行模型。它可以概括为以下步骤:
- 当一个交易被接收并准备执行时,以太坊会初始化一个新的执行环境并加载合约的字节码。
- 字节码被翻译成Opcode,被逐一执行。每个Opcodes代表一种操作,比如算术运算、逻辑运算、存储操作或者跳转到其他操作码。
- 每执行一个Opcodes,都要消耗一定数量的Gas。如果Gas耗尽或者执行出错,执行就会立即停止,所有的状态改变(除了已经消耗的Gas)都会被回滚。
- 执行完成后,交易的结果会被记录在区块链上,包括Gas的消耗、交易日志等信息。
Opcodes 分类
Opcodes 可以根据功能分为以下几类:
- 堆栈(stack)指令:这些指令直接操作 EVM 堆栈。这包括将元素压入堆栈(
PUSH1
)和从堆栈中弹出元素(如POP
)。 - 算术(Arithmetic)指令:这些指令用于在 EVM 中执行基本的数学运算,如加法(
ADD
)、减法(SUB
)、乘法(MUL
)、除法(DIV
) - 比较(Comparison)指令:这些指令用于比较堆栈顶部的两个元素。例如,大于(
GT
)、小于(LT
) - 位运算(Bitwise)指令:这些指令用于在位级别上操作数据,例如,按位与(
AND
)、按位或(OR
) - 内存(Memory)指令:这些指令用于操作 EVM 的账户存储。例如,将内存中的数据读取到堆栈(
MLOAD
)和将堆栈中的数据存储在内存(MSTORE
) - 存储(Storage)指令:这些指令用于操作 EVM 的账户存储。例如,将存储中的数据读取到堆栈(
SLOAD
)和将堆栈中的数据保存到存储(SSTORE
)。这类指令的 gas 消耗比内存指令更大。 - 控制流(Control Flow)指令:这些指令用于 EVM 的控制流操作,比如挑战
JUMP
和跳转目标JUMPDEST
- 上下文(Context)指令:这些指令用于获取交易和区块上下文信息。例如,获取 msg.sender(
CALLER
)和当前可用的 gas(gas
)
堆栈指令
程序计数器
在 EVM 中,程序计数器(通常缩写为 PC)是一个用于跟踪当前执行指令位置的寄存器。每执行一条指令(opcode),程序计数器的值会自动增加,以指向下一个待操作的指令。但是,这个过程并不总是线性的,在执行跳转指令(JUMP
和JUMPI
)时,程序计数器会被设置为新的值。
用 Python 创建一个简单的 EVM 程序计数器:
1 | class EVM: |
上面的代码示例的功能就是:利用程序计数器遍历字节码中的 opcode。
1 | code = b"\0x01\0x02\0x03" |
堆栈指令
EVM 是基于堆栈的,堆栈遵循 LIFO (后进先出)原则。 PUSH 和 POP 指令就是用来操作堆栈的。
PUSH
操作码范围:
0x60
-0x7F
gas 消耗:
3
在 EVM 中,PUSH 是一系列操作符,共有 32 个(在以太坊上海升级前),从PUSH1
,PUSH2
,一直到PUSH32
,**操作码范围为0x60
到0x7F
**。他们将字节大小为 1 到 32 字节的值从字节码压入堆栈(堆栈中每个元素的长度为 32 字节),每种指令的 gas 消耗都是 3。
以 PUSH1
为例,它的操作码为0x60
,它会将字节码中的下一个字节压入堆栈。例如,字节码0x6001
就是把0x01
压入堆栈。**PUSH2
就是将字节码中的下两个字节压入堆栈**,例如,0x610101
就是把0x0101
压入堆栈,其他的 PUSH 指令类似。
以太坊上海升级新加入了PUSH0
,操作码为0x5F
(即0x60
的前一位),用于将0
压入堆栈,gas消耗为2,比其他的PUSH指令更省gas。
用 Python 实现PUSH0
到PUSH32
:
1 | PUSH0 = 0x5F |
字节码0x60016001
(PUSH1 PUSH1)会将两个 1 压入堆栈:
1 | code = b"\x60\x01\x60\x01" |
在 evmcodes 上进行验证:
POP
操作码:
0x50
gas 消耗:
2
在 EVM中,POP
指令(操作码0x50
,gas 消耗2
)用于移除栈顶元素;如果当前堆栈为空,就抛出一个异常
下面将POP
指令加入到上面的代码中:
1 | PUSH0 = 0x5F |
字节码0x6001600150
(PUSH1 1 PUSH1 1 POP)会将两个 1 压入堆栈,然后再弹出一个 1:
1 | code = b"\x60\x01\x60\x01\x50" |
在 evm.codes 上验证:
DUP
- 操作码:
0x80
-0x8F
- gas 消耗:
3
DUP
是一系列指令,总共 16 个,从DUP1
到 DUP16
。这些指令用于复制(Duplicate)堆栈上的指定元素(根据指令的序号)。例如,DUP1
复制栈顶元素,DUP2
复制距离栈顶的第二个元素,以此类推。
示例:
6001600280
(PUSH1 1 PUSH1 2 DUP1)。这个字节码将1
和2
推入堆栈,然后进行DUP1
复制栈顶的元素(2),堆栈最后变为[1,2,2]。
SWAP
- 操作码:
0x90
-0x9F
- gas 消耗:
3
SWAP
指令用于交换堆栈顶的两个元素。与DUP
类型,SWAP
也是一系列的指令,从SWAP1
到SWAP16
共 16 个。**SWAP1
交换堆栈的顶部和次顶部的元素,SWAP2
交换顶部和第三个元素,以此类推**。
示例:
0x6001600290
(PUSH1 1 PUSH1 2 SWAP)。这个字节码将1
和2
推入堆栈,然后进行SWAP1
交换这两个元素,堆栈最后变为[2 , 1]
算术指令
这里将介绍 EVM 中用于基础算术运算的11个指令,包括ADD
(加法),MUL
(乘法),SUB
(减法),DIV
(除法)。
ADD
(加法)
操作码:
0x01
gas 消耗:
3
ADD
指令从堆栈中弹出两个元素,将他们相加,然后将结果推入堆栈。如果堆栈元素不足两个,那么会抛出异常。这个指令的操作码是0x01
,gas消耗为3
。
举例:0x6002600301
(PUSH1 2 PUSH1 3 ADD)。这个字节码将2
和3
推入堆栈,然后将它们相加。(返回值5)
MUL
(乘法)
操作码:
0x02
gas 消耗:
5
MUL
指令和ADD
类似,但是它将堆栈的顶部两个元素相乘。操作码是0x02
,gas消耗为5
。
举例:0x6002600302
(PUSH1 2 PUSH1 3 MUL)。这个字节码将2
和3
推入堆栈。然后将他们相乘。(返回值6)
SUB
(减法)
- 操作码:
0x03
- gas 消耗:
3
SUB
指令从堆栈顶部弹出两个元素,然后计算第二个元素减去第一个元素,最后将结果推入堆栈。该指令的操作码为0x03
,gas消耗为3
举例:0x6002600303
(PUSH1 2 PUSH1 3 SUB)。这个字节码将2
和3
推入堆栈,二者相减(3-2)。(返回值 1)
DIV
(除法)
- 操作码:
0x04
- gas 消耗:
5
DIV
指令从堆栈顶弹出两个元素,计算第二个元素除以第一个元素,最后将结果推入堆栈。该指令的操作码是0x04
,gas 消耗为5
。
举例:0x6002600304
(PUSH1 2 PUSH1 3 DIV)。这个字节码将2
和3
推入堆栈,然后将他们相除(3/2)。(返回值1)
其他算术指令
SDIV
:带有符号的整数除法指令。与DIV
类似,只是结果带有符号,如果第一个元素(除数)为 0,结果为 0。**操作码为0x05
,gas消耗为5
*。注意:EVM 字节码中的负数是用二进制补码形式,比如-1
表示为0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
,它加一等于0*MOD
:取模指令。从堆栈顶弹出两个元素,然后将第二个元素除以第一个元素的余数推入堆栈。如果第一个元素(除数)为 0,结果为 0。操作码0x06
,gas消耗5
SMOD
:带有符号的取模指令。与MOD
类似,只是结果带有第二个元素的符号。如果第一个元素(除数)为 0,结果为 0。操作码0x07
,gas 消耗为 5ADDMOD
: 取模加法指令。这个指令会从堆栈中弹出三个元素,将前两个元素相加,然后对第三个元素取模,将结果推入堆栈。如果第三个元素(模数)为0,结果为0。它的操作码是0x08
,gas消耗为8。MULMOD
:模乘法指令。该指令会从堆栈中弹出三个元素,将前两个元素相乘,对第三个元素取模,将结果推入堆栈。如果第三个元素(模数)为 0,结果为 0。操作码为0x09
,gas 消耗为5
EXP
:指数运算指令。这个指令会从堆栈中弹出两个元素,将第二个元素作为底数,第一个元素作为指数,进行指数运算,然后将结果推入堆栈。它的操作码是0x0A
,gas消耗为10
。SIGNEXTEND
:符号位扩展指令,即在保留数字的符号(正负性)及数值的情况下,增加二进制数字位数的操作。举个例子,若计算机使用8位二进制数表示数字“0000 1010”,且此数字需要将字长符号扩充至16位,则扩充后的值为“0000 0000 0000 1010”。此时,数值与符号均保留了下来。SIGNEXTEND
指令会从堆栈中弹出两个元素,对第二个元素进行符号扩展,扩展的位数由第一个元素决定,然后将结果推入堆栈。它的操作码是0x0B
,gas 消耗为5
比较指令
LT
(小于)
- 操作码:
0x10
- gas 消耗:
3
LT
指令从堆栈中弹出两个元素,比较第二个元素是否小于第一个元素。如果是,将1
推入堆栈,否则将0
推入堆栈。如果堆栈元素不足两个,那么抛出异常。该指令的操作码是0x10
,gas 消耗为 3
举例:0x6002600310
(PUSH1 2 PUSH1 3 LT)这个字节码将2
和3
推入堆栈,比较3
是否小于2
。(返回值 0)
GT
(大于)
- 操作码:
0x11
- gas 消耗
3
和LT
类似。比较元素是否大于
举例:0x6002600311
(PUSH1 2 PUSH1 3 GT)将2
和3
推入堆栈,比较3
是否大于2
。(返回值 1)
EQ
(等于)
- 操作码:
0x14
- gas 消耗:
3
EQ
指令从堆栈弹出两个元素,如果两个元素相等,那么将1
推入堆栈,否则0
推入堆栈。
举例:0x6002600314
(PUSH1 2 PUSH1 3 EQ)。将2
和3
推入堆栈,比较二者是否相等(返回值 0)
ISZERO
(是否为零)
- 操作码:
0x15
- gas 消耗:
3
ISZERO
从堆栈顶弹出一个元素,判断该元素是否为0
。
举例:0x600015
(PUSH1 0 ISZERO)。将0
推入堆栈,检查其是否为0
。(返回值为 1)
其他比较指令
SLT
(有符号小于):这个指令会从堆栈中弹出两个元素,然后比较第二个元素是否小于第一个元素,结果以有符号整数形式返回。如果第二个元素小于第一个元素,将1
推入堆栈,否则将0
推入堆栈。它的操作码是0x12
,gas消耗为3
。SGT
(有符号大于):这个指令会从堆栈中弹出两个元素,然后比较第二个元素是否大于第一个元素,结果以有符号整数形式返回。如果第二个元素大于第一个元素,将1
推入堆栈,否则将0
推入堆栈。它的操作码是0x13
,gas消耗为3
。
位级指令
AND(与)
- 操作码:
0x16
- gas 消耗:
3
AND
指令从堆栈中弹出两个元素,对他们进行位与运算,并将结果推入堆栈。
举例:0x6002600316
(PUSH1 2 PUSH1 3 AND)。将2
(0000 0010)和3
(0000 0011)推入堆栈,进行位与运算。结果应该为2
(0000 0010)。
OR(或)
- 操作码:
0x17
- gas 消耗:
3
OR
指令与AND
指令类似,但是执行的是位或运算。
举例:0x6002600317
(PUSH1 2 PUSH1 3 OR)。将2
(0000 0010)和3
(0000 0011)推入堆栈,进行位级或运算,结果应该为3
(0000 0011)。
XOR(异或)
- 操作码:
0x18
- gas 消耗:
3
XOR
与OR
和AND
指令都类似,只是执行的是异或运算
举例:0x6002600318
(PUSH1 2 PUSH1 3 XOR)。将2
(0000 0010)和3
(0000 0011)推入堆栈,进行位级异或运算,结果应该为1
(0000 0001)。
NOT(非)
- 操作码:
0x19
- gas 消耗:
3
NOT
指令执行按位非操作,取栈顶元素的补码,然后将结果退回栈顶。
举例:0x600219
(PUSH1 2 NOT)。将2
(0000 0010)推入堆栈,然后进行位级非运算,结果应该为很大的数
(0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd)
SHL(左移位)
- 操作码:
0x1B
- gas 消耗:
3
SHL
指令执行左移位操作,从堆栈中弹出两个元素,将第二个元素左移第一个元素位数,然后将结果推回栈顶。
举例:0x600260031B
(PUSH1 2 PUSH1 3 SHL)将2
(0000 0010)和3
(0000 0011)推入堆栈,然后将2
左移3
位,结果应该为16
(0000 1000)
SHR(右移位)
- 操作码:
0x1C
- gas 消耗:
3
SHR
执行右移位操作,从堆栈中弹出两个元素,将第二个元素右移第一个元素位数,然后将结果推回栈顶。
举例:0x601060031C
(PUSH1 16 PUSH1 3 SHR)将16
(0001 0000)和3
(0000 0011)推入堆栈,然后将16
右移3
位,结果应该为2
(0000 0010)
其他位级指令
- BYTE:
BYTE
指令从堆栈中弹出两个元素(a
和b
),将第二个元素(b
)看作一个字节数组。并返回该字节数组中第一个元素指定索引的字节(b[a]
),并压入堆栈。如果索引大于或等于字节数组的长度,则返回0
。操作码0x1a
,gas 消耗为3
- SAR:
SAR
指令执行算数右移位操作,与SHR
类似,但考虑符号位:如果我们对一个负数进行算术右移,那么在右移的过程中,最左侧(符号位)会被填充F
以保持数字的负值。它从堆栈中弹出两个元素,将第二个元素以符号位填充的方式右移第一个元素位数,然后将结果推回栈顶。它的操作码是0x1D
内存指令
EVM 中的内存
详见:[内存 Memory](###内存 Memory)
MLOAD(内存读)
- 操作码:
0x51
- **gas 消耗:
3 + x
**(根据实际内存使用情况计算)
MLOAD
指令从内存中加载一个 256 位的值并推入堆栈。他从堆栈中弹出一个元素,从该元素表示的内存地址中加载 32 字节,并将其推入堆栈。
举例:0x6002602052602051
(PUSH1 2 PUSH1 0x20 MSTORE PUSH1 0x20 MLOAD)。这个字节码将2
和0x20
(32)推入堆栈,然后进行MSTORE
,将2
存到偏移量为0x20
的地方;然后将0x20
推入堆栈,然后进行MLOAD
,将刚才存储在内存的值读取出来。
MSTORE(内存写)
- 操作码:
0x52
- **gas 消耗:
3 + x
**(根据实际内存使用情况计算)
MSTORE
指令用于将一个 256 位(32 字节)的值存储到内存中。他从堆栈顶弹出两个元素,第一个元素位内存的地址(偏移量 offset),第二个元素为存储的值(value)。
举例:0x6002602052
(PUSH1 2 PUSH1 0x20 MSTORE)。这个字节码将2
和0x20
(32)推入堆栈,然后进行MSTORE
,将2
存到偏移量为0x20
的地方。
MSTORE8(内存 8 位写)
- 操作码:
0x53
- **gas 消耗:
3 + x
**(根据实际内存使用情况计算)
MSTORE8
指令用于将一个8位(1字节)的值存储到内存中。与MSTORE
类似,但只使用最低8位。操作码是0x53
,gas消耗根据实际内存使用情况计算(3+X)。
示例:0x6002602053
(PUSH1 2 PUSH1 0x20 MSTORE8)。这个字节码将2
和0x20
(32)推入堆栈,然后进行MSTORE8
,将2
存到偏移量为0x20
的地方。
MSIZE(内存大小)
- 操作码:
0x59
- gas 消耗:
2
MSIZE
指令将当前的内存大小(以字节为单位)压入堆栈。
存储指令
EVM 中的存储
详见[EVM中的存储](####存储 Storage)
SLOAD(存储读)
- 操作码:
0x54
- gas 消耗:(详细见[Gas Cost](###Gas Cost)
- cold:2100
- warm:100
SLOAD
指令从存储中读取一个 256 位(32 字节)的值并推入堆栈。他会从堆栈中弹出一个元素,从该元素表示的存储槽中加载值,并将其推入堆栈。
示例:0x6002600556054
(PUSH1 2 PUSH1 0 SSTORE PUSH1 0 SLOAD)。将2
和0
推入堆栈,然后进行SSTORE
,将2
存到键为0
的地方;然后将0
推入堆栈,然后进行SLOAD
,将刚刚写入0x0
存储槽的值读取出来。
SSTORE(存储写)
- 操作码:
0x55
- gas 消耗:实际消耗在后面给出
SSTORE
指令将一个 256 位(32字节)的值写入到存储。他从堆栈中弹出两个元素,第一个元素位存储的地址(key),第二个元素位存储的值(value)。
示例:0x6002600055
(PUSH1 2 PUSH1 0 SSTORE)。这个字节码将2
和0
推入堆栈,然后进行SSTORE
,将2
存到键为0x0
的存储槽。
访问集 EIP-2929
访问集(Access Sets)是 EIP-2929 提出的一种新概念,它的引入有助于优化 Gas 计费和以太坊的网络性能。访问集是在每个外部交易中定义的,并且在交易过程中会根据和记录每个交易访问过的合约地址和存储槽(slot)。
- 合约地址:在执行交易过程中,任何被访问到的地址都会被添加到访问集中
- 存储槽:这个列表包含了一个交易在执行过程中访问过的所有存储槽。
如果一个地址或存储槽在访问集中,我们称它为warm,否则称之为cold。**一个地址或存储槽在一次交易中首次被访问时,他会从”cold”变为”warm”**。如果一个指令需要访问一个”cold”的地址或存储槽,那么这个指令的 Gas 消耗会更高。而对”warm”的地址或存储槽的访问,则会有较低的 Gas 消耗,因为相关数据已经被缓存了。
Gas Cost
对于SLOAD
(存储读),如果读取的存储槽为**”cold”**(即这是交易中首次访问),那么SLOAD
的 gas 消耗为 2100 gas;如果是 **”warm”**(即在交易中已经访问过),那么SLOAD
的 gas 消耗为 100 gas。
对于SSTORE
(存储写),gas 计算公式更为复杂,分为 gas 消耗和 gas 返还两部分。
SSTORE
的 gas 消耗:简单来说,如果存储槽为cold
,则需要多花费 2100 gas;如果存储槽初始值为 0,那么将它改为非 0 值的 gas 消耗最大,为 22100 gas。具体计算公式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14static_gas = 0
if value == current_value :
base_dynamic_gas = 100
else if current_value == original_value:
if original_value == 0 :
base_dynamiac_gas = 20000
else :
base_dynamic_gas = 2900
else :
base_dynamic_gas = 100
if key is not warm :
base_dybamic_gas += 2100其中
value
为要存储的新值,current_value
为存储槽当前值,original_value
为交易开始时存储槽的原始值,base_dynamic_gas
为 gas 消耗。SSTORE
的 gas 返还:当要存储的新值不等于存储槽的当前值时,可能触发 gas 返还。简单来说,将存储槽的非 0 值改为 0,返还的 gas 最多高达 19900 gas。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18if value != current_value:
if current_value == original_value:
if original_value != 0 and value == 0:
gas_refunds += 4800
else :
if original_value != 0:
if current_value == 0:
gas_refunds -= 4800
else if value == 0:
gas_refunds += 1800
if value == original_value :
if original_value ==0 :
gas_refunds += 19900
else :
if key is warm:
gas_refunds += 5000 - 2100 -100
else :
gas_refunds += 4900其中
value
为要存储的新值,current_value
为存储槽当前值,original_value
为交易开始时存储槽的原始值,gas_refunds
为gas返还。