开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情
一、EVM的内部结构
前面已经了解到Solidity合约是会被部署在以太坊节点上,当发生外部调用时,合约会被执行,变量状态将会改变,那么在以太坊节点上,合约、变量是存储在哪里的呢?要搞清楚这一点,需要先了解以太坊EVM的内部结构和工作原理。
下面两张图来自于以太坊官方文档:ethereum.org/en/develope…
从图中可以看到:智能合约经过编译转化为EVM可识别的机器码存储在虚拟ROM中,当此合约被触发执行时,程序计数器置为0,并对ROM中存储的EVM code进行计数,EVM code会转化为具体的操作指令,比如内部的堆栈操作、外部的消息调用,操作指令得以实施需要有充足的gas支持。堆栈操作会带来对memory的读写以及对storage的读写,对storage的读写会需要更多的gas消耗。
二、Solidity变量的存储位置
根据Solidity官方文档的叙述,在0.5.0版本以后,需要明确定义所有struct, array 和 mapping类型的数据存储位置,有三个可以定义的数据位置:memory(内存),storage(存储)和calldata(调用数据),也就是说原本定义一个array,可以写成:uint[] a=b,在0.5.0版本之后,需要写成:uint[] storage a=b.
结合上面EVM的内部结构图,我们可以根据数据是否持久存储的特点,把变量的存储分为两类——Memory和Storage,Memory为临时存储,广义来说还可以包含stack和calldata,Storage为持久存储,会将数据永久存储在区块链中。
-
Memory(内存)
- 临时存储空间,仅在函数执行期间存储数据,函数执行结束就会清除
- 插槽大小为256位(32个字节)
-
Storage(存储)
- 数据以键值的形式永久存储
- 固定大小的变量会从位置0开始连续放在存储中,存储需求少于 32 字节的多个变量也可能被打包到一个存储slot中,如果一个slot中的剩余空间不足以储存一个基本类型,那么它会被移入下一个slot中存储
- 基本类型仅使用存储它们所需的字节,结构(struct)和数组数据总是会占用一整个新插槽
-
Calldata(调用数据)
- 类似于Memory的临时存储,用来存储函数参数
- calldata类型的数据不能被修改
三、变量存储寻位
EVM为每一个智能合约分配了永久存储空间,可以把此存储空间理解为一系列slot,每个slot占用32字节,slot中初始元素均为0,合约可以在此空间内进行数据的读写操作。实际上这个存储空间并不会被填满,而是含有大量的0值,EVM允许合约进行存储回收,即将存储空间中的数据重置为0。
1. 对于大小固定的变量存储
以下面的这段代码为例:
contract StorageTest {
uint256 a;
uint256[2] b;
struct Entry {
uint256 id;
uint256 value;
}
Entry c;
}
- 变量a是一个uint256类型的整数,占用32字节大小的空间,会被存储在slot0,占用完整的一个slot
- 变量b是一个uint256的数组,占用2*32字节大小,会被存储在slot1,slot2
- 变量c是一个结构体,包含两个uint256的变量,会被存储在slot3,slot4
2. 对于变长变量的存储
继续沿用之前的代码示例,如果在Entry c变量之后再加一个变量d,定义d为
Entry[] d;
那么d的长度在定义时是不确定大小的,无法直接分配固定的空间给变量d。那应该如何分配d的存储空间呢?EVM的做法是先给变量d分配slot5这块存储空间,但是在slot5里存储的并不是d的实际值,而是变量d的长度,即d.length,而变量d的值实际存储空间为以hash(5)开始的插槽位置,即d[0]的存储位置是slot hash(5)和slot hash(5)+1,d[1]的存储位置为slot hash(5)+2,slot hash(5)+3,并依此类推。下面这段代码就是计算变长变量中某元素的存储位置的函数:
function arrLocation(uint256 slot, uint256 index, uint256 elementSize)
public
pure
returns (uint256)
{
return uint256(keccak256(slot)) + (index * elementSize);
}
3. 对于mappings变量的存储
继续追加代码:
contract StorageTest {
uint256 a; // slot 0
uint256[2] b; // slots 1-2
struct Entry {
uint256 id;
uint256 value;
}
Entry c; // slots 3-4
Entry[] d; // slot 5 存储d的长度
mapping(uint256 => uint256) e;
mapping(uint256 => uint256) f;
}
对于mapping类型的变量e,在slot6的位置会分配给e,slot7的位置分配给f,但是在slot6和slot7的位置将不会存任何值,而e和f中的变量值实际会散列在任意存储空间内,存放具体元素的计算方式如下面函数所示,比如e[59]的存储位置就是slot hash(59,6),f[112]的存储位置就是slot hash(112,7)。
function mapLocation(uint256 slot, uint256 key) public pure returns (uint256) {
return uint256(keccak256(key, slot));
}
4. 组合类型
如果是变长数组和mapping结合的组合类型,应该怎么存储呢?实际也就是上述情况2和情况3的结合,办法就是分步计算,例子如下:
contract StorageTest {
uint256 a; // slot 0
uint256[2] b; // slots 1-2
struct Entry {
uint256 id;
uint256 value;
}
Entry c; // slots 3-4
Entry[] d; // slot 5 for length, keccak256(5)+ for data
mapping(uint256 => uint256) e; // slot 6, data at h(k . 6)
mapping(uint256 => uint256) f; // slot 7, data at h(k . 7)
mapping(uint256 => uint256[]) g; // slot 8
mapping(uint256 => uint256)[] h; // slot 9
}
对于变量g,想要知道g[45][0]的存储位置,需要先计算g[45]所代表的uint256[]变长数组的位置,再计算其第一个元素(index=0)的位置:
// arr = g[45]
arrLoc = mapLocation(8, 45); // g is at slot 8
// then find arr[0]
itemLoc = arrLocation(arrLoc, 0, 1);
对于变量h,想要知道h[5][98]的位置,需要先计算h[5]所代表的mapping元素的位置,再计算h[5][98]的位置,即:
// find h[5]
mapLoc=arrLocation(9,5,1);
// then find h[5][98]
itemLoc=mapLocation(mapLoc,98);
参考资料:
-
深入理解Solidity:solidity-cn.readthedocs.io/zh/develop/…
-
以太坊官方文档:ethereum.org/en/develope…
-
Learn Solidity: Variables: betterprogramming.pub/learn-solid…
-
Understanding Ethereum Smart Contract Storage: programtheblockchain.com/posts/2018/…
-
Getting Deep Into EVM: How Ethereum Works Backstage: medium.com/swlh/gettin…