Solidity入门学习(4)-变量存储

720 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

一、EVM的内部结构

前面已经了解到Solidity合约是会被部署在以太坊节点上,当发生外部调用时,合约会被执行,变量状态将会改变,那么在以太坊节点上,合约、变量是存储在哪里的呢?要搞清楚这一点,需要先了解以太坊EVM的内部结构和工作原理。

下面两张图来自于以太坊官方文档:ethereum.org/en/develope…

EVM存储结构.png

以太坊EVM图解.png 从图中可以看到:智能合约经过编译转化为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为持久存储,会将数据永久存储在区块链中。

  1. Memory(内存)

    • 临时存储空间,仅在函数执行期间存储数据,函数执行结束就会清除
    • 插槽大小为256位(32个字节)
  2. Storage(存储)

    • 数据以键值的形式永久存储
    • 固定大小的变量会从位置0开始连续放在存储中,存储需求少于 32 字节的多个变量也可能被打包到一个存储slot中,如果一个slot中的剩余空间不足以储存一个基本类型,那么它会被移入下一个slot中存储
    • 基本类型仅使用存储它们所需的字节,结构(struct)和数组数据总是会占用一整个新插槽
  3. 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);
参考资料: