深入了解以太坊虚拟机

深入了解以太坊虚拟机是一个系列的文章,一共5篇! 本文是第1篇,主要介绍的是以太坊虚拟机汇编代码基础。后续的4篇译文链接在本文的结尾处。

Solidity提供了很多高级语言的抽象概念,但是这些特性让人很难明白在运行程序的时候到底发生了什么。我阅读了Solidity的文档,但依旧存在着几个基本的问题没有弄明白。

string, bytes32, byte[], bytes之间的区别是什么?

  • 该在什么地方使用哪个类型?
  • 将 string 转换成bytes时会怎么样?可以转换成byte[]吗?
  • 它们的存储成本是多少?

EVM是如何存储映射( mappings)的?

  • 为什么不能删除一个映射?
  • 可以有映射的映射吗?(可以,但是怎样映射?)
  • 为什么存在存储映射,但是却没有内存映射?

编译的合约在EVM看来是什么样子的?

  • 合约是如何创建的?
  • 到底什么是构造器?
  • 什么是 fallback 函数?

我觉得学习在以太坊虚拟机(EVM)上运行的类似Solidity 高级语言是一种很好的投资,有几个原因:

  1. Solidity不是最后一种语言。更好的EVM语言正在到来。(拜托?)
  2. EVM是一个数据库引擎。要理解智能合约是如何以任意EVM语言来工作的,就必须要明白数据是如何被组织的,被存储的,以及如何被操作的。
  3. 知道如何成为贡献者。以太坊的工具链还处于早期,理解EVM可以帮助你实现一个超棒的工具给自己和其他人使用。
  4. 智力的挑战。EVM可以让你有个很好的理由在密码学、数据结构、编程语言设计的交集之间进行翱翔。

在这个系列的文章中,我会拆开一个简单的Solidity合约,来让大家明白它是如何以EVM字节码(bytecode)来运行的。

我希望能够学习以及会书写的文章大纲:

  • EVM字节码的基础认识
  • 不同类型(映射,数组)是如何表示的
  • 当一个新合约创建之后会发生什么
  • 当一个方法被调用时会发生什么
  • ABI如何桥接不同的EVM语言

我的最终目标是整体的理解一个编译的Solidity合约。让我们从阅读一些基本的EVM字节码开始。

EVM指令集将是一个比较有帮助的参考。

一个简单的合约

我们的第一个合约有一个构造器和一个状态变量:

1
2
3
4
5
6
7
8
// c1.sol
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C() {
a = 1;
}
}

solc来编译此合约:

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
$ solc --bin --asm c1.sol
======= c1.sol:C =======
EVM assembly:
/* "c1.sol":26:94 contract C {... */
mstore(0x40, 0x60)
/* "c1.sol":59:92 function C() {... */
jumpi(tag_1, iszero(callvalue))
0x0
dup1
revert
tag_1:
tag_2:
/* "c1.sol":84:85 1 */
0x1
/* "c1.sol":80:81 a */
0x0
/* "c1.sol":80:85 a = 1 */
dup2
swap1
sstore
pop
/* "c1.sol":59:92 function C() {... */
tag_3:
/* "c1.sol":26:94 contract C {... */
tag_4:
dataSize(sub_0)
dup1
dataOffset(sub_0)
0x0
codecopy
0x0
return
stop
sub_0: assembly {
/* "c1.sol":26:94 contract C {... */
mstore(0x40, 0x60)
tag_1:
0x0
dup1
revert
auxdata: 0xa165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
}
Binary:
60606040523415600e57600080fd5b5b60016000819055505b5b60368060266000396000f30060606040525b600080fd00a165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029

6060604052...这串数字就是EVM实际运行的字节码。

一小步一小步的来

上面一半的编译汇编是大多数Solidity程序中都会存在的样板语句。我们稍后再来看这些。现在,我们来看看合约中独特的部分,简单的存储变量赋值:

1
a = 1

代表这个赋值的字节码是6001600081905550。我们把它拆成一行一条指令:

1
2
3
4
5
6
60 01
60 00
81
90
55
50

EVM本质上就是一个循环,从上到下的执行每一条命令。让我们用相应的字节码来注释汇编代码(缩进到标签tag_2下),来更好的看看他们之间的关联:

1
2
3
4
5
6
7
8
9
10
11
12
13
tag_2:
// 60 01
0x1
// 60 00
0x0
// 81
dup2
// 90
swap1
// 55
sstore
// 50
pop

注意0x1在汇编代码中实际上是push(0x1)的速记。这条指令将数值1压入栈中。

只是盯着它依然很难明白到底发生了什么,不过不用担心,一行一行的模拟EVM是比较简单的。

模拟EVM

EVM是个堆栈机器。指令可能会使用栈上的数值作为参数,也会将值作为结果压入栈中。让我们来思考一下add操作。

假设栈上有两个值:

1
[1 2]

当EVM看见了add,它会将栈顶的2项相加,然后将答案压入栈中,结果是:

1
[3]

接下来,我们用[]符号来标识栈:

1
2
3
4
// 空栈
stack: []
// 有3个数据的栈,栈顶项为3,栈底项为1
stack: [3 2 1]

{}符号来标识合约存储器:

1
2
3
4
// 空存储
store: {}
// 数值0x1被保存在0x0的位置上
store: { 0x0 => 0x1 }

现在让我们来看看真正的字节码。我们将会像EVM那样来模拟6001600081905550字节序列,并打印出每条指令的机器状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 60 01:将1压入栈中
0x1
stack: [0x1]
// 60 00: 将0压入栈中
0x0
stack: [0x0 0x1]
// 81: 复制栈中的第二项
dup2
stack: [0x1 0x0 0x1]
// 90: 交换栈顶的两项数据
swap1
stack: [0x0 0x1 0x1]
// 55: 将数值0x01存储在0x0的位置上
// 这个操作会消耗栈顶两项数据
sstore
stack: [0x1]
store: { 0x0 => 0x1 }
// 50: pop (丢弃栈顶数据)
pop
stack: []
store: { 0x0 => 0x1 }

最后,栈就为空栈,而存储器里面有一项数据。

值得注意的是Solidity已经决定将状态变量uint256 a保存在0x0的位置上。其他语言完全可以选择将状态变量存储在其他的任何位置上。

6001600081905550字节序列在本质上用EVM的操作伪代码来表示就是:

1
2
// a = 1
sstore(0x0, 0x1)

仔细观察,你就会发现dup2swap1pop都是多余的,汇编代码可以更简单一些:

1
2
3
0x1
0x0
sstore

你可以模拟上面的3条指令,然后会发现他们的机器状态结果都是一样的:

1
2
stack: []
store: { 0x0 => 0x1 }

两个存储变量

让我们再额外的增加一个相同类型的存储变量:

1
2
3
4
5
6
7
8
9
10
// c2.sol
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
function C() {
a = 1;
b = 2;
}
}

编译之后,主要来看tag_2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ solc --bin --asm c2.sol
//前面的代码忽略了
tag_2:
/* "c2.sol":99:100 1 */
0x1
/* "c2.sol":95:96 a */
0x0
/* "c2.sol":95:100 a = 1 */
dup2
swap1
sstore
pop
/* "c2.sol":112:113 2 */
0x2
/* "c2.sol":108:109 b */
0x1
/* "c2.sol":108:113 b = 2 */
dup2
swap1
sstore
pop

汇编的伪代码:

1
2
3
4
// a = 1
sstore(0x0, 0x1)
// b = 2
sstore(0x1, 0x2)

我们可以看到两个存储变量的存储位置是依次排列的,a0x0的位置而b0x1的位置。

存储打包

每个存储槽都可以存储32个字节。如果一个变量只需要16个字节但是使用全部的32个字节会很浪费。Solidity为了高效存储,提供了一个优化方案:如果可以的话,就将两个小一点的数据类型进行打包然后存储在一个存储槽中。

我们将ab修改成16字节的变量:

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

编译此合约:

1
$ solc --bin --asm c3.sol

产生的汇编代码现在更加的复杂一些:

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
tag_2:
// a = 1
0x1
0x0
dup1
0x100
exp
dup2
sload
dup2
0xffffffffffffffffffffffffffffffff
mul
not
and
swap1
dup4
0xffffffffffffffffffffffffffffffff
and
mul
or
swap1
sstore
pop
// b = 2
0x2
0x0
0x10
0x100
exp
dup2
sload
dup2
0xffffffffffffffffffffffffffffffff
mul
not
and
swap1
dup4
0xffffffffffffffffffffffffffffffff
and
mul
or
swap1
sstore
pop

上面的汇编代码将这两个变量打包放在一个存储位置(0x0)上,就像这样:

1
2
[         b         ][         a         ]
[16 bytes / 128 bits][16 bytes / 128 bits]

进行打包的原因是因为目前最昂贵的操作就是存储的使用:

  • sstore指令第一次写入一个新位置需要花费20000 gas
  • sstore指令后续写入一个已存在的位置需要花费5000 gas
  • sload指令的成本是500 gas
  • 大多数的指令成本是3~10 gas

通过使用相同的存储位置,Solidity为存储第二个变量支付5000 gas,而不是20000 gas,节约了15000 gas。

更多优化

应该可以将两个128位的数打包成一个数放入内存中,然后使用一个’sstore’指令进行存储操作,而不是使用两个单独的sstore命令来存储变量ab,这样就额外的又省了5000 gas。

你可以通过添加optimize选项来让Solidity实现上面的优化:

1
$ solc --bin --asm --optimize c3.sol

这样产生的汇编代码只有一个sload指令和一个sstore指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
tag_2:
/* "c3.sol":95:96 a */
0x0
/* "c3.sol":95:100 a = 1 */
dup1
sload
/* "c3.sol":108:113 b = 2 */
0x200000000000000000000000000000000
not(sub(exp(0x2, 0x80), 0x1))
/* "c3.sol":95:100 a = 1 */
swap1
swap2
and
/* "c3.sol":99:100 1 */
0x1
/* "c3.sol":95:100 a = 1 */
or
sub(exp(0x2, 0x80), 0x1)
/* "c3.sol":108:113 b = 2 */
and
or
swap1
sstore

字节码是:

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
48
49
50
// push 0x0
60 00
// dup1
80
// sload
54
// push17 将下面17个字节作为一个32个字的数值压入栈中
70 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
/* not(sub(exp(0x2, 0x80), 0x1)) */
// push 0x1
60 01
// push 0x80 (32)
60 80
// push 0x80 (2)
60 02
// exp
0a
// sub
03
// not
19
// swap1
90
// swap2
91
// and
16
// push 0x1
60 01
// or
17
/* sub(exp(0x2, 0x80), 0x1) */
// push 0x1
60 01
// push 0x80
60 80
// push 0x02
60 02
// exp
0a
// sub
03
// and
16
// or
17
// swap1
90
// sstore
55

上面的汇编代码中使用了4个神奇的数值:

  • 0x1(16字节),使用低16字节

    1
    2
    3
    // 在字节码中表示为0x01
    16:32 0x00000000000000000000000000000000
    00:16 0x00000000000000000000000000000001
  • 0x2(16字节),使用高16字节

    1
    2
    3
    //在字节码中表示为0x200000000000000000000000000000000 
    16:32 0x00000000000000000000000000000002
    00:16 0x00000000000000000000000000000000
  • not(sub(exp(0x2, 0x80), 0x1))

    1
    2
    3
    // 高16字节的掩码
    16:32 0x00000000000000000000000000000000
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
  • sub(exp(0x2, 0x80), 0x1)

    1
    2
    3
    // 低16字节的掩码
    16:32 0x00000000000000000000000000000000
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF

代码将这些数值进行了一些位的转换来达到想要的结果:

1
2
16:32 0x00000000000000000000000000000002 
00:16 0x00000000000000000000000000000001

最后,该32字节的数值被保存在了0x0的位置上。

Gas 的使用

600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055

注意0x200000000000000000000000000000000被嵌入到了字节码中。但是编译器也可能选择使用exp(0x2, 0x81)指令来计算数值,这会导致更短的字节码序列。

但结果是0x200000000000000000000000000000000exp(0x2, 0x81)更便宜。让我们看看与gas费用相关的信息:

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

来计算下两个表示方式所花费的gas成本:

  • 0x200000000000000000000000000000000字节码包含了很多的0,更加的便宜。
    (1 68) + (32 4) = 196

  • 608160020a字节码更短,但是没有0。
    5 * 68 = 340

更长的字节码序列有很多的0,所以实际上更加的便宜!

总结

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

我们也看到了EVM一些奇特的地方:

  • EVM是一个256位的机器。以32字节来处理数据是最自然的
  • 持久存储是相当昂贵的
  • Solidity编译器会为了减少gas的使用而做出相应的优化选择

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

本系列文章其他部分译文链接:

翻译作者: 许莉
原文地址:Diving Into The Ethereum VM Part One

文章目录
  1. 1. 一个简单的合约
  2. 2. 一小步一小步的来
  3. 3. 模拟EVM
  4. 4. 两个存储变量
  5. 5. 存储打包
  6. 6. 更多优化
  7. 7. Gas 的使用
  8. 8. 总结