死磕以太坊源码分析之EVM固定长度数据类型表示

配合以下代码进行阅读:https://github.com/blockchainGuide/

写文不易,给个小关注,有什么问题可以指出,便于大家交流学习。

翻译自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7

我们先看一个简单的Solidity合约的汇编代码:

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

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

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

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

在典型编程语言中理解数据类型在底层是如何表示的没有太大的作用。但是在Solidity(或其他的EVM语言)中,这个知识点是非常重要的,因为存储的访问是非常昂贵的:

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

这里说的成本,就是真正的金钱,而不仅仅是毫秒级别的性能。运行和使用合约的成本基本上是由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;
}
}

存储器中的布局很简单。

  • 变量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编译器在未来肯定会改良。对于现在而言,很不幸,我们不能盲目的相信它的优化器。

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

死磕以太坊源码分析之EVM固定长度数据类型表示的更多相关文章

  1. 以太坊源码分析(52)以太坊fast sync算法

    this PR aggregates a lot of small modifications to core, trie, eth and other packages to collectivel ...

  2. Geth命令用法-参数详解 and 以太坊源码文件目录

    本文是对以太坊客户端geth命令的解析 命令用法 geth [选项] 命令 [命令选项] [参数-] 版本 1.7.3-stable 命令 account 管理账户 attach 启动交互式JavaS ...

  3. asp.net mvc源码分析-DefaultModelBinder 自定义的普通数据类型的绑定和验证

    原文:asp.net mvc源码分析-DefaultModelBinder 自定义的普通数据类型的绑定和验证 在前面的文章中我们曾经涉及到ControllerActionInvoker类GetPara ...

  4. 以太坊的crypto模块--以太坊源码学习

    以太坊的crypto模块 该模块分为两个部分一个是实现sha3,一个是实现secp256k1(这也是比特币中使用的签名算法). 需要说明的是secp256k1有两种实现方式,一种是依赖libsecp2 ...

  5. 以太坊系列之三: 以太坊的crypto模块--以太坊源码学习

    以太坊的crypto模块 该模块分为两个部分一个是实现sha3,一个是实现secp256k1(这也是比特币中使用的签名算法). 需要说明的是secp256k1有两种实现方式,一种是依赖libsecp2 ...

  6. 以太坊系列之一: 以太坊RLP用法-以太坊源码学习

    RLP (递归长度前缀)提供了一种适用于任意二进制数据数组的编码,RLP已经成为以太坊中对对象进行序列化的主要编码方式.RLP的唯一目标就是解决结构体的编码问题:对原子数据类型(比如,字符串,整数型, ...

  7. 以太坊系列之六: p2p模块--以太坊源码学习

    p2p模块 p2p模块对外暴露了Server关键结构,帮助上层管理复杂的p2p网路,使其集中于Protocol的实现,只关注于数据的传输. Server使用discover模块,在指定的UDP端口管理 ...

  8. 以太坊源码学习 – EVM

    学习文档链接:here 一.虚拟机外 主要功能: 执行前将Transaction类型转化成Message,创建虚拟机(EVM)对象,计算一些Gas消耗,以及执行交易完毕后创建收据(Receipt)对象 ...

  9. 以太坊系列之五: p2p的nat模块--以太坊源码学习

    p2p的nat模块 该模块相对比较简单,因为nat的真正实现并不在此模块,主要是使用了第三方的nat-upnp和nat-pmp来实现真正的穿透(端口映射). 对外公布的接口 ```go // An i ...

  10. netty之NioEventLoopGroup源码分析二

    大家好,今天我准备死磕NioEventLoopGroup的源码,首先讲下概念,NioEventLoopGroup 它是一个线程池,存放NioEventLoop,一个数组,今天打算先看下这行代码的初始化 ...

随机推荐

  1. eclipse将android项目生成apk并且给apk签名

    转载:http://www.cnblogs.com/tianguook/archive/2012/09/27/2705724.html 生成apk最懒惰的方法是:只要你运行过android项目,到工作 ...

  2. 串口控RGB三色灯

    本文由博主原创,如有不对之处请指明,转载请说明出处. /********************************* 代码功能:串口控RGB三色灯 使用函数: Serial.flush(); / ...

  3. 常用的JAVA集合讲解

    java.util包中包含了一系列重要的集合类,而对于集合类,主要需要掌握的就是它的内部结构,以及遍历集合的迭代模式. 接口:Collection Collection是最基本的集合接口,一个Coll ...

  4. leetcode 173. Binary Search Tree Iterator

    Implement an iterator over a binary search tree (BST). Your iterator will be initialized with the ro ...

  5. HDU 1754区间最值 &amp; SPLAY

    真是亲切的1754啊..第一道傻逼版的线段树做的是这个,后来学了zkw做的是这个,在后来决定打lrj线段树又打了一遍,如今再用splay和老朋友见面   从上到下依次为:加了读入优化的splay,sp ...

  6. Sphinx 的介绍和原理探索——不存储原始数据,原始数据来源于SQL,而生成索引放在内存或者磁盘中

    摘自:http://blog.jobbole.com/101672/ What/Sphinx是什么 定义:Sphinx是一个全文检索引擎. 特性: 索引和性能优异 易于集成SQL和XML数据源,并可使 ...

  7. Spring MVC框架

    这个Spring Web MVC 框架提供了模型视图控制器的架构,这种结构能够被用来开发灵活的和松耦合的Web应用程序. 这种MVC模式能够将应用程序分离成不同的层面,(输入逻辑,业务逻辑,UI逻辑) ...

  8. vue.js插件使用(02) vue-router

    概述 vue-router是Vue.js官方的路由插件,它和vue.js是深度集成的,适合用于构建单页面应用.vue的单页面应用是基于路由和组件的,路由用于设定访问路径,并将路径和组件映射起来.传统的 ...

  9. 在Code first中使用数据库里的视图

    如果想在Code first中使用数据库里的视图 (不管你出于什么原因),目前的方法有2种. 一.使用Database.SqlQuery<T>("查询语句"),如: v ...

  10. Custom Control

    How to create custom control http://www.silverlightshow.net/items/Creating-a-Silverlight-Custom-Cont ...