Opcodes基础
Opcodes基础
Opcodes(操作码)是字节码的组成部分,solidity会被编译为操作码,有点像高级语言与汇编的关系。看到这里我就已经有重温计组和汇编的心理准备了。
EVM
写过java会比较熟悉JVM,其实都差不多,EVM是以太坊虚拟机,它的结构是:

学过计组的话会非常熟悉这种图,EVM的栈中,每个元素长度为256位(32字节),最大深度为1024元素,每个操作只能操作堆栈顶的16个元素。
内存部分线性寻址,用来支持交易执行期间的数据存储和读取。交易开始时,所有内存位置的值均为0;交易执行期间,值被更新;交易结束时,内存中的所有数据都会被清除。它支持以8或256 bit写入(MSTORE8
/MSTORE
),但只支持以256 bit读取(MLOAD
)。
存储类似mongoDB,用键值对存数据,键值都是256bits。对存储的读取(SLOAD
)和写入(SSTORE
)都需要gas,并且比内存操作更昂贵。这样设计可以防止滥用存储资源,因为所有的存储数据都需要在每个以太坊节点上保存。
Gas的基本计算单位就是操作码,不同操作码有不同消耗,最后加总就是一次交易的消耗,例如ADD
要3 gas,存储操作SSTORE
要20000 gas的天价。
交易的执行过程其实就和一般程序差不多,就是取指并和栈交互,要额外做一个算gas剩余的步骤:

1+1程序
PUSH1 0x01
PUSH1 0x01
ADD
PUSH0
MSTORE
PUSH1
指令将一个长度为1字节的数据压入堆栈顶部,压两次就是两个1在栈顶。ADD
指令会弹出堆栈顶部的两个元素(不用手动弹),计算它们的和,然后将结果压入栈顶。
之后PUSH0
指令将0压入堆栈。这里为啥要额外压个0呢?因为MSTORE
属于内存指令,它会弹出堆栈顶的两个数据 [offset, value]
,分别是偏移量和值,然后将value
(长度为32字节)保存到内存索引(偏移量)为offset
的位置。
执行完上述代码,内存0号为应该有个2。
之后我将按网站上的顺序分类记录操作码的学习过程。
堆栈指令
PUSH
用来把东西压入栈。字节码是0x60 - 0x7f
,分别对应PUSH1 - PUSH32
。比如PUSH1 0x01
写成字节码就是0x6001
。有个例外是PUSH0
,操作码为0x5F
(即0x60
的前一位),用于将0
压入堆栈。
PUSH1 0x01
PUSH1 0x01
// 等价于
0x60016001
POP
移除栈顶元素,如果没有元素就抛异常,字节码是0x50
。
算数指令
ADD
从栈顶弹出两个元素并相加,结果压回栈顶。不足就抛异常,字节码是0x01
:
PUSH1 0x02
PUSH1 0x03
ADD
// 等价于
0x6002600301
MUL、SUB、DIV
这三个运算和加法一模一样,不再赘述。不过减法和除法要注意顺序,牢记栈是先进后出的。
SDIV
它能将第一个元素除以第二个元素,结果带有符号。如果第二个元素(除数)为0,结果为0。它的操作码是0x05
,EVM字节码中的负数是用补码形式,比如-1
表示为0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
,它加一等于0。
MOD
取模指令。这个指令会从堆栈中弹出两个元素,然后将第一个元素除以第二个元素的余数推入堆栈。如果第二个元素(除数)为0,结果为0。它的操作码是0x06
。
SMOD
带符号取模,规则和上边一样。
ADDMOD
模加法指令。这个指令会从堆栈中弹出三个元素,将前两个元素相加,然后对第三个元素取模,将结果推入堆栈。如果第三个元素(模数)为0,结果为0。它的操作码是0x08
。
MULMOD
模乘法指令。这个指令会从堆栈中弹出三个元素,将前两个元素相乘,然后对第三个元素取模,将结果推入堆栈。如果第三个元素(模数)为0,结果为0。它的操作码是0x09
。
EXP
指数运算指令。这个指令会从堆栈中弹出两个元素,将第一个元素作为底数,第二个元素作为指数,进行指数运算,然后将结果推入堆栈。它的操作码是0x0A
。
SIGNEXTEND
符号位扩展指令,即在保留数字的符号(正负性)及数值的情况下,增加二进制数字位数的操作,比如:
PUSH1 0x03
PUSH1 0x10
SIGNEXTEND
就是把0000 0011
扩展为0000 0000 0000 0011
。它的操作码是0x0B
。
比较指令
LT
LT
指令从堆栈中弹出两个元素,比较第二个元素是否小于第一个元素。如果是,那么将0
推入堆栈,否则将1
推入堆栈。如果堆栈元素不足两个,那么会抛出异常。这个指令的操作码是0x10
。
PUSH1 0x03
PUSH1 0x10
LT
// 栈顶压入0
GT
GT
指令和LT
指令非常类似,不过它比较的是第二个元素是否大于第一个元素。如果是,那么将0
推入堆栈,否则将1
推入堆栈。如果堆栈元素不足两个,那么会抛出异常。这个指令的操作码是0x11
。
这两个指令比较怪,0真1假。
EQ
EQ
指令从堆栈中弹出两个元素,如果两个元素相等,那么将1
推入堆栈,否则将0
推入堆栈。该指令的操作码是0x14
:
PUSH1 0x03
PUSH1 0x10
EQ
// 栈顶压入0
这里开始又变成0假1真了。
ISZERO
ISZERO
指令从堆栈中弹出一个元素,如果元素为0,那么将1
推入堆栈,否则将0
推入堆栈。该指令的操作码是0x15
。
SLT、SGT
操作码是0x12
和0x13
,也是比大小,但是返回值带符号。
位级指令
AND、OR、XOR、NOT
这四位老熟人了,操作码是0x16 - 0x19
,就是对两个栈顶元素做相应运算。
PUSH1 0x13
PUSH1 0x10
XOR
// 00010011 XOR 00010000 = 00000011 = 0x03
NOT
只需要取一个栈顶元素按位非回去:
PUSH0
NOT
// ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
SHL、SHR
SHL
指令执行左移位操作,从堆栈中弹出两个元素,将第二个元素左移第一个元素位数,然后将结果推回栈顶。它的操作码是0x1B
;SHR
指令执行右移位操作,从堆栈中弹出两个元素,将第二个元素右移第一个元素位数,然后将结果推回栈顶。它的操作码是0x1C
。
PUSH1 0x11
PUSH1 0x01
SHL
// 00010001 << 1 = 00100010 = 0x22
BYTE
BYTE
指令从堆栈中弹出两个元素(a
和b
),将第二个元素(b
)看作一个32字节
的数组,不够位数补 0,并返回该字节数组中从高位开始的第a
个索引的字节,即(b[31-a]
),并压入堆栈。如果索引a
大于或等于 32,返回0
,否则返回b[31-a]
。 操作码是0x1a
。
PUSH32 0xae4cb089c23657fe9d211114514191981045df0ac4087fffffffffffffffffff
PUSH1 0x12
BYTE
// df, 从左往右数到0x12,也就是18个字节,注意从0开始数
SAR
SAR
指令执行算术右移位操作,与SHR
类似,但考虑符号位:如果我们对一个负数进行算术右移,那么在右移的过程中,最左侧(符号位)会被填充F
以保持数字的负值。它从堆栈中弹出两个元素,将第二个元素以符号位填充的方式右移第一个元素位数,然后将结果推回栈顶。它的操作码是0x1D
。
// 使用无符号右移
PUSH32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
PUSH1 0x01
SHR
// 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
// 使用SAR有符号右移
PUSH32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
PUSH1 0x01
SAR
// 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
内存指令
如果首次访问了新的内存位置(内存拓展),则需要付额外的费用(由当前偏移量和历史最大偏移量决定)。
MSTORE
MSTORE
指令用于将一个256位(32字节)的值存储到内存中。它从堆栈中弹出两个元素,第一个元素为内存的地址(偏移量 offset),第二个元素为存储的值(value)。操作码是0x52
:
PUSH1 0x01
PUSH1 0xac
MSTORE
// 这段代码把32位的1存在0xac开始的32字节上,此时内存中的相应位置变成:
// 0xac: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
// 0xbc: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01
MSTORE8
MSTORE8
指令用于将一个8位(1字节)的值存储到内存中。与MSTORE
类似,但只使用最低8位。操作码是0x53
。
还看上边的例子:
PUSH1 0x01
PUSH1 0xac
MSTORE8
// 这个1只会占用0xac单个字节的空间,不会像MSTORE那样一下占32字节
// 0xac: 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
// 0xbc: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
MLOAD
MLOAD
指令从内存中加载一个32字节的值并推入堆栈。它从堆栈中弹出一个元素,从该元素表示的内存地址中加载32字节,并将其推入堆栈。操作码是0x51
。
PUSH32 0xde
PUSH1 0xac
MSTORE
PUSH1 0xac
MLOAD
// de, 这段代码就是原位存取
MSIZE
MSIZE
指令将当前的内存大小(以字节为单位)压入堆栈。操作码是0x59
。
PUSH32 0xde
PUSH1 0xac
MSTORE
PUSH1 0xac
MLOAD
POP
MSIZE
// 栈顶被压了个e0,说明现在已经分配了0xe0字节的内存
注意,EVM一次性分配0x20字节,MSIZE得出的一定是0x20的整数倍。
存储指令
SSTORE
SSTORE
指令用于将一个256位(32字节)的值写入到存储。它从堆栈中弹出两个元素,第一个元素为存储的地址(key),第二个元素为存储的值(value)。操作码是0x55
。
SLOAD
SLOAD
指令从存储中读取一个256位(32字节)的值并推入堆栈。它从堆栈中弹出一个元素,从该元素表示的存储槽中加载值,并将其推入堆栈。操作码是0x54
。
控制流
STOP
STOP
是EVM的停止指令,它的作用是停止当前上下文的执行,并成功退出。它的操作码是0x00
。
JUMPDEST、JUMP
JUMPDEST
指令标记一个有效的跳转目标位置,不然无法使用JUMP
和JUMPI
进行跳转。它的操作码是0x5b
。
JUMP
指令用于无条件跳转到一个新的程序计数器位置。它从堆栈中弹出一个元素,将这个元素设定为新的程序计数器(pc
)的值。操作码是0x56
。
其实就是说,JUMP指令在跳转后必须先检查所在位置是不是JUMPDEST,如果不是就要报错,如果是才能继续。
注意,JUMP的参数不是指令的序号,而是JUMPDEST的位置。在下面的例子中,JUMPDEST排在第5条,但是如果往0x05跳就会报错,这涉及到PUSH指令一个非常坑人的地方:
PUSH32 0xde
PUSH1 0xac
MSTORE
PUSH1 0x27
// PUSH1 0x05就错了!!!
JUMP
JUMPDEST
PUSH1 0xac
MLOAD
POP
MSIZE
PUSH32这条指令会先占用1字节表示自己,除此之外它的参数也要占去32字节,所以这一条指令实际上占了33字节,即0x20
个字节,所以它的下一条指令PUSH1 0xac
位于0x21
,以此类推,JUMPDEST应该在0x27
的位置。
如果用的是这个网站编写操作码,它会帮你算出指令位置:

JUMPI
JUMPI
指令用于条件跳转,它从堆栈中弹出两个元素,如果第二个元素(条件,condition
)不为0,那么将第一个元素(目标,destination
)设定为新的pc
的值。操作码是0x57
。
PUSH32 0xde
PUSH1 0xac
MSTORE
PUSH1 0x01 // 条件,这里我让它为真
PUSH1 0x29 // 注意刚刚说过的,JUMPDEST之前加了一条PUSH1指令,计数器应该后移2字节
JUMPI
JUMPDEST
PUSH1 0xac
MLOAD
POP
MSIZE
PC
PC
指令将当前的程序计数器(pc
)的值压入堆栈。操作码为0x58
。它压的是自己的位置,比如
[2f] PC
压进去的就是0x2f
。