跳到主要内容

EVM变量存储规则

· 阅读需 7 分钟

存储机制

智能合约中的状态变量的值都是永久存储在链上的,这些值存储在一个叫做存储槽的地方。每个智能合约中都有 22562^{256} 个存储槽,如下图所示:

这些存储槽实际上并没有占据这么大的空间,而是需要用到的时候才会去分配。

知道了存储槽的概念,那么EVM又是如何去分配这些空间的呢,这就需要根据数据类型来讨论

存储分配

定长类型

Solidity 中有很多固定长度的数据类型,例如 uint8uint16uint256等,又或者 bytes2bytes32等。uint256bytes32 存储的值都是 32 个字节,刚好占据一个存储槽。而 uint8 占据的空间只有 1 个字节。

假设在合约中按顺序定义了以下的变量:

uint256 public a;
uint256 public b;
bytes32 public c;

由于变量 abc 的值都是 32 个字节,所以分别占据了存储槽 slot 0slot 1slot 2

注意:分配的存储槽是按照变量在合约中顺序分配的。

通过 ethers.js 读取存储槽的值

const { ethers } = require('ethers')
const provider = new ethers.providers.JsonRpcProvider()

const getSlotValue = async () => {
// 读取 contractAddress 合约中 slot 1的值
const v = await provider.getStorageAt(contractAddress, 1)
}

如果定义的变量不足 32 个字节,其值又是如何分配的呢

假设定义了如下变量

uint8 public a;
bytes2 public b;
uint256 public c;

a 占据了 1 个字节,b 占据了 2 个字节。由于 ab 加起来所占用的字节数不足 32,所以都会被存储在 slot 0, 而 c 占据了 32 个字节,由于 slot 0 只剩下 29 个字节的可用空间了,所以 c 需要占用一个单独的存储槽 slot 1

而对于定长数组的空间分配也一样,可以看成定义了数组长度个类型的变量

uint[5] arr = [1, 2, 3, 4, 5]

可以看成定义了 5 个 uint256 类型的变量,由于 uint256 占据了 32 个字节的空间,所以该数组的每个元素占据的空间都是 32 字节,也正好是一个存储槽的空间, 所以该定长数组 arr 占据了 5 个存储槽,类似的,如果是 uint16[5],由于 uint16 占据了 2 个字节,数组总占据的空间就是 10 个字节,所以数组只会占据一个存储槽。

结构体的内存分配也是按照同样的规则,如下面的代码

struct User{
uint age;
bool isMale;
}
User public user;

对变量 user 的空间分配,就是对变量所对应的结构体内部变量进行的分配,相当于定义了变量

  • uint user.age
  • bool user.isMale

user.age 占据了 32 个字节的空间,被分配到 slot 0, user.isMale 被分配到了 slot 1

如果结构体内部定义了非定长的数据类型,如

struct User {
string name;
uint age;
uint[] score;
}

存储槽又该如何分配呢?

非定长类型

由于非定长类型的大小未知,无法在编译期间确定其存储。因此 Solidity 在编译动态数组、映射类型、字符串时采用的是特定算法

动态数组

动态数组由两部分组成,数组的长度和元素值,在定义了动态数组后,将在本该存储数组值的存储槽存储元素数量(单独占用一个存储槽),元素的值的存储起始槽则在 keccak256(slot)

uint[] public arr = [1, 2, 3, 4, 5];

数组 arr 中有 5 个元素,则在 slot 0 中存储数字 5。其次元素值的存储起始位置需要通过计算得到

keccak256(0) = 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563

计算得到了起始位置后,值将按序存储。

字符串类型

字符串 stringbytes 实际是一个特殊的数组,编译器对这类数据有进行优化。如果 stringbytes 的数据很短。那么它们的长度也会和数据一起存储到同一个插槽。 具体为:

  • 如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2

  • 如果数据长度超出 31 字节,则在主插槽存储 length * 2 + 1, 数据照常存储在 keccak256(slot) 中。

例如:

string public short = 'hello'
string public long = 'State variables of contracts are stored in storage in a compact way such that multiple values sometimes use the same storage slot'

由于变量 short 的内容很短,所以其值和长度被存储在 slot 0

变量 long 的内容很长,所以其长度存储在 slot 1 , 内容存储在 keccak256(1) 中。

映射类型

mapping 类型是一个 key-value 结构,其所在的存储槽会空置,并且其值存储位置根据 keccak(key + slot) 计算得到。

如下代码

contract Storage {
uint public a = 100;
mapping(uint => bool) public b;
uint public c = 11;

constructor() public {
b[100] = true;
}
}
  • 变量 a 占据 slot 0

  • 变量 b 占据 slot 1 , 但却是空置的。可以理解为 slot 1 是虚拟的,并不实际存在。作用就是用来计算 value 的存储位置

  • 变量 c 占据 slot 2

已知变量 bslot 是 1, 就可以根据公式 keccak256(key + slot) 轻松计算出key 为 10 时值的存储位置:

keccak256(abi.encode(10, 1))