深刻了解以太坊虚拟机第2部分——固定长度数据类型的表示方法

在本系列的第一篇文章中,咱们已经看到了一个简单的Solidity合约的汇编代码:javascript

contract C {
    uint256 a;
    function C() { a = 1; } } 

该合约归结于sstore指令的调用:php

// a = 1 sstore(0x0, 0x1) 
  • EVM将0x1数值存储在0x0的位置上
  • 每一个存储槽能够存储正好32字节(或256位)

若是你以为这看起来很陌生,我建议你阅读本系列的第一篇文章:EVM汇编代码的介绍css

在本文中咱们将会开始研究Solidity如何使用32字节的块来表示更加复杂的数据类型如结构体和数组。咱们也将会看到存储是如何被优化的,以及优化是如何失败的。java

在典型编程语言中理解数据类型在底层是如何表示的没有太大的做用。可是在Solidity(或其余的EVM语言)中,这个知识点是很是重要的,由于存储的访问是很是昂贵的:数据库

  • sstore指令成本是20000 gas,或比基本的算术指令要贵~5000x
  • sload指令成本是 200 gas,或比基本的算术指令要贵~100x

这里说的成本,就是真正的金钱,而不只仅是毫秒级别的性能。运行和使用合约的成本基本上是由sstore指令和sload指令来主导的!编程

Parsecs磁带上的Parsecs

 
图林机器,来源:http://raganwald.com/

构建一个通用计算机器须要两个基本要素:数组

  • 一种循环的方式,不管是跳转仍是递归
  • 无限量的内存

EVM的汇编代码有跳转,EVM的存储器提供无限的内存。这对于一切就已经足够了,包括模拟一个运行以太坊的世界,这个世界自己就是一个模拟运行以太坊的世界.........编程语言

 
进入Microverse电池

EVM的存储器对于合约来讲就像一个无限的自动收报机磁带,磁带上的每一个槽都能存储32个字节,就像这样:函数

[32 bytes][32 bytes][32 bytes]... 

咱们将会看到数据是如何在无限的磁带中生存的。布局

磁带的长度是2²⁵⁶,或者每一个合约~10⁷⁷存储槽。可观测的宇宙粒子数是10⁸⁰。大概1000个合约就能够容纳全部的质子、中子和电子。不要相信营销炒做,由于它比无穷大要短的多。

空磁带

存储器初始的时候是空白的,默认是0。拥有无限的磁带不须要任何的成本。

以一个简单的合约来演示一下0值的行为:

pragma solidity ^0.4.11; contract C { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; function C() { f = 0xc0fefe; } } 

存储器中的布局很简单。

  • 变量a0x0的位置上
  • 变量b0x1的位置上
  • 以此类推.........

关键问题是:若是咱们只使用f,咱们须要为abcde支付多少成本?

编译一下再看:

$ solc --bin --asm --optimize c-many-variables.sol 

汇编代码:

// sstore(0x5, 0xc0fefe) tag_2: 0xc0fefe 0x5 sstore 

因此一个存储变量的声明不须要任何成本,由于没有初始化的必要。Solidity为存储变量保留了位置,可是只有当你存储数据进去的时候才须要进行付费。

这样的话,咱们只须要为存储0x5进行付费。

若是咱们手动编写汇编代码的话,咱们能够选择任意的存储位置,而用不着"扩展"存储器:

// 编写一个任意的存储位置 sstore(0xc0fefe, 0x42) 

读取零

你不只能够写在存储器的任意位置,你还能够马上读取任意的位置。从一个未初始化的位置读取只会返回0x0

让咱们看看一个合约从一个未初始化的位置a读取数据:

pragma solidity ^0.4.11; contract C { uint256 a; function C() { a = a + 1; } } 

编译:

$ solc --bin --asm --optimize c-zero-value.sol 

汇编代码:

tag_2:
  // sload(0x0) returning 0x0 0x0 dup1 sload // a + 1; where a == 0 0x1 add // sstore(0x0, a + 1) swap1 sstore 

注意生成从一个未初始化的位置sload的代码是无效的。

然而,咱们能够比Solidity编译器聪明。既然咱们知道tag_2是构造器,并且a从未被写入过数据,那么咱们能够用0x0替换掉sload,以此节省5000 gas。

结构体的表示

来看一下咱们的第一个复杂数据类型,一个拥有6个域的结构体:

pragma solidity ^0.4.11; contract C { struct Tuple { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; } Tuple t; function C() { t.f = 0xC0FEFE; } } 

存储器中的布局和状态变量是同样的:

  • t.a域在0x0的位置上
  • t.b域在0x1的位置上
  • 以此类推.........

就像以前同样,咱们能够直接写入t.f而不用为初始化付费。

编译一下:

$ solc --bin --asm --optimize c-struct-fields.sol 

而后咱们看见如出一辙的汇编代码:

tag_2:
  0xc0fefe
  0x5
  sstore

固定长度数组

让咱们来声明一个定长数组:

pragma solidity ^0.4.11; contract C { uint256[6] numbers; function C() { numbers[5] = 0xC0FEFE; } } 

由于编译器知道这里到底有几个uint256(32字节)类型的数值,因此它能够很容易让数组里面的元素依次存储起来,就像它存储变量和结构体同样。

在这个合约中,咱们再次存储到0x5的位置上。

编译:

$ solc --bin --asm --optimize c-static-array.sol 

汇编代码:

tag_2:
  0xc0fefe
  0x0
  0x5
tag_4:
  add
  0x0
tag_5:
  pop
  sstore

这个稍微长一点,可是若是你仔细一点,你会看见它们实际上是同样的。咱们手动的来优化一下:

tag_2:
  0xc0fefe // 0+5. 替换为0x5 0x0 0x5 add // 压入栈中而后马上出栈。没有做用,只是移除 0x0 pop sstore 

移除掉标记和伪指令以后,咱们再次获得相同的字节码序列:

tag_2:
  0xc0fefe
  0x5
  sstore

数组边界检查

咱们看到了定长数组、结构体和状态变量在存储器中的布局是同样的,可是产生的汇编代码是不一样的。这是由于Solidity为数组的访问产生了边界检查代码。

让咱们再次编译数组合约,此次去掉优化的选项:

$ solc --bin --asm c-static-array.sol 

汇编代码在下面已经注释了,而且打印出每条指令的机器状态:

tag_2:
  0xc0fefe [0xc0fefe] 0x5 [0x5 0xc0fefe] dup1 /* 数组边界检查代码 */ // 5 < 6 0x6 [0x6 0x5 0xc0fefe] dup2 [0x5 0x6 0x5 0xc0fefe] lt [0x1 0x5 0xc0fefe] // bound_check_ok = 1 (TRUE) // if(bound_check_ok) { goto tag5 } else { invalid } tag_5 [tag_5 0x1 0x5 0xc0fefe] jumpi // 测试条件为真,跳转到 tag_5. // `jumpi` 从栈中消耗两项数据 [0x5 0xc0fefe] invalid // 数据访问有效,继续执行 // stack: [0x5 0xc0fefe] tag_5: sstore [] storage: { 0x5 => 0xc0fefe } 

咱们如今已经看见了边界检查代码。咱们也看见了编译器能够对这类东西进行一些优化,可是不是很是完美。

在本文的后面咱们将会看到数组的边界检查是如何干扰编译器优化的,比起存储变量和结构体,定长数组的效率更低。

打包行为

存储是很是昂贵的(呀呀呀,这句话我已经说了无数次了)。一个关键的优化就是尽量的将数据打包成一个32字节数值。

考虑一个有4个存储变量的合约,每一个变量都是64位,所有加起来就是256位(32字节):

pragma solidity ^0.4.11; contract C { uint64 a; uint64 b; uint64 c; uint64 d; function C() { a = 0xaaaa; b = 0xbbbb; c = 0xcccc; d = 0xdddd; } } 

咱们指望(但愿)编译器使用一个sstore指令将这些数据存放到同一个存储槽中。

编译:

$ solc --bin --asm --optimize c-many-variables--packing.sol 

汇编代码:

tag_2:
    /* "c-many-variables--packing.sol":121:122 a */ 0x0 /* "c-many-variables--packing.sol":121:131 a = 0xaaaa */ dup1 sload /* "c-many-variables--packing.sol":125:131 0xaaaa */ 0xaaaa not(0xffffffffffffffff) /* "c-many-variables--packing.sol":121:131 a = 0xaaaa */ swap1 swap2 and or not(sub(exp(0x2, 0x80), exp(0x2, 0x40))) /* "c-many-variables--packing.sol":139:149 b = 0xbbbb */ and 0xbbbb0000000000000000 or not(sub(exp(0x2, 0xc0), exp(0x2, 0x80))) /* "c-many-variables--packing.sol":157:167 c = 0xcccc */ and 0xcccc00000000000000000000000000000000 or sub(exp(0x2, 0xc0), 0x1) /* "c-many-variables--packing.sol":175:185 d = 0xdddd */ and 0xdddd000000000000000000000000000000000000000000000000 or swap1 sstore 

这里仍是有不少的位转移我没能弄明白,可是无所谓。最关键事情是这里只有一个sstore指令。

这样优化就成功!

干扰优化器

优化器并不能一直工做的这么好。让咱们来干扰一下优化器。惟一的改变就是使用协助函数来设置存储变量:

pragma solidity ^0.4.11; contract C { uint64 a; uint64 b; uint64 c; uint64 d; function C() { setAB(); setCD(); } function setAB() internal { a = 0xaaaa; b = 0xbbbb; } function setCD() internal { c = 0xcccc; d = 0xdddd; } } 

编译:

$ solc --bin --asm --optimize c-many-variables--packing-helpers.sol 

输出的汇编代码太多了,咱们忽略了大多数的细节,只关注结构体:

// 构造器函数 tag_2: // ... // 经过跳到tag_5来调用setAB() jump tag_4: // ... //经过跳到tag_7来调用setCD() jump // setAB()函数 tag_5: // 进行位转移和设置a,b // ... sstore tag_9: jump // 返回到调用setAB()的地方 //setCD()函数 tag_7: // 进行位转移和设置c,d // ... sstore tag_10: jump // 返回到调用setCD()的地方 

如今这里有两个sstore指令而不是一个。Solidity编译器能够优化一个标签内的东西,可是没法优化跨标签的。

调用函数会让你消耗更多的成本,不是由于函数调用昂贵(他们只是一个跳转指令),而是由于sstore指令的优化可能会失败。

为了解决这个问题,Solidity编译器应该学会如何內联函数,本质上就是不用调用函数也能获得相同的代码:

a = 0xaaaa;
b = 0xbbbb;
c = 0xcccc;
d = 0xdddd;

若是咱们仔细阅读输出的完整汇编代码,咱们会看见setAB()setCD()函数的汇编代码被包含了两次,不只使代码变得臃肿了,而且还须要花费额外的gas来部署合约。在学习合约的生命周期时咱们再来谈谈这个问题。

为何优化器会被干扰?

由于优化器不会跨标签进行优化。思考一下"1+1",在同一个标签下,它会被优化成0x2:

// 优化成功! tag_0: 0x1 0x1 add ... 

可是若是指令被标签分开的话就不会被优化了:

// 优化失败! tag_0: 0x1 0x1 tag_1: add ... 

在0.4.13版本中上面的行为都是真实的。也许将来会改变。

再次干扰优化器

让咱们看看优化器失败的另外一种方式,打包适用于定长数组吗?思考一下:

pragma solidity ^0.4.11; contract C { uint64[4] numbers; function C() { numbers[0] = 0x0; numbers[1] = 0x1111; numbers[2] = 0x2222; numbers[3] = 0x3333; } } 

再一次,这里有4个64位的数值咱们但愿能打包成一个32位的数值,只使用一个sstore指令。

编译的汇编代码太长了,咱们就数数sstoresload指令的条数:

$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)' sload sstore sload sstore sload sstore sload sstore 

哦,不!即便定长数组与等效的结构体和存储变量的存储布局是同样的,优化也失败了。如今须要4对sloadsstore指令。

快速的看一下汇编代码,能够发现每一个数组的访问都有一个边界检查代码,它们在不一样的标签下被组织起来。优化没法跨标签,因此优化失败。

不过有个小安慰。其余额外的3个sstore指令比第一个要便宜:

  • sstore指令第一次写入一个新位置须要花费 20000 gas
  • sstore指令后续写入一个已存在的位置须要花费 5000 gas

因此这个特殊的优化失败会花费咱们35000 gas而不是20000 gas,多了额外的75%。

总结

若是Solidity编译器能弄清楚存储变量的大小,它就会将这些变量依次的放入存储器中。若是可能的话,编译器会将数据紧密的打包成32字节的块。

总结一下目前咱们见到的打包行为:

  • 存储变量:打包
  • 结构体:打包
  • 定长数组:不打包。在理论上应该是打包的

由于存储器访问的成本较高,因此你应该将存储变量做为本身的数据库模式。当写一个合约时,作一个小实验是比较有用的,检测汇编代码看看编译器是否进行了正确的优化。

咱们能够确定Solidity编译器在将来确定会改良。对于如今而言,很不幸,咱们不能盲目的相信它的优化器。

它须要你真正的理解存储变量。

本系列文章其余部分译文连接:

原文地址:Diving Into The Ethereum VM Part Two

相关文章
相关标签/搜索