深入了解以太坊虚拟机

译者说,深入了解以太坊虚拟机是一个系列的文章,一共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.  
    // c1.sol
  2.  
    pragma solidity ^0.4.11;
  3.  
    contract C {
  4.  
    uint256 a;
  5.  
    function C() {
  6.  
    a = 1;
  7.  
    }
  8.  
    }
  9.  
     

solc来编译此合约:

  1.  
    $ solc --bin --asm c1.sol
  2.  
    ======= c1.sol:C =======
  3.  
    EVM assembly:
  4.  
    /* "c1.sol":26:94 contract C {... */
  5.  
    mstore(0x40, 0x60)
  6.  
    /* "c1.sol":59:92 function C() {... */
  7.  
    jumpi(tag_1, iszero(callvalue))
  8.  
    0x0
  9.  
    dup1
  10.  
    revert
  11.  
    tag_1:
  12.  
    tag_2:
  13.  
    /* "c1.sol":84:85 1 */
  14.  
    0x1
  15.  
    /* "c1.sol":80:81 a */
  16.  
    0x0
  17.  
    /* "c1.sol":80:85 a = 1 */
  18.  
    dup2
  19.  
    swap1
  20.  
    sstore
  21.  
    pop
  22.  
    /* "c1.sol":59:92 function C() {... */
  23.  
    tag_3:
  24.  
    /* "c1.sol":26:94 contract C {... */
  25.  
    tag_4:
  26.  
    dataSize(sub_0)
  27.  
    dup1
  28.  
    dataOffset(sub_0)
  29.  
    0x0
  30.  
    codecopy
  31.  
    0x0
  32.  
    return
  33.  
    stop
  34.  
    sub_0: assembly {
  35.  
    /* "c1.sol":26:94 contract C {... */
  36.  
    mstore(0x40, 0x60)
  37.  
    tag_1:
  38.  
    0x0
  39.  
    dup1
  40.  
    revert
  41.  
    auxdata: 0xa165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
  42.  
    }
  43.  
    Binary:
  44.  
    60606040523415600e57600080fd5b5b60016000819055505b5b60368060266000396000f30060606040525b600080fd00a165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029
  45.  
     

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

一小步一小步的来

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

a = 1

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

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

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

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

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

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

模拟EVM

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

假设栈上有两个值:

[1 2]

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

[3]

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

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

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

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

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

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

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

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

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

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

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

  1.  
    0x1
  2.  
    0x0
  3.  
    sstore

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

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

两个存储变量

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

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

编译之后,主要来看tag_2

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

汇编的伪代码:

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

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

存储打包

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

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

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

编译此合约:

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

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

  1.  
    tag_2:
  2.  
    // a = 1
  3.  
    0x1
  4.  
    0x0
  5.  
    dup1
  6.  
    0x100
  7.  
    exp
  8.  
    dup2
  9.  
    sload
  10.  
    dup2
  11.  
    0xffffffffffffffffffffffffffffffff
  12.  
    mul
  13.  
    not
  14.  
    and
  15.  
    swap1
  16.  
    dup4
  17.  
    0xffffffffffffffffffffffffffffffff
  18.  
    and
  19.  
    mul
  20.  
    or
  21.  
    swap1
  22.  
    sstore
  23.  
    pop
  24.  
    // b = 2
  25.  
    0x2
  26.  
    0x0
  27.  
    0x10
  28.  
    0x100
  29.  
    exp
  30.  
    dup2
  31.  
    sload
  32.  
    dup2
  33.  
    0xffffffffffffffffffffffffffffffff
  34.  
    mul
  35.  
    not
  36.  
    and
  37.  
    swap1
  38.  
    dup4
  39.  
    0xffffffffffffffffffffffffffffffff
  40.  
    and
  41.  
    mul
  42.  
    or
  43.  
    swap1
  44.  
    sstore
  45.  
    pop
  46.  
     

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

  1.  
    [ b ][ a ]
  2.  
    [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
  2.  
     

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

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

字节码是:

600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055

将字节码解析成一行一指令:

  1.  
    // push 0x0
  2.  
    60 00
  3.  
    // dup1
  4.  
    80
  5.  
    // sload
  6.  
    54
  7.  
    // push17 将下面17个字节作为一个32个字的数值压入栈中
  8.  
    70 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  9.  
    /* not(sub(exp(0x2, 0x80), 0x1)) */
  10.  
    // push 0x1
  11.  
    60 01
  12.  
    // push 0x80 (32)
  13.  
    60 80
  14.  
    // push 0x80 (2)
  15.  
    60 02
  16.  
    // exp
  17.  
    0a
  18.  
    // sub
  19.  
    03
  20.  
    // not
  21.  
    19
  22.  
    // swap1
  23.  
    90
  24.  
    // swap2
  25.  
    91
  26.  
    // and
  27.  
    16
  28.  
    // push 0x1
  29.  
    60 01
  30.  
    // or
  31.  
    17
  32.  
    /* sub(exp(0x2, 0x80), 0x1) */
  33.  
    // push 0x1
  34.  
    60 01
  35.  
    // push 0x80
  36.  
    60 80
  37.  
    // push 0x02
  38.  
    60 02
  39.  
    // exp
  40.  
    0a
  41.  
    // sub
  42.  
    03
  43.  
    // and
  44.  
    16
  45.  
    // or
  46.  
    17
  47.  
    // swap1
  48.  
    90
  49.  
    // sstore
  50.  
    55
  51.  
     

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

  • 0x1(16字节),使用低16字节
  1.  
    // 在字节码中表示为0x01
  2.  
    16:32 0x00000000000000000000000000000000
  3.  
    00:16 0x00000000000000000000000000000001
  4.  
     
  • 0x2(16字节),使用高16字节
  1.  
    //在字节码中表示为0x200000000000000000000000000000000
  2.  
    16:32 0x00000000000000000000000000000002
  3.  
    00:16 0x00000000000000000000000000000000
  4.  
     
  • not(sub(exp(0x2, 0x80), 0x1))
  1.  
    // 高16字节的掩码
  2.  
    16:32 0x00000000000000000000000000000000
  3.  
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
  4.  
     
  • sub(exp(0x2, 0x80), 0x1)
  1.  
    // 低16字节的掩码
  2.  
    16:32 0x00000000000000000000000000000000
  3.  
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
  4.  
     

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

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

最后,该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成本的设置有一点武断,也许未来会改变。当成本改变的时候,编译器也会做出不同的优化选择。

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

posted @ 2018-10-31 14:06  苦逼码农2014  阅读(457)  评论(0编辑  收藏  举报