EVM深度分析之数据存储(一)介绍了EVM中不同的数据存储位置的特点,但是并没有对应到具体的存储位置,这篇文章对Storage中的数据是如何被EVM存储做了简要的分析。
状态变量
Storage初始化的时候是空白的,默认是0。
1 2 3 4 5 6 7 8 9 10 11 12
| pragma solidity ^0.5.1; contract C { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; function test() public { f = 0xc0fefe; } }
|
用solc --bin --asm --optimize test.sol
编译合约,可以看到;
1 2 3 4 5 6 7
| tag_5: /* "test.sol":167:175 0xc0fefe */ 0xc0fefe /* "test.sol":163:164 f */ 0x5 /* "test.sol":163:175 f = 0xc0fefe */ sstore
|
这段汇编执行的是sstore(0x5, 0xc0fefe)
,把0xc0fefe存储到0x5这个位置,在EVM中声明变量不需要成本,EVM会在编译的时候保留位置,但是不会初始化。
当通过指令sload
读取一个未初始化的变量的时候, 不会报错,只会读取到零值0x0。
结构体
结构体的初始化和变量是一样的;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| pragma solidity ^0.5.1; contract C { struct Tuple { uint256 a; uint256 b; uint256 c; uint256 d; uint256 e; uint256 f; } Tuple t; function test() public { t.f = 0xC0FEFE; } }
|
编译得到;
1 2 3 4 5 6 7 8
| tag_5: /* "test.sol":219:227 0xC0FEFE */ 0xc0fefe /* "test.sol":213:216 t.f */ 0x5 /* "test.sol":213:227 t.f = 0xC0FEFE */ sstore /* "test.sol":182:234 function test() public {... */
|
分析编译后的汇编发现结果和状态变量的行为是一致的。
定长数组
定长数组EVM很容易知道类型和长度,所以可以依次排列,就像存储状态变量一样。
1 2 3 4 5 6 7
| pragma solidity ^0.5.1; contract C { uint256[6] numbers; function test() public { numbers[5] = 0xC0FEFE; } }
|
编译合约,可以看到一样的汇编。
1 2 3 4 5 6 7
| tag_5: /* "test.sol":110:118 0xC0FEFE */ 0xc0fefe /* "test.sol":105:106 5 */ 0x5 /* "test.sol":97:118 numbers[5] = 0xC0FEFE */ sstore
|
但是使用定长数组就会有越界的问题,EVM会在赋值的时候生成汇编检查,具体的内容在下篇合约分析中讨论。
固定大小的变量都是尽可能打包成32字节的块然后依次存储的,而一些类型是可以动态扩容的,这个时候就需要更加灵活的存储方式了,这些类型有映射(map),数组(array),字节数组(Byte arrays),字符串(string)。
映射(map)
通过一个简单的合约学习map的存储方式;
1 2 3 4 5 6 7 8 9
| pragma solidity ^0.5.1; contract Test { mapping(uint256 => uint256) items; function test() public { items[0x01] = 0x42; } }
|
这个合约很简单,就是创建了一个key和value都是uint256类型的map,并且在用0x01作为key存储了0x42,用solc --bin --asm --optimize test.sol
编译合约,得到如下汇编。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| tag_5: /* "test.sol":119:123 0x01 */ 0x1 /* "test.sol":113:118 items */ 0x0 /* "test.sol":113:124 items[0x01] */ swap1 dup2 mstore 0x20 mstore /* "test.sol":127:131 0x42 */ 0x42 /* "test.sol":113:124 items[0x01] */ 0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d /* "test.sol":113:131 items[0x01] = 0x42 */ sstore /* "test.sol":82:136 function test() public {... */ jump // out
|
分析一些这段汇编就会发现0x42并不是存储在key是0x01的位置,取而代之的是0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
这样一段地址,这段地址是通过keccak256( bytes32(0x01) + bytes32(0x00) )
计算得到的,0x01就是key,而0x00表示这个合约存储的第一个storage类型变量。
所以key的计算公式就是keccak256(bytes32(key) + bytes32(position))
多个映射map
假设我们的合约有两个map
1 2 3 4 5 6 7 8 9 10 11
| pragma solidity ^0.5.1; contract Test { mapping(uint256 => uint256) itemsA; mapping(uint256 => uint256) itemsB; function test() public { itemsA[0xAAAA] = 0xAAAA; itemsB[0xBBBB] = 0xBBBB; } }
|
编译得到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| tag_5: /* "test.sol":166:172 0xAAAA */ 0xaaaa /* "test.sol":149:163 itemsA[0xAAAA] */ 0x839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3 /* "test.sol":149:172 itemsA[0xAAAA] = 0xAAAA */ sstore /* "test.sol":195:201 0xBBBB */ 0xbbbb /* "test.sol":149:155 itemsA */ 0x0 /* "test.sol":178:192 itemsB[0xBBBB] */ dup2 swap1 mstore /* "test.sol":178:184 itemsB */ 0x1 /* "test.sol":149:163 itemsA[0xAAAA] */ 0x20 /* "test.sol":178:192 itemsB[0xBBBB] */ mstore 0x34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395 /* "test.sol":178:201 itemsB[0xBBBB] = 0xBBBB */ sstore /* "test.sol":120:206 function test() public {... */ jump // out
|
itemsA的位置是0,key是0xAAAA:
1 2 3
| # key = 0xAAAA, position = 0 keccak256(bytes32(0xAAAA) + bytes32(0)) '839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3'
|
itemsB的位置是1,key是0xBBBB:
1 2 3
| # key = 0xBBBB, position = 1 keccak256(bytes32(0xBBBB) + bytes32(1)) '34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395'
|
用solc --bin --asm --optimize test.sol
编译合约,得到如下汇编。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| /* "test.sol":166:172 0xAAAA */ 0xaaaa /* "test.sol":149:163 itemsA[0xAAAA] */ 0x839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3 /* "test.sol":149:172 itemsA[0xAAAA] = 0xAAAA */ sstore /* "test.sol":195:201 0xBBBB */ 0xbbbb /* "test.sol":149:155 itemsA */ 0x0 /* "test.sol":178:192 itemsB[0xBBBB] */ dup2 swap1 mstore /* "test.sol":178:184 itemsB */ 0x1 /* "test.sol":149:163 itemsA[0xAAAA] */ 0x20 /* "test.sol":178:192 itemsB[0xBBBB] */ mstore 0x34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395 /* "test.sol":178:201 itemsB[0xBBBB] = 0xBBBB */ sstore /* "test.sol":120:206 function test() public {... */ jump // out
|
可以看到,存储的地址和我们推到的一样。
动态数组
在其他语言中,数组只是连续存储在内存中的一系列相同类型的元素,取值的时候都是采用首地址+偏移量的形式,但是在solidity中,数组是一种映射。数组在存储器中是这样存储的;
1 2 3 4
| 0x290d...e563 0x290d...e564 0x290d...e565 0x290d...e566
|
虽然看起来像是连续存储的,但实际上访问的时候是通过映射来查找的。增加了数组类型的意义在于多了一些数组的方法,便于我们更好的理解和编写代码,增加的特性有;
- length表示数组的长度,一共有多少元素;
- 边界检查,当读取或者写入时索引值大于length就会报错;
- 比映射更加复杂的存储打包行为;
- 当数组变小时,自动清除未使用的空间;
- bytes和string的特殊优化让短数组(小于32字节)存储更加高效;
编译合约
1 2 3 4 5 6 7 8 9
| pragma solidity ^0.5.1; contract C { uint256[] chunks; function test() public { chunks.push(0xAA); chunks.push(0xBB); chunks.push(0xCC); } }
|
使用remix
调试合约可以看到storage部分的存储内容;
因为动态数组在编译期间无法知道数组的长度,提前预留存储空间,所以solidity就用chunks
变量的位置存储了动态数组的长度,而具体的数据地址通过计算keccak256(bytes32(0))
算得数组首地址,再加数组长度偏移量获得具体的元素。
这里的 0 表示的是chunks变量的位置哦
动态数据打包
数组与映射相比,有更加优化的打包行为,编译合约;
1 2 3 4 5 6 7 8 9 10 11
| pragma solidity ^0.5.1; contract C { uint128[] s; function test() public { s.length = 4; s[0] = 0xAA; s[1] = 0xBB; s[2] = 0xCC; s[3] = 0xDD; } }
|
使用remix
调试合约可以看到storage部分的存储内容;
可以发现4个元素并没有占据4个插槽,而只有两个,solidity一个插糟的大小是256bit,s的类型是uint128,编译器做了一个优化,对数据进行了更优化的打包策略,可以最大限度的节约Gas。
看一些各项操作所花费Gas的表格;
其中数据的持久化操作sstore
是消耗Gas最多的操作,在合适的场景下使用数组可以利用编译器优化节约大量的Gas。
字节数组和字符串
bytes和string是EVM特殊优化的类型;
1 2 3 4 5 6 7 8 9
| pragma solidity ^0.5.1; contract C { bytes s; function test() public { s.push(0xAA); s.push(0xBB); s.push(0xCC); } }
|
最后用remix编译得到;
1 2
| key: 0x0000000000000000000000000000000000000000000000000000000000000000 value: 0xaabbcc0000000000000000000000000000000000000000000000000000000006
|
当bytes和string的长度小于31字节的时候可以这样放到一个插槽里,但是当大于31字节的时候,就采用存储动态数组的方式。
总结
EVM的存储器就是一个健值数据库,当改变里面的任何一点东西,根节点的校验和也会改变,如果两个根节点拥有相同的校验和,存储的数据就能保持一致。