以太坊vm系列2-基础篇

前言

vm这东西,要想自己搞任何链,这都是绕不开的一道坎。
对于写智能合约也一样很重要,要知道自己写下的每一个字符都是真金白银,合理的规划,会让你极大的减少不必要的开支。

原本想根据黄皮书来逐步了解vm的理念,时间和精力有限,打算结合一些现有的网络资料,编辑整理出自己对vm的理解。Howard是量子链的大神,有幸在以太坊线下沙龙中听了他的演讲,一个字:非常强。回来翻了下他的相关文章,发现他有很完整的以太坊vm讲解的文章,都是英文^^。。。
小编准备结合他的文章来以自己的方式了解到底vm是怎样运行的。

概述

一个合约写出来,传到vm后是如何处理的?燃料手续费是怎么样一个逻辑?为什么任意别的语言也可以开发合约?想要开发自己的公链,别的模块怎么和vm对接?等等。。。
带着种种问题,我们没有理由不去了解vm到底是怎么一回事。
好,我们一步步的来揭开这层神秘的面纱

解析只有一个变量的合约

solidity编写:

1
2
3
4
5
6
7
pragma solidity ^0.4.11;
contract C {
uint256 a;
constructor() public {
a = 1;
}
}

Remix编译(不要说不知道这是什么工具。。。)该合约,
我们获取了EVM字节码:
上面整个合约编辑后的字节码,16进制表示,evm中就是以这个来运行的

1
6080604052348015600f57600080fd5b50600160008190555060358060256000396000f3006080604052600080fd00a165627a7a72305820c2f00be46981ed7116a7d8162fd0cb5c04c4571aa49f5fccbea4b90a5fe8f9290029

·······································
2019-01-17 ps:
这部分内容可以不看,只是半年后的不同状况的补充,建议等了解本文全局后在看此处
新版本solidity编译后的字节码结果不太一样,可能对细节做了优化,先记录下来
6080604052348015600f57600080fd5b50600160005560358060226000396000f3006080604052600080fd00a165627a7a723058201504b6af3b58ab847e989f2345b4448a1d53d6107652ed711770c90cdb7d60890029
此时的a=1的字节码为:6001600055,指令优化,比原先减少了一半,此时指令为:
60 01
60 00
55
简洁明了,出栈后,直接把01存储在00位置,比原先简单多了
········································

上面的一坨信息,看的就头大,但不要急,我们先从合约中的简单的存储变量赋值来入手:

1
a = 1

这个过程的字节码是:6001600081905550(先不用考虑怎么得来的),我们通过查看以太坊vm系列1-指令集汇总,把它拆成一行一条指令

1
2
3
4
5
6
60 01 //60是PUSH1指令,因此后面要跟另一个16进制数
60 00 //同理
81
90
55
50

为了更清晰的了解上面的内容,我们用以太坊vm系列1-指令集汇总中查出的结果重新描述一下汇编代码:
stack[]表示栈,store{}表示存储器(后续均使用此表示)

1
2
3
4
5
6
7
8
9
10
11
PUSH1 01  //60 0x1,该过程可以用0x1表示,它是push(0x1)的速记。这条指令将数值1压入栈中。此时栈中数据stack[0x1]

PUSH1 00 //60 0x0,该过程可以用0x0表示,它是push(0x0)的速记。这条指令将数值0压入栈中。此时栈中数据stack[0x1 0x0]

DUP2 //81 ,从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x1 0x0 0x1]

SWAP1 //90 ,从栈顶起,将前两个数据交换。此时栈中数据stack[0x1 0x1 0x0]

SSTORE //55 ,栈顶推出两位,将数值0x01存储在存储器的0x0的位置上。此时栈中数据stack[0x1],存储器数据store{`0x1数值存在0x0地址`}

POP //50 ,丢弃栈顶数据。此时栈中数据stack[],存储器数据store{`0x1数值存在0x0地址`}

从上面分析中可知:Solidity是将uint256 a保存在0x0的位置上。

仔细观察,会发现DUP2SWAP1POP命令都是多余的,去掉后,会更清晰,并且运行结果也一样:

1
2
3
PUSH1 01  //60 0x1,该过程可以用0x1表示,它是push(0x1)的速记。这条指令将数值1压入栈中。此时栈中数据stack[0x1]
PUSH1 00 //60 0x0,该过程可以用0x0表示,它是push(0x0)的速记。这条指令将数值0压入栈中。此时栈中数据stack[0x1,0x0]
SSTORE //55 ,将数值0x01存储在存储器的0x0的位置上,此时会消耗掉栈顶的两项数据。此时栈中数据stack[0x1],存储器数据store{`0x1数值存在0x0地址`}

解析有两个变量的合约

代码如下:

1
2
3
4
5
6
7
8
9
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
constructor() public {
a = 1;
b = 2;
}
}

编译后,b=2的过程的EVM字节码(a=1前面已解析):

1
6002600181905550

对应的汇编指令:

1
2
3
4
5
6
PUSH1 0x2  //60 0x2,该过程可以用0x2表示,它是push(0x2)的速记。这条指令将数值2压入栈中。此时栈中数据stack[0x2]
PUSH1 0x1 //60 0x1,该过程可以用0x1表示,它是push(0x1)的速记。这条指令将数值2压入栈中。此时栈中数据stack[0x2 0x1]
DUP2 //81, 从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x2 0x1 0x2]
SWAP1 //90, 从栈顶起,将前两个数据交换。此时栈中数据stack[0x2 0x2 0x1]
SSTORE //55, 将数值0x02存储在存储器的0x1的位置上,此时会消耗掉栈顶的两项数据。此时栈中数据stack[0x2],存储器数据store{`0x02数值存在0x1地址`}
POP //50, 丢弃栈顶数据。此时栈中数据stack[],存储器数据store{`0x1数值存在0x0地址`,`0x2数值存在0x1地址`}

可知,最终:
store{0x1数值存在0x0地址,0x2数值存在0x1地址},也就是说a存在0x0地址,b存在0x1地址

存储打包(合约初步优化)

上面案例中,我们将每个数据都保存在了一个地址,其实每个地址可以看作是一个槽,一个槽可以放下32字节的。要是每个16字节的变量分别都放在32字节的不同槽中,会很浪费空间的,并且存储在不同的槽每次都需要很高的手续费,要知道手续费可不是闹着玩的。
Solidity提供的优化方案:尽量将小一点的两个数据打包并存储在同一个槽中。
把合约改为如下样子,a和b都使用uint128,也就是16个字节:

1
2
3
4
5
6
7
8
9
pragma solidity ^0.4.11;
contract C {
uint128 a;
uint128 b;
constructor() public {
a = 1;
b = 2;
}
}

编译后,a=1;b=2;对应EVM字节码:

1
60016000806101000a8154816fffffffffffffffffffffffffffffffff02191690836fffffffffffffffffffffffffffffffff1602179055506002600060106101000a8154816fffffffffffffffffffffffffffffffff02191690836fffffffffffffffffffffffffffffffff160217905550

对应汇编指令:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
a=1:
PUSH1 0x1 //60 0x1, 该过程可以用0x1表示,它是push(0x1)的速记。这条指令将数值1压入栈中。此时栈中数据stack[0x1]

PUSH1 0x0 //60 0x0, 该过程可以用0x0表示,它是push(0x0)的速记。这条指令将数值0压入栈中。此时栈中数据stack[0x1 0x0]

DUP1 //80, 从栈顶起,将栈中第1个元素复制并加入栈顶。此时栈中数据stack[0x1 0x0 0x0]

PUSH2 0x100 //61 0x100 占用2个字节,将值256压入栈。此时栈中数据stack[0x1 0x0 0x0 0x100]

EXP //0a 从栈顶起,依次取出2个元素,第一个元素为底数,第二个元素为指数,计算出结果为1,然后1字节存储到栈中。此时栈中数据stack[0x1 0x0 0x1]

DUP2 //81 从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x1 0x0 0x1 0x0]

SLOAD //54 取出栈顶元素,转为hash长度(表示在db中的地址),在db中是否存在对应值,并读取出,此处db中不存在对应数据,返回0x0,push到栈中。此时栈中数据stack[0x1 0x0 0x1 0x0]

DUP2 //81 从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x1 0x0 0x1 0x0 0x1]

PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF //6f 0xFF... 将16字节值0xFF...推入栈中。此时栈中数据stack[0x1 0x0 0x1 0x0 0x1 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

MUL //02 栈中两个元素相乘,结果存入栈中。此时栈中数据stack[0x1 0x0 0x1 0x0 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

NOT //19 栈顶数据取反,但实际运算还需要转为补码。此时栈中数据stack[0x1 0x0 0x1 0x0 0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000]

AND //16 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x1 0x0 0x1 0x0]

SWAP1 //90 从栈顶起,将前两个数据交换。此时栈中数据stack[0x1 0x0 0x0 0x1]

DUP4 //83 从栈顶起,将栈中第4个元素复制并加入栈顶。此时栈中数据stack[0x1 0x0 0x0 0x1 0x1]

PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF //6f 0xFF... 将16字节值0xFF...推入栈中。此时栈中数据stack[0x1 0x0 0x0 0x1 0x1 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

AND //16 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x1 0x0 0x0 0x1 0x1]

MUL //02 栈中两个元素相乘,结果存入栈中。此时栈中数据stack[0x1 0x0 0x0 0x1]

OR //17 栈中pop出栈顶元素,与栈中新的栈顶元素或运算,栈顶修改为新的运算结果。此时栈中数据stack[0x1 0x0 0x1]

SWAP1 //90 从栈顶起,将前两个数据交换。此时栈中数据stack[0x1 0x1 0x0]

SSTORE //55 栈顶开始,前两项。将数据0x1保存在0x0地址,此时store{`0x1数值存在0x0地址`}。此时栈中数据stack[0x1]

POP //50 栈中数据推出。此时栈中数据stack[]。

b=2:
PUSH1 0x2 //60 0x2, 该过程可以用0x2表示,它是push(0x2)的速记。这条指令将数值1压入栈中。此时栈中数据stack[0x2]

PUSH1 0x0 //60 0x0, 该过程可以用0x0表示,它是push(0x0)的速记。这条指令将数值0压入栈中。此时栈中数据stack[0x2,0x0]

PUSH1 0x10 //60 0x10, 将数值0x10压入栈中。此时栈中数据stack[0x2,0x0,0x10]

PUSH2 0x100 //60 0x100, 将数值0x100压入栈中。此时栈中数据stack[0x2,0x0,0x10,0x100]

EXP //0a 从栈顶起,依次取出2个元素,第一个元素为底数,第二个元素为指数,计算结果进栈。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000]

DUP2 //81 从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x0]

SLOAD //54 取出栈顶元素,转为hash长度(表示在db中的地址),在db中读取出对应值为1,将其压入栈中。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1]

DUP2 //81 从栈顶起,将栈中第2个元素复制并加入栈顶。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1,0x100000000000000000000000000000000]

PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF //6f 0xFF... 将16字节值0xFF...推入栈中。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1,0x100000000000000000000000000000000,0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

MUL //02 栈中两个元素相乘,结果存入栈中。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1,0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000]

NOT //19 栈顶数据取反,但实际运算还需要转为补码。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1,0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

AND //16 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x2,0x0,0x100000000000000000000000000000000,0x1]

SWAP1 //90 从栈顶起,将前两个数据交换。此时栈中数据stack[0x2,0x0,0x1,0x100000000000000000000000000000000]

DUP4 //83 从栈顶起,将栈中第4个元素复制并加入栈顶。此时栈中数据stack[0x2,0x0,0x1,0x100000000000000000000000000000000,0x2]

PUSH16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF //6f 0xFF... 将16字节值0xFF...推入栈中。此时栈中数据stack[0x2,0x0,0x1,0x100000000000000000000000000000000,0x2,0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

AND //16 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x2,0x0,0x1,0x100000000000000000000000000000000,0x2]

MUL //02 栈中两个元素相乘,结果存入栈中。此时栈中数据stack[0x2,0x0,0x1,0x200000000000000000000000000000000]

OR //17 栈中pop出栈顶元素,与栈中新的栈顶元素或运算,栈顶修改为新的运算结果。此时栈中数据stack[0x2,0x0,0x200000000000000000000000000000000]

SWAP1 //90 从栈顶起,将前两个数据交换。此时栈中数据stack[0x2,0x200000000000000000000000000000000,0x0]

SSTORE //55 栈顶开始,前两项。将数据0x200000000000000000000000000000000保存在0x0地址,此时store{`0x200000000000000000000000000000000数值存在0x0地址`,`0x1数值存在0x0地址`}。此时栈中数据stack[0x2]

POP //50 栈中数据推出。此时栈中数据stack[]。

一路把这些蛋疼的指令整理后,会发现a=1b=2都存在了同一个地址,完整的存储形式是这样子:[0x0000000000000000000000000000000200000000000000000000000000000001],低16个字节存1,高16个字节存2,倒数第二行的SSTORE的指令应该是进行了或操作。
指令分析完了,有什么用呢?先看看各指令手续费情况:

  1. sstore指令第一次写入一个新位置需要花费20000 gas
  2. sstore指令后续写入一个已存在的位置需要花费5000 gas
  3. sload指令的成本是200 gas
  4. 其余大多数的指令成本是3~10 gas

知道手续费成本后,我们就明白,将两个变量写入同一个槽中,按照上面指令:sstore指令使用2次,费用25000gas;sload指令使用2次,费用1000gas;整体大概用了26000的gas。这比分别写在不同槽中,节省了将近15000gas。
这里不知道大家是否发现,SLOAD命令,我们明明知道这个0x0地址没有值,结果肯定是值0x0,却还要用指令去读取,无辜花费了手续费,这是Solidity语言的不足之处。此处若直接用值0x0替代SLOAD,将会剩下不少手续费,期待后面Solidity语言的优化吧。

合约进一步优化

从上面的指令中进一步研究发现,如果能将a=1b=2这两个128位的数只调用一次sstroe存储指令就将其存储,还可以再节省5000gas。
Solidity中提供了这样一个方式来实现此目的,即在编译命令中加入--optimize,如:solc --bin --asm --optimize test.sol
小编使用的是Remix,如下图操作选中optimize即可:

(合约源码紧接上一部分的)编译后,a=1;b=2;对应EVM字节码:

1
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055

对应汇编指令:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
PUSH1 0x0                                   //60 0x0,      该过程可以用0x0表示,它是push(0x0)的速记。这条指令将数值0压入栈中。此时栈中数据stack[0x0]

DUP1 //80, 从栈顶起,将栈中第1个元素复制并加入栈顶。此时栈中数据stack[0x0 0x0]

SLOAD //54, 取出栈顶元素,转为hash长度(表示在db中的地址),在db中是否存在对应值,并读取出,此处db中不存在对应数据,返回0x0,push到栈中。此时栈中数据stack[0x0 0x0]

PUSH17 0x200000000000000000000000000000000 //70 0x200..., 将17字节值0x200...推入栈中。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000]

PUSH1 0x1 //60 0x1, 将值0x1压入栈。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0x1]

PUSH1 0x80 //60 80, 将值0x80压入栈。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0x1 0x80]

PUSH1 0x2 //60 0x2, 将值0x2压入栈。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0x1 0x80 0x2]

EXP //0a, 从栈顶起,依次取出2个元素,第一个元素为底数,第二个元素为指数,计算结果进栈。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0x1 0x100000000000000000000000000000000]

SUB //03, 栈中推出栈顶元素,减去新的栈顶元素,栈顶修改为新的运算结果。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

NOT //19, 栈顶数据取反,但实际运算还需要转为补码。此时栈中数据stack[0x0 0x0 0x200000000000000000000000000000000 0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000]

SWAP1 //90, 从栈顶起,将前两个数据交换。此时栈中数据stack[0x0 0x0 0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000 0x200000000000000000000000000000000]

SWAP2 //91, 栈顶元素和它下面的第2项进行交换,此时栈中数据stack[0x0 0x200000000000000000000000000000000 0XFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000000000000000000000000000 0x0]

AND //16, 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x0]

PUSH1 0x1 //60 0x1, 将值0x1压入栈。。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x0 0x1]

OR //17, 栈中pop出栈顶元素,与栈中新的栈顶元素或运算,栈顶修改为新的运算结果。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1]

PUSH1 0x1 //60 0x1, 将值0x1压入栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1 0x1]

PUSH1 0x80 //60 80, 将值0x80压入栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1 0x1 0x80]

PUSH1 0x2 //60 0x2, 将值0x2压入栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1 0x1 0x80 0x2]

EXP //0a, 从栈顶起,依次取出2个元素,第一个元素为底数,第二个元素为指数,计算结果进栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1 0x1 0x100000000000000000000000000000000]

SUB //03, 栈中推出栈顶元素,减去新的栈顶元素,栈顶修改为新的运算结果。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF]

AND //16, 栈中取出前两个数据进行与运算,结果推入栈。此时栈中数据stack[0x0 0x200000000000000000000000000000000 0x1]

OR //17, 栈中pop出栈顶元素,与栈中新的栈顶元素或运算,栈顶修改为新的运算结果。此时栈中数据stack[0x0 0x200000000000000000000000000000001]

SWAP1 //90, 从栈顶起,将前两个数据交换。此时栈中数据stack[0x200000000000000000000000000000001 0x0]

SSTORE //55, 栈顶开始,前两项。将数值0x200000000000000000000000000000001存储在存储器的0x0的位置上,此时会消耗掉栈顶的两项数据。此时栈中数据stack[],存储器数据store{`0x200000000000000000000000000000001数值存在0x0地址`}

从上面指令中可以看出,Solidity使用了一系列的运算,生成0x200000000000000000000000000000001,然后一次性将其写入存储器中,正好32个字节。

关于Gas的考虑

关于上面合约中a=1;b=2的字节码,仔细观察:

1
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055

我们发现字节码中直接使用了200000000000000000000000000000000,而不是用指令exp(0x2, 0x81)来计算,原因很简单,因为前者便宜,我们来看看对于手续费的标准:

  1. 一笔交易的每个零字节的数据或代码费用为4 gas
  2. 一笔交易的每个非零字节的数据或代码的费用为68 gas

根据这个标准,我们来计算一下200000000000000000000000000000000的费用:

  1. 一个非0数2,费用为68gas
  2. 32个0,费用为32*4=128gas
  3. 公共gas费用:128+68=196gas

再来计算一下使用exp(0x2, 0x81)费用:

  1. exp(0x2, 0x81)的字节码为:608160020a,即指令:PUSH 0x81 PUSH 0x02 EXP
  2. 其中代码:60600a总共3个,费用为3*68=204gas
  3. 非0数:81、02,费用为2*68=136gas
  4. 总共gas费用204+136=340gas

看,虽然后者字节码短,但是费用却要高很多。因此Solidity的字节码会生成为前者。

总结

EVM的编译器实际上不会为字节码的大小、速度或内存高效性进行优化。相反,它会为gas的使用进行优化,这间接鼓励了计算的排序,让以太坊区块链可以更高效一点。
我们也看到了EVM一些奇特的地方:

  1. EVM是一个256位的机器。以32字节来处理数据是最自然的
  2. 持久存储是相当昂贵的
  3. Solidity编译器会为了减少gas的使用而做出相应的优化选择
  4. 指令中涉及的位操作,小编也没有深究,也没有太多精力去深究,简单说一句是:位操作主要是用来优化汇编操作的,由Solidity的编译器来处理。

Gas成本的设置有一点武断,也许未来会改变。当成本改变的时候,编译器也会做出不同的优化选择。

量子链-Howard英文原文:https://blog.qtum.org/diving-into-the-ethereum-vm-6e8d5d2f3c30
xuli中文翻译:https://lilymoana.github.io/evm_part1.html

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2017-2023 Jason
  • Visitors: | Views:

谢谢打赏~

微信