深入了解以太坊虚拟机第2部分——固定长度数据类型的表示方法
本文由币乎社区(bihu.com)内容支持计划赞助
在本系列的第一篇文章中,我们已经看到了一个简单的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,或比基本的算术指令要贵~5000xsload
指令成本是5000 gas,或比基本的算术指令要贵~1600x
这里说的成本,就是真正的金钱,而不仅仅是毫秒级别的性能。运行和使用合约的成本基本上是由sstore
指令和sload
指令来主导的!
Parsecs磁带上的Parsecs
构建一个通用计算机器需要两个基本要素:
- 一种循环的方式,无论是跳转还是递归
- 无限量的内存
EVM的汇编代码有跳转,EVM的存储器提供无限的内存。这对于一切就已经足够了,包括模拟一个运行以太坊的世界,这个世界本身就是一个模拟运行以太坊的世界.........
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;}
}
存储器中的布局很简单。
- 变量
a
在0x0
的位置上 - 变量
b
在0x1
的位置上 - 以此类推.........
关键问题是:如果我们只使用f
,我们需要为a
,b
,c
,d
,e
支付多少成本?
编译一下再看:
$ 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
指令。
编译的汇编代码太长了,我们就数数sstore
和sload
指令的条数:
$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)'sloadsstoresloadsstoresloadsstoresloadsstore
哦,不!即使定长数组与等效的结构体和存储变量的存储布局是一样的,优化也失败了。现在需要4对sload
和sstore
指令。
快速的看一下汇编代码,可以发现每个数组的访问都有一个边界检查代码,它们在不同的标签下被组织起来。优化无法跨标签,所以优化失败。
不过有个小安慰。其他额外的3个sstore
指令比第一个要便宜:
sstore
指令第一次写入一个新位置需要花费 20000 gassstore
指令后续写入一个已存在的位置需要花费 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
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
深入了解以太坊虚拟机第2部分——固定长度数据类型的表示方法相关推荐
- 以太坊-EVM第2部分——固定长度数据类型的表示方法
在本系列的第一篇文章中,我们已经看到了一个简单的Solidity合约的汇编代码: contract C {uint256 a;function C() {a = 1;} } 该合约归结于sstore指 ...
- 以太坊虚拟机固定长度数据类型的表示方法
一个简单的Solidity合约的汇编代码: contract C {uint256 a;function C() {a = 1;} } 该合约归结于sstore指令的调用: // a = 1 ssto ...
- 深入了解以太坊虚拟机
本文由币乎社区(bihu.com)内容支持计划赞助. 译者说,深入了解以太坊虚拟机是一个系列的文章,一共5篇! 本文是第1篇,主要介绍的是以太坊虚拟机汇编代码基础.后续的4篇译文链接在本文的结尾处. ...
- 深入了解以太坊虚拟机第4部分——ABI编码外部方法调用的方式
本文由币乎社区(bihu.com)内容支持计划赞助. 在本系列的上一篇文章中我们看到了Solidity是如何在EVM存储器中表示复杂数据结构的.但是如果无法交互,数据就是没有意义的.智能合约就是数据和 ...
- 从比特币脚本引擎到以太坊虚拟机
这个系列是目标受众是区块链开发者和有其他开发经验的CS专业学生 面对媒体对区块链相关技术的解读和吹捧,许多人一时不知所措.投资人.大公司都在FOMO(fear of missing out)的心理驱动 ...
- 以太坊虚拟机EVM的缺陷与不足
首先对作者做个简单的自我介绍,Jordan Earls是Qtum量子链的联合创始人之一,目前担任Qtum量子链全球首席工程师.Qtum目前采用了以太坊虚拟机(Ethereum Virtual Mach ...
- solidity智能合约[37]-以太坊虚拟机数据存储
EVM 当调用solidity函数时,都会在以太坊虚拟机当中来执行.因此了解以太坊虚拟机的细节,了解其数据的存储机制变得尤为重要. 以太坊虚拟机中的空间分为3大部分.分别为storage.stack与 ...
- 对于以太坊虚拟机 (EVM)及其相关知识的讲解
以太坊虚拟机(EVM) EVM是智能合约的运行环境 作为区块验证协议的一部分,参与网络的每个节点都会运行EVM,审查节点会检查验证正在验证的区块中列出的交易,并运行EVM中交易触发的代码 EVM是沙盒 ...
- 写给开发者——从比特币脚本引擎到以太坊虚拟机
这个系列的目标受众是区块链开发者和CS专业同学 面对媒体对区块链相关技术的解读和吹捧,许多人一时不知所措.投资人.大公司都在FOMO(fear of missing out)的心理驱动下,争相宣布al ...
最新文章
- DetachedCriteria 分页Projection取消聚合条件
- c语言调用labview方法,LabVIEW与C语言接口的方法
- 2016第三届科学数据大会诚邀商务合作
- 我的一些学习经验:ONVIF
- 说说如何抛出Java异常
- java 学习第三篇if判断
- android模拟点击webview,的Android的WebView点击模拟
- 【Java】15分钟快速体验阿里Java诊断工具Arthas
- 逻辑函数代数法化简(二)
- HDU - 6184 Counting Stars (无向图找三元环)
- 阿里云服务器怎么预防CC攻击?
- flutter doctor --android-licenses命令之后出现JAVA_HOME错误
- Windows 11 任务栏、菜单栏无故消失解决方案
- 与门,AND Gate
- MacOS技巧:如何禁用 Adob​​e 后台进程,保存 CPU、内存和网络活动
- java正则表达式类_java中正则表达式之Pattern类与Matcher类
- python基础—3
- 英特尔快速启动技术如何安装
- matlab 环境 word 和 excel 文件操作
- 这一波区块链风口,你怎么看?