深入了解以太坊虚拟机

作者: Lilymoana | 来源:发表于2017-10-30 14:11 被阅读2221次

    本文由币乎社区(bihu.com)内容支持计划赞助。

    译者说,深入了解以太坊虚拟机是一个系列的文章,一共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指令集将是一个比较有帮助的参考。

    一个简单的合约

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

    // c1.sol
    pragma solidity ^0.4.11;
    contract C {
        uint256 a;
        function C() {
          a = 1;
        }
    }
    

    solc来编译此合约:

    $ 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程序中都会存在的样板语句。我们稍后再来看这些。现在,我们来看看合约中独特的部分,简单的存储变量赋值:

    a = 1
    

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

    60 01
    60 00
    81
    90
    55
    50
    

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

    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 2]
    

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

    [3]
    

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

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

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

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

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

    // 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的操作伪代码来表示就是:

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

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

    0x1
    0x0
    sstore
    

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

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

    两个存储变量

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

    // c2.sol
    pragma solidity ^0.4.11;
    contract C {
        uint256 a;
        uint256 b;
        function C() {
          a = 1;
          b = 2;
        }
    }
    

    编译之后,主要来看tag_2

    $ 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
    

    汇编的伪代码:

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

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

    存储打包

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

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

    pragma solidity ^0.4.11;
    contract C {
        uint128 a;
        uint128 b;
        function C() {
          a = 1;
          b = 2;
        }
    }
    

    编译此合约:

    $ solc --bin --asm c3.sol
    

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

    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)上,就像这样:

    [         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实现上面的优化:

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

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

    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
    

    字节码是:

    600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
    

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

    // 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字节
    // 在字节码中表示为0x01
    16:32 0x00000000000000000000000000000000
    00:16 0x00000000000000000000000000000001
    
    • 0x2(16字节),使用高16字节
    //在字节码中表示为0x200000000000000000000000000000000 
    16:32 0x00000000000000000000000000000002
    00:16 0x00000000000000000000000000000000
    
    • not(sub(exp(0x2, 0x80), 0x1))
    // 高16字节的掩码
    16:32 0x00000000000000000000000000000000 
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    
    • sub(exp(0x2, 0x80), 0x1)
    // 低16字节的掩码
    16:32 0x00000000000000000000000000000000 
    00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
    

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

    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

    相关文章

      网友评论

      • 1437d7aa4f5f:更多优化中 高16字节的掩码是不是有问题?
      • 277230e63609:第一章sload指令成本是500 gas,第二章变成了5000gas:cry: ,是我理解有误吗?
        梦中可:params/gas_table.go中的GasTable可以看到以前sload指令成本是200,现在是50了
        277230e63609:感谢,受益匪浅!
        Lilymoana:@区块链路上 我回去看了一下原文,两篇的确写的gas不一样,第一篇是500gas,第二篇是200gas,我写成了5000gas,可能是当时看差了,改过来了,跟原文保持一致。这错误可能是原文作者笔误什么的。sload指令成本到底是多少,我也没研究过
      • cc1229b97daf:看了之后很有收获,解决了最近好多的疑惑,不胜感谢!
        Lilymoana:@章鱼哥90 恩,我看到了,最近有点忙,应该会找个时间翻译出来
        cc1229b97daf:@Lilymoana :smile: 第六篇已经出来了 不知道会不会翻译呢
        Lilymoana:@章鱼哥90 能够对你有帮助,我也感到很荣幸:smile:

      本文标题:深入了解以太坊虚拟机

      本文链接:https://www.haomeiwen.com/subject/wqisuxtx.html