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

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

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

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

如果你觉得这看起来很陌生,我建议你阅读本系列的第一篇文章:EVM汇编代码的介绍

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

在典型编程语言中理解数据类型在底层是如何表示的没有太大的作用。但是在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:0xc0fefe0x5sstore

所以一个存储变量的声明不需要任何成本,因为没有初始化的必要。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 0x00x0dup1sload// a + 1; where a == 00x1add// sstore(0x0, a + 1)swap1sstore

注意生成从一个未初始化的位置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:0xc0fefe0x5sstore

固定长度数组

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

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:0xc0fefe0x00x5
tag_4:add0x0
tag_5:popsstore

这个稍微长一点,但是如果你仔细一点,你会看见它们其实是一样的。我们手动的来优化一下:

tag_2:0xc0fefe// 0+5. 替换为0x50x00x5add// 压入栈中然后立刻出栈。没有作用,只是移除0x0popsstore

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

tag_2:0xc0fefe0x5sstore

数组边界检查

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

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

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

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

tag_2:0xc0fefe[0xc0fefe]0x5[0x5 0xc0fefe]dup1/* 数组边界检查代码 */// 5 < 60x6[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 */dup1sload/* "c-many-variables--packing.sol":125:131  0xaaaa */0xaaaanot(0xffffffffffffffff)/* "c-many-variables--packing.sol":121:131  a = 0xaaaa */swap1swap2andornot(sub(exp(0x2, 0x80), exp(0x2, 0x40)))/* "c-many-variables--packing.sol":139:149  b = 0xbbbb */and0xbbbb0000000000000000ornot(sub(exp(0x2, 0xc0), exp(0x2, 0x80)))/* "c-many-variables--packing.sol":157:167  c = 0xcccc */and0xcccc00000000000000000000000000000000orsub(exp(0x2, 0xc0), 0x1)/* "c-many-variables--packing.sol":175:185  d = 0xdddd */and0xdddd000000000000000000000000000000000000000000000000orswap1sstore

这里还是有很多的位转移我没能弄明白,但是无所谓。最关键事情是这里只有一个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:0x10x1add...

但是如果指令被标签分开的话就不会被优化了:

// 优化失败!
tag_0:0x10x1
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)'sloadsstoresloadsstoresloadsstoresloadsstore

哦,不!即使定长数组与等效的结构体和存储变量的存储布局是一样的,优化也失败了。现在需要4对sloadsstore指令。

快速的看一下汇编代码,可以发现每个数组的访问都有一个边界检查代码,它们在不同的标签下被组织起来。优化无法跨标签,所以优化失败。

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

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

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

总结

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

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

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

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

我们可以肯定Solidity编译器在未来肯定会改良。对于现在而言,很不幸,我们不能盲目的相信它的优化器。

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

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

  • EVM汇编代码的介绍(第1部分)
  • 动态数据类型的表示方法(第3部分)
  • ABI编码外部方法调用的方式(第4部分)
  • 一个新合约被创建后会发生什么(第5部分)

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

作者:Lilymoana
链接:https://www.jianshu.com/p/9df8d15418ed
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

以太坊-EVM第2部分——固定长度数据类型的表示方法相关推荐

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

    本文由币乎社区(bihu.com)内容支持计划赞助 在本系列的第一篇文章中,我们已经看到了一个简单的Solidity合约的汇编代码: contract C {uint256 a;function C( ...

  2. 以太坊虚拟机固定长度数据类型的表示方法

    一个简单的Solidity合约的汇编代码: contract C {uint256 a;function C() {a = 1;} } 该合约归结于sstore指令的调用: // a = 1 ssto ...

  3. 以太坊-EVM第3部分——动态数据类型的表示方法

    Solidity提供了在其他编程语言常见的数据类型.除了简单的值类型比如数字和结构体,还有一些其他数据类型,随着数据的增加可以进行动态扩展的动态类型.动态类型的3大类: 映射(Mappings):ma ...

  4. 以太坊EVM源码注释之数据结构

    以太坊EVM源码分析之数据结构 EVM代码整体结构 EVM相关的源码目录结构: ~/go-ethereum-master/core/vm# tree . ├── analysis.go // 分析合约 ...

  5. 以太坊EVM源码注释之State

    以太坊EVM源码注释之State Ethereum State EVM在给定的状态下使用提供的上下文(Context)运行合约,计算有效的状态转换(智能合约代码执行的结果)来更新以太坊状态(Ether ...

  6. 以太坊EVM源码注释之执行流程

    以太坊EVM源码分析之执行流程 业务流程概述 EVM是用来执行智能合约的.输入一笔交易,内部会将之转换成一个Message对象,传入 EVM 执行.在合约中,msg 全局变量记录了附带当前合约的交易的 ...

  7. 以太坊EVM兼容区块链全表

    以太坊已经借助DeFi迅速成为去中心化应用的主流开发平台,利用以太坊 技术开发的分叉链或EVM兼容链也层出不穷.本文列出主流的以太坊EVM兼容链, 以便开发者使用MetaMask或Web3中间件时,可 ...

  8. 以太坊EVM智能合约中的数据存储

    目录 EVM基本信息 数据管理 Stack Args Memory Storage 固定长度的值 动态长度数组 Mappings 复杂类型的组合 总结 EVM基本信息 以太坊是一种基于栈的虚拟机,基于 ...

  9. 以太坊EVM在安全性方面的考虑

    以太坊上用户编写的合约是不可控的,要保证这些合约能够正确执行并且不会影响区块链的稳定,虚拟机需要做安全方面的考虑. 1 在程序执行过程中采取的每个计算步骤都必须提前支付费用, 从而防止DoS攻击.先消 ...

最新文章

  1. 07Bridge(桥)模式
  2. MySQL 调优基础:Linux内存管理 Linux文件系统 Linux 磁盘IO Linux网络
  3. 【Java 虚拟机原理】线程栈 | 栈帧 | 局部变量表 | 反汇编字节码文件 | Java 虚拟机指令手册 | 程序计数器
  4. 蓝奏云文件上传php源码_蓝奏云客户端 v0.3.1,第三方蓝奏网盘电脑版
  5. C#开发和使用中的23个技巧
  6. 10分钟虚拟设备接入阿里云IoT平台实战
  7. CVE-2021-1675: Windows Print Spooler远程代码执行漏洞
  8. 一年多少钱_赴英读研一年多少钱?
  9. 含有空格或者逗号的字符串反转最有效的办法——栈
  10. 【渝粤题库】陕西师范大学201311 刑法学作业
  11. 服务器向客户端发送数据自动中断
  12. Mesos和Marathon下容器无法正常部署可能的原因
  13. Mac安装PyQt4
  14. java中控指纹仪_java 中控URU4500指纹仪开发
  15. html+css基础教程学习之css连接
  16. bmp怎么改jpg格式?
  17. Nacos+openFeign 服务之间调用 出现错误:Load balancer does not contain an instance for the service 解决
  18. Mysql引擎·索引·事务·锁机制·优化推荐
  19. python file是什么意思_Python一直提示runfile是什么意思?
  20. 自动颁发证书 AD域策略

热门文章

  1. 质量意识:质量认识的误区
  2. C# 委托、事件、回调 讲解
  3. 通过js给input框的value赋值触发input事件
  4. 解决web项目中发送文字乱码以及Tomcat 7控制台打印乱码问题
  5. DP2232H国产替代FT2232H_USB2.0转UART/FIFO芯片
  6. Everything-快速强大的Windows搜索工具
  7. tlwdr6300虚拟服务器,TL-WDR6300怎么设置?TP-Link TL-WDR6300设置方法详解
  8. Windows Phone实例开发:快递查询助手 - [WP开发]
  9. 计算机毕业设计Java盘山县智慧项目管理系统(源码+系统+mysql数据库+lw文档)
  10. Java 设计模式(二)《建造者模式》