Solidity提供了很多高级语言的抽象概念,但是这些特性让人很难明白在运行程序的时候到底发生了什么。我阅读了Solidity的文档,但依旧存在着几个基本的问题没有弄明白。
string, bytes32, byte[], bytes之间的区别是什么?
-
该在什么地方使用哪个类型?
-
将 string 转换成bytes时会怎么样?可以转换成byte[]吗?
-
它们的存储成本是多少?
EVM是如何存储映射( mappings)的?
-
为什么不能删除一个映射?
-
可以有映射的映射吗?(可以,但是怎样映射?)
-
为什么存在存储映射,但是却没有内存映射?
编译的合约在EVM看来是什么样子的?
-
合约是如何创建的?
-
到底什么是构造器?
-
什么是 fallback 函数?
我觉得学习在以太坊虚拟机(EVM)上运行的类似Solidity 高级语言是一种很好的投资,有几个原因:
-
Solidity不是最后一种语言。更好的EVM语言正在到来。(拜托?)
-
EVM是一个数据库引擎。要理解智能合约是如何以任意EVM语言来工作的,就必须要明白数据是如何被组织的,被存储的,以及如何被操作的。
-
知道如何成为贡献者。以太坊的工具链还处于早期,理解EVM可以帮助你实现一个超棒的工具给自己和其他人使用。
-
智力的挑战。EVM可以让你有个很好的理由在密码学、数据结构、编程语言设计的交集之间进行翱翔。
在这个系列的文章中,我会拆开一个简单的Solidity合约,来让大家明白它是如何以EVM字节码(bytecode)来运行的。
我希望能够学习以及会书写的文章大纲:
-
EVM字节码的基础认识
-
不同类型(映射,数组)是如何表示的
-
当一个新合约创建之后会发生什么
-
当一个方法被调用时会发生什么
-
ABI如何桥接不同的EVM语言
我的最终目标是整体的理解一个编译的Solidity合约。让我们从阅读一些基本的EVM字节码开始。
EVM指令集(link.jianshu.com/?t=https://…
1
一个简单的合约
我们的第一个合约有一个构造器和一个状态变量:
// c1.solpragma 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实际运行的字节码。
2
一小步一小步地来
上面一半的编译汇编是大多数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是比较简单的。
3
模拟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)
复制代码
仔细观察,你就会发现dup2,swap1,pop都是多余的,汇编代码可以更简单一些:
0x1
0x0
sstore
复制代码
你可以模拟上面的3条指令,然后会发现他们的机器状态结果都是一样的:
stack: []
store: { 0x0 => 0x1 }
复制代码
4
两个存储变量
让我们再额外的增加一个相同类型的存储变量:
// c2.solpragma 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)
复制代码
我们可以看到两个存储变量的存储位置是依次排列的,a在0x0的位置而b在0x1的位置。
5
存储打包
每个存储槽都可以存储32个字节。如果一个变量只需要16个字节但是使用全部的32个字节会很浪费。Solidity为了高效存储,提供了一个优化方案:如果可以的话,就将两个小一点的数据类型进行打包然后存储在一个存储槽中。
我们将a和b修改成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。
6
更多优化
应该可以将两个128位的数打包成一个数放入内存中,然后使用一个'sstore'指令进行存储操作,而不是使用两个单独的sstore命令来存储变量a和b,这样就额外的又省了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的位置上。
7
Gas的使用
600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055
注意0x200000000000000000000000000000000被嵌入到了字节码中。但是编译器也可能选择使用exp(0x2, 0x81)指令来计算数值,这会导致更短的字节码序列。
但结果是0x200000000000000000000000000000000比exp(0x2, 0x81)更便宜。让我们看看与gas费用相关的信息:
-
一笔交易的每个零字节的数据或代码费用为 4 gas
-
一笔交易的每个非零字节的数据或代码的费用为 68 gas
来计算下两个表示方式所花费的gas成本:
-
0x200000000000000000000000000000000字节码包含了很多的0,更加的便宜。 (1 * 68) + (32 * 4) = 196
-
608160020a字节码更短,但是没有0。 5 * 68 = 340
更长的字节码序列有很多的0,所以实际上更加的便宜!
8
总结
EVM的编译器实际上不会为字节码的大小、速度或内存高效性进行优化。相反,它会为gas的使用进行优化,这间接鼓励了计算的排序,让以太坊区块链可以更高效一点。
我们也看到了EVM一些奇特的地方:
-
EVM是一个256位的机器。以32字节来处理数据是最自然的
-
持久存储是相当昂贵的
-
Solidity编译器会为了减少gas的使用而做出相应的优化选择
Gas成本的设置有一点武断,也许未来会改变。当成本改变的时候,编译器也会做出不同的优化选择。
内容来源:简书
原文作者: Lilymoana
原文链接:www.jianshu.com/p/1969f3761…
Blockathon|48小时极客竞赛,区块链马拉松等你挑战(成都)
时间:2018年9月14-16日
地点:成都高新区天府五街200号菁蓉国际广场2号楼A座12楼中韩互联网+新技术孵化器
-
招募50名开发者(识别下图二维码或点击“阅读原文”即可报名)
-
报名费100元为参赛押金,参赛者个人原因不能到场参加活动概不退款;参赛者全程参与活动,待活动结束后现场退还。9月14日18:00开始第一次签到,9月15日和16日每天早上都要记得签到哦。
-
主办方免费提供2天的食物、饮料,并为每一位参会者准备一件文化衫
以下是我们的社区介绍,欢迎各种合作、交流、学习:)