简介
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。 - 使用场景:使用上一个调用返回的部分数据。