简介
Opcodes(操作码)是以太坊智能合约的基本单元。
一般来说智能合约会先被编译成字节码,然后才能在EVM上运行。而字节码就是由一系列Opcodes组成。当用户在EVM中调用这个智能合约的函数时,EVM就会解析并且执行这些Opcodes,以实现合约逻辑。
例如:
PUSH1
:将一个字节的数据压入堆栈。例如:PUSH1 0x60
就是将0x60压入堆栈。DUP1
:复制堆栈顶部的一个元素SWAP1
: 交换堆栈顶部的前两个元素。
EVM基础
由于Opcodes直接操作EVM的资源,比如堆栈、内存、存储,因此了解EVM基础很重要。
堆栈 Stack
EVM基于堆栈,所以EVM处理数据的方式是使用对战数据结构进行大多数的计算。堆栈是一种后进先出的数据结构。
在堆栈中,每个元素长度为256位(32字节),最大深度是1024元素,但是每个操作只能操作堆栈顶部的16个元素。这也是为什么Solidity会有Stack too deep的error。
Memory
堆栈虽然高效,但是存储能力有限,为此EVM使用内存来支持交易执行期间的数据存取和读取。EVM的内存是一个线性寻址存储器,末种意义上是一个动态字节数组,可以根据需要动态拓展。支持以8或者256 bit写入(MSTORE8/MSTORE),but only support load by 256bit (MLOAD).
Remember , The memory in EVM is “易失性”的. 当交易开始的时候,所有内存位置的值都是0,交易执行期间,值被更新,交易结束时候,内存中所有数据都会被清除,不会被持久化报错,如果需要永久的保存数据,就需要使用EVM的存储。
Storage
EVM的账户存储Account Storage是一种映射,每个健和值都是256bit的数据,支持256 bit的写和读。这种存储在每个合约账户上都有,并且是持久化的,它的数据会保存在区块链上,直到被明确的修改。
对存储的读取SLOAD和写入SSTORE都需要gas。 并且比内存操作更贵。这样的设计可以防止滥用存储资源,因为所有的存储数据都需要在每个以太坊节点上保存
EVM字节码
Solidity智能合约会被编译为EVM字节码,然后才能在EVM上运行。这个字节码是由一系列的Opcodes组成的,通常表现为一串十六进制的数字。EVM字节码在执行的时候,会按照顺序一个一个地读取并执行每个Opcode。
eg:
字节码6001600101
PUSH1 0x01
PUSH1 0x01
ADD
Gas
Gas是以太坊中执行交易和运行合约的"燃料"。每个交易或合约调用都需要消耗一定数量的Gas,这个数量取决于它们进行的计算的复杂性和数据存储的大小。
EVM上每笔交易的gas是如何计算的呢?其实是通过opcodes。以太坊规定了每个opcode的gas消耗,复杂度越高的opcodes消耗越多的gas,比如:
ADD
操作消耗3 gasSSTORE
操作消耗20000 gasSLOAD
操作消耗200 Gas
一笔交易的gas消耗等于其中所有opcodes的gas成本总和。当你调用一个合约函数时,你需要预估这个函数执行所需要的Gas,并在交易中提供足够的Gas。如果提供的Gas不够,那么函数执行会在中途停止,已经消耗的Gas不会退回。 ![[Pasted image 20240105145627.png]]
Excute
-
当一个交易被接收并准备执行时,以太坊会初始化一个新的执行环境并加载合约的字节码。
-
字节码被翻译成Opcode,被逐一执行。每个Opcodes代表一种操作,比如算术运算、逻辑运算、存储操作或者跳转到其他操作码。
-
每执行一个Opcodes,都要消耗一定数量的Gas。如果Gas耗尽或者执行出错,执行就会立即停止,所有的状态改变(除了已经消耗的Gas)都会被回滚。
-
执行完成后,交易的结果会被记录在区块链上,包括Gas的消耗、交易日志等信息。
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
)。
Example
PUSH1 0x02
PUSH1 0x03
ADD
PUSH0
MSTORE
- 首先PUSH1,指令将一个长度为1字节的数据压入了堆栈顶部
PUSH1 0x01
// stack: [1]
PUSH1 0x01
// stack: [1, 1]
- ADD指令会弹出堆栈顶部的两个元素,计算他们的和,然后再将结果压入堆栈
ADD
// stack: [2]
- PUSH0将0压入堆栈
PUSH0
// stack: [0, 2]
- MSTORE属于内存指令,
MSTORE
指令需要两个参数:一个是要存储的值,另一个是存储的内存地址。也就是它会弹出堆栈顶的两个数据[offset, value]
(偏移量和值),然后将value
(长度为32字节)保存到内存索引(偏移量)为offset
的位置。
MSTORE
// stack: []
// memory: [0: 2]
堆栈指令
程序计数器
在EVM中,程序技术器,是一个用于跟踪当前执行指令位置的寄存器。每执行一条指令,程序计数器的值就会自动增加,以指向下一条待执行的指令。但这个过程不总是线性的,在执行跳转指令的时候,程序计数器会被设置为新的值
堆栈指令
EVM是基于堆栈的,堆栈遵循LIFO的原则。
PUSH
在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。s
![[Pasted image 20240105154933.png]]
POP
在EVM中,POP
指令(操作码0x50
,gas消耗2
)用于移除栈顶元素;如果当前堆栈为空,就抛出一个异常。
算数指令
ADD
ADD
指令从堆栈中弹出两个元素,将它们相加,然后将结果推入堆栈。如果堆栈元素不足两个,那么会抛出异常。这个指令的操作码是0x01
,gas消耗为3
。
MUL
MUL
指令和ADD
类似,但是它将堆栈的顶部两个元素相乘。操作码是0x02
,gas消耗为5
。
SUB
SUB
指令从堆栈顶部弹出两 个元素,然后计算第一个元素减去第二个元素,最后将结果推入堆栈。这个指令的操作码是0x03
,gas消耗为3
。
DIV
DIV
指令从堆栈顶部弹出两个元素,然后将第一个元素除以第二个元素,最后将结果推入堆栈。如果第二个元素(除数)为0,则将0推入堆栈。这个指令的操作码是0x04
,gas消耗为5
。
SDIV
带符号整数的除法指令。与DIV
类似,这个指令会从堆栈中弹出两个元素,然后将第一个元素除以第二个元素,结果带有符号。如果第二个元素(除数)为0,结果为0。它的操作码是0x05
,gas消耗为5。要注意,EVM字节码中的负数是用二进制补码(two’s complement)形式,比如-1
表示为0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
,它加一等于0。
MOD
取模指令。这个指令会从堆栈中弹出两个元素,然后将第一个元素除以第二个元素的余数推入堆栈。如果第二个元素(除数)为0,结果为0。它的操作码是0x06
,gas消耗为5。
SMOD
带符号的取模指令。这个指令会从堆栈中弹出两个元素,然后将第一个元素除以第 二个元素的余数推入堆栈,结果带符号。如果第一个元素(除数)为0,结果为0。它的操作码是0x07
,gas消耗为5。
ADDMOD
模加法指令。这个指令会从堆栈中弹出三个元素,将前两个元素相加,然后对第三个元素取模,将结果推入堆栈。如果第三个元素(模数)为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
LT
指令从堆栈中弹出两个元素,比较第二个元素是否小于第一个元素。如果是,那么将1
推入堆栈,否则将0
推入堆栈。如果堆栈元素不足两个,那么会抛出异常。这个指令的操作码是0x10
,gas消耗为3
。
比较的是压入顺序
GT
GT
指令和LT
指令非常类似,不过它比较的是第二个元素是否大于第一个元素。操作码是0x11
,gas消耗为3
。
EQ
EQ
指令从堆栈中弹出两个元素,如果两个元素相等,那么将1
推入堆栈,否则将0
推入堆栈。该指令的操作码是0x14
,gas消耗为3
。
ISZERO
ISZERO
指令从堆栈中弹出一个元素,如果元素为0,那么将1
推入堆栈,否则将0
推入堆栈。该指令的操作码是0x15
,gas消耗为3
。
SLT
这个指令会从堆栈中弹出两个元素,然后比较第二个元素是否小于第一个元素,结果以有符号整数形式返回。如果第二个元素小于第一个元素,将1
推入堆栈,否则将0
推入堆栈。它的操作码是0x12
,gas消耗为3
。
SGT
这个指令会从堆栈中弹出两个元素,然后比较第二个元素是否大于第一个元素,结果以有符号整数形式返回。如果第二个元素大于第一个元素,将1
推入堆栈,否则将0
推入堆栈。它的操作码是0x13
,gas消耗为3
。
位级指令
包括AND
(与),OR
(或),和XOR
(异或)
XOR
XOR
指令与AND
和OR
指令类似,但执行的是异或运算。操作码是0x18
,gas 消耗为3
。
内存指令
EVM的内存,它是一个线性寻址存储器,类似一个动态的字节数组,可以根据需求动态扩展。它的另一个特点就是易失性,交易结束时所有数据都会被清零。它支持以8或256 bit写入(MSTORE8
/MSTORE
),但只支持以256 bit取(MLOAD
)。
内存的读写比存储(Storage)的读写要便宜的多,每次读写有固定费用3 gas,另外如果首次访问了新的内存位置(内存拓展),则需要付额外的费用(由当前偏移量和历史最大偏移量决定),
MSTORE
MSTORE
指令用于将一个256位(32字节)的值存储到内存中。它从堆栈中弹出两个元素,第一个元素为内存的地址(偏移量 offset),第二个元素为存储的值(value)。操作码是0x52
,gas消耗根据实际内存使用情况计算(3+X)。
MSTORE8
MSTORE8
指令用于将一个8位(1字节)的值存储到内存中。与MSTORE
类似,但只使用最低8位。操作码是0x53
,gas消耗根据实际内存使用情况计算(3+X)。
MLOAD
MLOAD
指令从内存中加载一个256位的值并推入堆栈。它从堆栈中弹出一个元素,从该元素表示的内存地址中加载32字节,并将其推入堆栈。操作码是0x51
,gas消耗根据实际内存使用情况计算(3+X)。
MSIZE
MSIZE
指令将当前的内存大小(以字节为单位)压入堆栈。操作码是0x59
,gas消耗为2。
存储指令
和内存不同,它是一种持久化存储空间,存在存储中的数据在交易之间可以保持。它是EVM的状态存储的一部分,支持以256 bit为单位的读写。
SSTORE
SSTORE
指令用于将一个256位(32字节)的值写入到存储。它从堆栈中弹出两个元素,第一个元素为存储的地址(key),第二个元素为存储的值(value)。操作码是0x55
,gas消耗根据实际改变的数据计算(下面给出)。
SLOAD
SLOAD
指令从存储中读取一个256位(32字节)的值并推入堆栈。它从堆栈中弹出一个元素,从该元素表示的存储槽中加载值,并将其推入堆栈。操作码是0x54
,gas消耗后面给出。
控制流指令
STOP
EVM的控制流是 由跳转指令(JUMP
,JUMPI
,JUMPDEST
)控制PC指向新的指令位置而实现的,这允许合约进行条件执行和循环执行。
STOP
是EVM的停止指令,它的作用是停止当前上下文的执行,并成功退出。它的操作码是0x00
,gas消耗为0。
将STOP
操作作码设为0x00
有一个好处:当一个调用被执行到一个没有代码的地址(EOA),并且EVM尝试读取代码数据时,系统会返回一个默认值0,这个默认值对应的就是STOP
指令,程序就会停止执行。
JUMPDEST
JUMPDEST
指令标记一个有效的跳转目标位置,不然无法使用JUMP
和JUMPI
进行跳转。它的操作码是0x5b
,gas消耗为1。
但是0x5b
有时会作为PUSH
的参数(详情可看黄皮书中的9.4.3. Jump Destination Validity),所以需要在运行代码前,筛选字节码中有效的JUMPDEST
指令,使用ValidJumpDest
来存储有效的JUMPDEST
指令所在位置。
JUMP
JUMP
指令用于无条件跳转到一个新的程序计数器位置。它从堆栈中弹出一个元素,将这个元素设定为新的程序计数器(pc
)的值。操作码是0x56
,gas消耗为8。
JUMPI
JUMPI
指令用于条件跳转,它从堆栈中弹出两个元素,如果第二个元素(条件,condition
)不为0,那么将第一个元素(目标,destination
)设定为新的pc
的值。操作码是0x57
,gas消耗为10。
PC
PC
指令将当前的程序计数器(pc
)的值压入堆栈。操作码为0x58
,gas消耗为2。
区块信息指令
堆栈指令2
DUP1
在EVM中,DUP
是一系列的指令,总共有16个,从DUP1
到DUP16
,操作码范围为0x80
到0x8F
,gas消耗均为3。这些指令用于复制(Duplicate)堆栈上的指定元素(根据指令的序号)到堆栈顶部。例如,DUP1
复制栈顶元素,DUP2
复制距离栈顶的第二个元素,以此类推。
SWAP
SWAP
指令用于交换堆栈顶部的两个元素。与DUP
类似,SWAP
也是一系列的指令,从SWAP1
到SWAP16
共16个,操作码范围为0x90
到0x9F
,gas消耗均为3。SWAP1
交换堆栈的顶部和次顶部的元素,SWAP2
交换顶部和第三个元素,以此类推。
SHA3指令
账户指令
以太坊上的账户分两类:外部账户(Externally Owned Accounts,EOA)和合约账户。EOA是用户在以太坊网络上的代表,它们可以拥有ETH、发送交易并与合约互动;而合约账户是存储和执行智能合约代码的实体,它们也可以拥有和发送ETH,但不能主动发起交易。
![[Pasted image 20240108152351.png]]
以太坊上的账户结构非常简单,你可以它理解为地址到账户状态的映射。账户地址是20字节(160位)的数据,可以用40位的16进制表示,比如0x9bbfed6889322e016e0a02ee459d306fc19545d8
。而账户的状态具有4种属性:
- Balance:这是账户持有的ETH数量,用Wei表示(1 ETH = 10^18 Wei)。
- Nonce:对于外部账户(EOA),这是该账户发送的交易数。对于合约账户,它是该账户创建的合约数量。
- Storage:每个合约账户都有与之关联的存储空间,其中包含状态变量的值。
- Code:合约账户的字节码。
也就是说,只有合约账户拥有Storage
和Code
,EOA没有。
BALANCE
BALANCE
指令用于返回某个账户的余额。它从堆栈中弹出一个地址,然后查询该地址的余额并压入堆栈。它的操作码是0x31
,gas为2600(cold address)或100(warm address)。
EXTCODESIZE
EXTCODESIZE
指令用于返回某个账户的代码长度(以字节为单位)。它从堆栈中弹出一个地址,然后查询该地址的代码长度并压入堆栈。如果账户不存在或没有代码,返回0。他的操作码为0x3B
,gas为2600(cold address)或100(warm address)。
EXTCODECOPY
EXTCODECOPY
指令用于将某个账户的部分代码复制到EVM的内存中。它会从堆栈中弹出4个参数(addr, mem_offset, code_offset, length),分别对应要查询的地址,写到内存的偏移量,读取代码的偏移量和长度。它的操作码是0x3C
,gas由读取代码长度、内存扩展成本和地址是否为cold这三部分决定。
EXTCODEHASH
EXTCODEHASH
指令返回某个账户的代码的Keccak256哈希值。它从堆栈中弹出一个地址,然后查询该地址代码的哈希并压入堆栈。它的操作码是0x3F
,gas为2600(cold address)或100(warm address)。
交易指令
![[Pasted image 20240108140043.png]]
每一笔以太坊交易都包含以下属性:
nonce
:一个与发送者账户相关的数字,表示该账户已发送的交易数。gasPrice
:交易发送者愿意支付的单位gas价格。gasLimit
:交易发送者为这次交易分配的最大gas数量。to
:交易的接收者地址。当交易为合约创建时,这一字段为空。value
:以wei为单位的发送金额。data
:附带的数据,通常为合约调用的输入数据(calldata)或新合约的初始化代码(initcode)。v, r, s
:与交易签名相关的三个值。
ADDRESS
- 操作码:
0x30
- gas消耗: 2
- 功能:将当前执行合约的地址压入堆栈。
- 使用场景:当合约需要知道自己的地址时使用。
ORIGIN
- 操作码:
0x32
- gas消耗: 2
- 功能:将交易的原始发送者(即签名者)地址压入堆栈。
- 使用场景:区分合约调用者与交易发起者。
CALLER
- 操作码:
0x33
- gas消耗: 2
- 功能:将直接调用当前合约的地址压入堆栈。
- 使用场景:当合约需要知道是谁调用了它时使用。
CALLVALUE
- 操作码:
0x34
- gas消耗: 2
- 功能:将发送给合约的ether的数量(以wei为单位)压入堆栈。
- 使用场景:当合约需要知道有多少以太币被发送时使用。
CALLDATALOAD
- 操作码:
0x35
- gas消耗: 3
- 功能:从交易或合约调用的
data
字段加载数据。它从堆栈中弹出calldata的偏移量(offset
),然后从calldata的offset
位置读取32字节的数据并压入堆栈。如果calldata剩余不足32字节,则补0。 - 使用场景:读取传入的数据。
CALLDATASIZE
- 操作码:
0x36
- gas消耗:2
- 功能:获取交易或合约调用的
data
字段的字节长度,并压入堆栈。 - 使用场景:在读取数据之前检查大小。
CALLDATACOPY
- 操作码:
0x37
- gas消耗:3 + 3 * 数据长度 + 内存扩展成本
- 功能:将
data
中的数据复制到内存中。它会从堆栈中弹出3个参数(mem_offset, calldata_offset, length),分别对应写到内存的偏移量,读取calldata的偏移量和长度。 - 使用场景:将输入数据复制到内存。
CODESIZE
- 操作码:
0x38
- gas消耗: 2
- 功能:获取当前合约代码的字节长度,然后压入堆栈。
- 使用场景:当合约需要访问自己的字节码时使用。
CODECOPY
- 操作码:
0x39
- gas消耗:3 + 3 * 数据长度 + 内存扩展成本
- 功能:复制合约的代码到EVM的内存中。它从堆栈中弹出三个参数:目标内存的开始偏移量(
mem_offset
)、代码的开始偏移量(code_offset
)、以及要复制的长度(length
)。 - 使用场景:当合约需要读取自己的部分字节码时使用。
GASPRICE
- 操作码:
0x3A
- gas消耗:2
- 功能:获取交易的gas价格,并压入堆栈。
- 使用场景:当合约需要知道当前交易的gas价格时使用。
RETURN指令
EVM的返回数据,通常称为returnData
,本质上是一个字节数组。它不遵循固定的数据结构,而是简单地表示为连续的字节。当合约函数需要返回复杂数据类型(如结构体或数组)时,这些数据将按照ABI规范被编码为字节,并存储在returnData
中,供其他函数或合约访问。
RETURN
- 操作码:
0xF3
- gas消耗:内存扩展成本。
- 功能:从指定的内存位置提取数据,存储到
returnData
中,并终止当前的操作。此指令需要从堆栈中取出两个参数:内存的起始位置mem_offset
和数据的长度length
。 - 使用场景:当需要将数据返回给外部函数或交易时。
RETURNDATASIZE
- 操作码:
0x3D
- gas消耗:2
- 功能:将
returnData
的大小推入堆栈。 - 使用场景:使用上一个调用返回的数据。
RETURNDATACOPY
- 操作码:
0x3E
- gas消耗: 3 + 3 * 数据长度 + 内存扩展成本
- 功能:将
returnData
中的某段数据复制到内存中。此指令需要从堆栈中取出三个参数:内存的起始位置mem_offset
,返回数据的起始位置return_offset
,和数据的长度length
。 - 使用场景:使用上一个调用返回的部分数据。