EVM指令集

EVM中的代码是合约编译过来的字节码。其中构成字节码的一个EVM指令集大小为一个字节(8bit),所以EVM总指令集最多由256个,目前已存在100多个指令(5C-5E 没有使用)。

EVM执行从EVM Code中读取到的指令集,stack能从memory、calldata或storage一次加载一个字大小的数据(4byte),将指令使用的值放入栈内进行操作得到结果后将其值返回。

EVM_run_tmie

下面会从solidity汇编常用的指令去进行代码展示。EVM指令集详细细节查询:EVM Codes - An Ethereum Virtual Machine Opcodes Interactive Reference

EVM指令提供了以下几个功能:

  • 算术和位逻辑运算
  • 执行上下文查询
  • 堆栈、内存和存储访问
  • 控制流操作
  • Logging, calling和其他操作符

算术运算

1
2
3
4
5
6
7
8
9
10
11
12
ADD        //将堆栈顶部的两个项相加
MUL //将堆栈顶部的两个项相乘
SUB //减去堆栈顶部的两个项
DIV //整数除法
SDIV //有符号整数除法
MOD //取模(余数)操作
SMOD //有符号取模操作
ADDMOD //对任意数字进行加法取模
MULMOD //对任意数字进行乘法取模
EXP //指数运算
SIGNEXTEND //扩展二进制补码有符号整数的长度
SHA3 //计算内存块的 Keccak-256 哈希

堆栈操作

1
2
3
4
5
6
7
8
9
10
POP     //从堆栈中移除顶部项
MLOAD //从内存中加载一个字
MSTORE //将一个字保存到内存中
MSTORE8 //将一个字节保存到内存中
SLOAD //从存储中加载一个字
SSTORE //将一个字保存到存储中
MSIZE //获取活动内存的大小(以字节为单位)
PUSHx //将 x 字节项目放入堆栈,其中 x 可以是从 1 到 32(完整字)的任何整数
DUPx //复制第 x 个堆栈项,其中 x 可以是从 1 到 16 的任何整数
SWAPx //交换第 1 个和第(x +1)个堆栈项,其中 x 可以是从 1 到 16 的任何整数

流程控制操作码

1
2
3
4
5
STOP      //停止执行
JUMP //将程序计数器设置为任何值
JUMPI //有条件地更改程序计数器
PC //获取程序计数器的值(在增量之前对应于此指令)
JUMPDEST //标记跳转的有效目的地

系统操作

1
2
3
4
5
6
7
8
9
10
LOGx          //附加具有 x 个主题的日志记录,其中 x 是从 0 到 4 的任何整数
CREATE //创建一个带有关联代码的新帐户
CALL //消息调用到另一个帐户,即运行另一个帐户的代码
CALLCODE //消息调用到此帐户与另一个帐户的代码
RETURN //停止执行并返回输出数据
DELEGATECALL //使用替代帐户的代码向此帐户发送消息调用,但保留当前发送者和价值的值
STATICCALL //静态消息调用到一个帐户
REVERT //停止执行,恢复状态更改但返回数据和剩余 gas
INVALID //指定的无效指令
SELFDESTRUCT //停止执行并注册帐户以进行删除逻辑操作:用于比较和位逻辑的操作码:

逻辑运算

1
2
3
4
5
6
7
8
9
10
11
LT     //小于比较
GT //大于比较
SLT //有符号小于比较
SGT //有符号大于比较
EQ //相等比较
ISZERO //简单的非运算
AND //按位与操作
OR //按位或操作
XOR //按位异或操作
NOT //按位非操作
BYTE //从完整的 256 位宽字中检索单个字节

环境操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GAS            //获取可用 gas 的数量(在减少此指令的 gas 后)
ADDRESS //获取当前执行账户的地址
BALANCE //获取任何给定账户的账户余额
ORIGIN //获取启动此 EVM 执行的 EOA 的地址
CALLER //获取立即负责此执行的调用者的地址
CALLVALUE //获取由负责此执行的调用者存入的以太币金额
CALLDATALOAD //获取由负责此执行的调用者发送的输入数据
CALLDATASIZE //获取输入数据的大小
CALLDATACOPY //将输入数据复制到内存
CODESIZE //获取当前环境中运行的代码大小
CODECOPY //将当前环境中运行的代码复制到内存
GASPRICE //获取由发起交易指定的 gas 价格
EXTCODESIZE //获取任何账户的代码大小
EXTCODECOPY //将任何账户的代码复制到内存
RETURNDATASIZE //获取当前环境中上一次调用的输出数据大小
RETURNDATACOPY //将上一次调用的数据输出复制到内存

区块操作码

1
2
3
4
5
6
BLOCKHASH  //获取最近完成的 256 个区块之一的哈希
COINBASE //获取区块奖励的区块受益人地址
TIMESTAMP //获取区块的时间戳
NUMBER //获取区块的编号
DIFFICULTY //获取区块的难度
GASLIMIT //获取区块的 gas 限制

编译合约

工具准备

笔者编译合约使用以下工具

  • solcjs: 使用solcjs能够获得合约的二进制代码。

    1
    2
    3
    4
    5
    6
    # 安装solcjs
    npm install solcjs
    # 查看版本
    solcjs -V
    # 指定合约二进制代码输出路径
    solcjs --bin --output-dir out example.sol
  • Remix:Remix能够获得合约字节码和将字节码指定为操作码。

编译合约

选用remix的代码案例进行分析。下面是一个存储合约,代码函数由两部分组成。

两个函数的选择器

  • store(uint256):6057361d
  • retrieve():2e64cec1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.8.2 <0.9.0;

/**
* @title Storage
* @dev Store & retrieve value in a variable
* @custom:dev-run-script ./scripts/deploy_with_ethers.ts
*/
contract Storage {

uint256 number;

/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}

/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}

字节码分析

该部分字节码通过remix获得。object部分对应合约字节码,opcode部分对应字节码的操作码。

1
2
3
4
{
"object": "608060405234801561000f575f80fd5b506101438061001d5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80632e64cec1146100385780636057361d14610056575b5f80fd5b610040610072565b60405161004d919061009b565b60405180910390f35b610070600480360381019061006b91906100e2565b61007a565b005b5f8054905090565b805f8190555050565b5f819050919050565b61009581610083565b82525050565b5f6020820190506100ae5f83018461008c565b92915050565b5f80fd5b6100c181610083565b81146100cb575f80fd5b50565b5f813590506100dc816100b8565b92915050565b5f602082840312156100f7576100f66100b4565b5b5f610104848285016100ce565b9150509291505056fea26469706673582212205b47cee55f559ebab97cf0c7dc7a6cef1aba6f07a98a0a25c74b22ba6701722364736f6c63430008160033",
"opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0xF JUMPI PUSH0 DUP1 REVERT JUMPDEST POP PUSH2 0x143 DUP1 PUSH2 0x1D PUSH0 CODECOPY PUSH0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0xF JUMPI PUSH0 DUP1 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH2 0x34 JUMPI PUSH0 CALLDATALOAD PUSH1 0xE0 SHR DUP1 PUSH4 0x2E64CEC1 EQ PUSH2 0x38 JUMPI DUP1 PUSH4 0x6057361D EQ PUSH2 0x56 JUMPI JUMPDEST PUSH0 DUP1 REVERT JUMPDEST PUSH2 0x40 PUSH2 0x72 JUMP JUMPDEST PUSH1 0x40 MLOAD PUSH2 0x4D SWAP2 SWAP1 PUSH2 0x9B JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST PUSH2 0x70 PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 PUSH2 0x6B SWAP2 SWAP1 PUSH2 0xE2 JUMP JUMPDEST PUSH2 0x7A JUMP JUMPDEST STOP JUMPDEST PUSH0 DUP1 SLOAD SWAP1 POP SWAP1 JUMP JUMPDEST DUP1 PUSH0 DUP2 SWAP1 SSTORE POP POP JUMP JUMPDEST PUSH0 DUP2 SWAP1 POP SWAP2 SWAP1 POP JUMP JUMPDEST PUSH2 0x95 DUP2 PUSH2 0x83 JUMP JUMPDEST DUP3 MSTORE POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 ADD SWAP1 POP PUSH2 0xAE PUSH0 DUP4 ADD DUP5 PUSH2 0x8C JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 DUP1 REVERT JUMPDEST PUSH2 0xC1 DUP2 PUSH2 0x83 JUMP JUMPDEST DUP2 EQ PUSH2 0xCB JUMPI PUSH0 DUP1 REVERT JUMPDEST POP JUMP JUMPDEST PUSH0 DUP2 CALLDATALOAD SWAP1 POP PUSH2 0xDC DUP2 PUSH2 0xB8 JUMP JUMPDEST SWAP3 SWAP2 POP POP JUMP JUMPDEST PUSH0 PUSH1 0x20 DUP3 DUP5 SUB SLT ISZERO PUSH2 0xF7 JUMPI PUSH2 0xF6 PUSH2 0xB4 JUMP JUMPDEST JUMPDEST PUSH0 PUSH2 0x104 DUP5 DUP3 DUP6 ADD PUSH2 0xCE JUMP JUMPDEST SWAP2 POP POP SWAP3 SWAP2 POP POP JUMP INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 JUMPDEST SELFBALANCE 0xCE 0xE5 PUSH0 SSTORE SWAP15 0xBA 0xB9 PUSH29 0xF0C7DC7A6CEF1ABA6F07A98A0A25C74B22BA6701722364736F6C634300 ADDMOD AND STOP CALLER ",
}

合约部署

使用remix的调试器对合约部署交易进行分析。合约部署时,EVM会执行到字节码第27字节时结束。下面开始分析合约部署时,opcode的逻辑。

set_2

合约部署相关操作码主要发生在第15-27字节之间。我们可以先通过前四个指令开始研究EVM指令集是怎样工作的。

首先push1是将1字节压入栈中,可以使用push32指令可以压入32字节到栈。指令进行了两次push1操作

1
PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE 

然后使用了mstore(offset,vaule)将0x08存入内存中。

set_3

下一个操作码是CALLVALUE,这是一个环境操作码,它将启动此执行的消息调用发送的以太数量(以wei为单位)推到堆栈的顶部。

合约部署时会通过codecopy将合约操作码copy到内存中,在代码执行到jump时,会跳转到0xf位置,即下面的JUMPDEST。

1
JUMPDEST POP PUSH2 0x143 DUP1 PUSH2 0x1D PUSH0 CODECOPY PUSH0 RETURN

此时栈的状态如下:

set_4

让我们跳到最重要的一步:codecopy(destOffset, offset, size),该指令会将当前环境下运行的代码复制到内存中,其中destOffset是复制到内存的偏移量,offset是复制代码中的字节偏移量,size是复制的代码字节大小。

set_5

codecopy从栈顶拿到三个值,复制到代码0x00位置,从当前环境(calldata)中的1d位置开始,大小为143字节。可以看到memory中复制的字节码。

set_6

执行函数

使用remix调用store函数,存入5到合约中。交易的calldata如下:

1
"0x6057361d0000000000000000000000000000000000000000000000000000000000000005"

执行store函数时从第26字节开始,第26字节位置使用了calldataload(i)。calldataload是从calldata指定偏移i读取32字节到栈中。然后通过shr(shift, value)将value向右偏移0xE0位

1
CALLDATALOAD PUSH1 0xE0 SHR 

set_7

calldata读到的是store的函数选择器,指令会比较函数选择器跳转到相应位置。下面两行是两个函数选择器,根据操作码逻辑,先使用dup对栈中0的值进行复制,然后push4 向栈中推入retrieve()的选择器进行比较,eq会返回0。

1
DUP1 PUSH4 0x2E64CEC1 EQ PUSH2 0x38 JUMPI 

set_8

jumpI(counter, b)能跳转到其他指令,其中counter是将继续执行的部署代码中的字节偏移量(跳到的指令必须是JUMPDEST),b当该值不等于0是才会跳转。在下图中,栈顶的两个值为0x38,0x00,由于b等于0,jumpI不会跳转。

set_9

同上,但是jumpI操作码能够跳转到0x56位置,因为calldata传入的选择器与栈顶顶的值一致。(注意remix使用十进制对应指令位置,即跳转到86)

1
DUP1 PUSH4 0x6057361D EQ PUSH2 0x56 JUMPI

set_10

现在我们直接跳到即将结束的一步,EVM会使用sstore(key,value)将给定的参数5存储在对应合约地址的key-0:value-5的位置。

set_11

使用sstore后storage变化

set_12

使用内联汇编减少gas消耗

上面我们学习了指令集,不仅调试的时候可以使用它进行分析,我们还能够使用它在solidity中写内联汇编。可以通过assembly块开始一段代码。

1
2
3
assembly{
// EVM汇编语言
}

下面进行一个简单的演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract assTest{

function add1(uint256 a, uint256 b) public pure returns (uint) {
return a + b;
}

function add2(uint x, uint y) public pure returns (uint) {
assembly {
let result := add(x, y)
mstore(0x0, result)
return(0x0, 32)
}
}
}

从remix的获得的两个函数消耗gas进行比较(可能因环境不同有差异)

  • 前者使用:949 gas
  • 后者使用:561 gas

引用:

1.ethereumbook/13evm.asciidoc at develop · ethereumbook/ethereumbook (github.com)

2.EVM Codes - An Ethereum Virtual Machine Opcodes Interactive Reference