Solidity 中的存储类型
Solidity 中的变量大体上分为三种存储类型:
- Storage(存储): 永久存储,数据会持续存在,直到合约被销毁或数据被更新, 也是 gas 成本最高的
- Memory(内存): 临时数据存储区,合约调用时分配
contract MemoryExample {
// nums 数组是 memory 类型,在函数执行时临时分配内存空间
function sum(uint256[] memory nums) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < nums.length; i++) {
total += nums[i];
}
return total;
}
}
- Stack(栈): 一个由 EVM(以太坊虚拟机)管理的、非常高效的临时数据存储区域。栈通常用于存储小型的临时数据,如局部变量和计算结果, gas 成本最低
contract StackExample {
function multiply(uint256 a, uint256 b) public pure returns (uint256) {
uint256 result = a * b; // result 存储在栈中
return result;
}
}
变量存储布局
基本存储布局
- 合约变量是对应 Slot 存储的,每个合约有 2256 -1 个 Slot,每个 Slot 大小 32 bytes
- Slot 是紧凑存储数据的,意味着一个变量如果大小不足 32 bytes 则可能会和其他变量共享一个 slot 存储
- 定长数据是直接按 slot 顺序排列存储的, 如下示例:
contract StorageExample {
// uint256 对应 32 字节,刚好占用一个 slot =======> slot1
uint256 a;
// uint128 对应 16 字节,b 和 c 共享一个 slot =====> slot2
uint128 b;
uint128 c;
// d 虽然是 uint24 对应 3字节, 但是因为下一个变量 e 是uint256,两个共享的话也超过了 32 字节, 所以 d 和 c 分别对应一个 slot
uint24 d;
uint256 e;
}
利用命令 forge inspect StorageExample storageLayout
查看对应的存储布局如下:
{
"storage": [
{
"astId": 3,
"contract": "src/Counter.sol:Counter",
"label": "a",
"offset": 0,
"slot": "0",
"type": "t_uint256"
},
{
"astId": 5,
"contract": "src/Counter.sol:Counter",
"label": "b",
"offset": 0,
"slot": "1",
"type": "t_uint128"
},
{
"astId": 7,
"contract": "src/Counter.sol:Counter",
"label": "c",
"offset": 16,
"slot": "1",
"type": "t_uint128"
},
{
"astId": 9,
"contract": "src/Counter.sol:Counter",
"label": "d",
"offset": 0,
"slot": "2",
"type": "t_uint24"
},
{
"astId": 11,
"contract": "src/Counter.sol:Counter",
"label": "e",
"offset": 0,
"slot": "3",
"type": "t_uint256"
}
],
"types": {
"t_uint128": {
"encoding": "inplace",
"label": "uint128",
"numberOfBytes": "16"
},
"t_uint24": {
"encoding": "inplace",
"label": "uint24",
"numberOfBytes": "3"
},
"t_uint256": {
"encoding": "inplace",
"label": "uint256",
"numberOfBytes": "32"
}
}
}
不定长数据存储 - string & bytes
如果 string 和 bytes 的数据很短。那么它们的长度也会和数据一起存储到同一个插槽。 具体为:
- 如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length
- 如果数据长度超出 31 字节,则在主插槽存储
length + 1
, 数据按顺序存储在keccak256(slot)、keccak256(slot)+1 ... keccak256(slot) + n
中
contract StorageExample2 {
string str1 = "aaaaaaa";
string str2 = unicode"啦啦啦已经超过了一个插槽存储量";
}
然后用 forge 工具查看其存储布局:
不定长数据存储 - array 数组
数组分两种,静态数组和动态数组:
- 静态数组类似定长数组,slot 按序布局存储
- 动态数组则是不定长的,将在定义的插槽位置存储数组元素数量,也就是数组
length
, 元素数据存储的起始位置是:keccak256(slotIndex)
, 每个元素需要根据下标和元素大小来读取数据
举个例子:
contract StorageExample3 {
uint256[2] arr0 = [1, 2];
uint128[] arr1 = [1, 2, 3, 4];
uint256[] arr2;
}
对应的合约布局如下:
{
"storage": [
{
"astId": 5,
"contract": "src/StorageExample3.sol:StorageExample3",
"label": "arr0",
"offset": 0,
"slot": "0",
"type": "t_array(t_uint256)2_storage"
},
{
"astId": 13,
"contract": "src/StorageExample3.sol:StorageExample3",
"label": "arr1",
"offset": 0,
"slot": "2",
"type": "t_array(t_uint128)dyn_storage"
},
{
"astId": 16,
"contract": "src/StorageExample3.sol:StorageExample3",
"label": "arr2",
"offset": 0,
"slot": "3",
"type": "t_array(t_uint256)dyn_storage"
}
]
}
可以看到:
- arr0 变量是长度为 2 的定长 uint256 数组,所以占用了 2 个 slot
- arr1 和 arr2 变量都是动态数组, 所以都只占用一个 slot, 用于存储数组元素数量
**length**
- arr1 变量数组的实际数据内容则是从起始位置:
**keccak256(slotIndex)**
, 因为是 uint128 类型, 所以是两个数组值共用一个 slot
不定长数据存储 - mapping 映射
-
每个 mapping 变量本身占用一个固定的存储槽,这个槽位用于标记映射的存储起点(即映射变量声明的顺序决定了它的槽位索引)
-
每个键值对的实际存储位置是通过哈希公式计算的:
keccak256(abi.encodePacked(key, slotIndex))
- key:是映射的键值,例如 uint256 或 string
- slot:是映射变量的基础槽位(变量的顺序决定的)
- 该哈希值表示映射键对应的数据存储槽位置
contract StorageExample4 {
mapping(uint256 => string) a;
mapping(string => uint128) b;
constructor() {
a[1] = "aaaaaaa";
a[2] = unicode"啦啦啦已经超过了一个插槽存储量";
b["aaaaaaa"] = 123;
b[unicode"啦啦啦已经超过了一个插槽存储量"] = 456;
}
}
对应的合约布局如下:
{
"storage": [
{
"astId": 5,
"contract": "src/StorageExample4.sol:StorageExample4",
"label": "a",
"offset": 0,
"slot": "0",
"type": "t_mapping(t_uint256,t_string_storage)"
},
{
"astId": 9,
"contract": "src/StorageExample4.sol:StorageExample4",
"label": "b",
"offset": 0,
"slot": "1",
"type": "t_mapping(t_string_memory_ptr,t_uint128)"
}
]
}
然后同样部署合约,利用 foundry 工具实际验证一下合约的存储布局:
- 首先计算一下映射的实际存储位置:
console.logBytes32(keccak256(abi.encodePacked(uint(1),uint(0))));
console.logBytes32(keccak256(abi.encodePacked(uint(2),uint(0))));
console.logBytes32(keccak256(abi.encodePacked("aaaaaaa",uint(1))));
console.logBytes32(keccak256(abi.encodePacked(unicode"啦啦啦已经超过了一个插槽存储量",uint(1))));
=============>
Logs:
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569
0xc2a1e4bd6f4e0c99b3d20580b7d1088e8562190e53bbee39d37e499ebf199d92
0x5ae00d69e1a9025002ca27b7340ab7d6ba47969c081349aa84b0aaec666376b7
- 查看合约存储:
其中因为
a[2] = unicode"啦啦啦已经超过了一个插槽存储量"
数据长度超出了 32 bytes, 所以和 string 类型一样,0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569
slot 存储的是其长度 length, 实际数据存储在keccak256("0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569")
的区域
复合嵌套结构存储
- struct 定义的对象: 类似合约, 合约中定义的变量怎么存储的, struct 中定义的变量也一样
- 其他类似
mapping(string -> mapping(uint256, array[]))
这样的数据结构, 其实熟悉了基础的数据结构存储布局, 都是类似的, 一层层嵌套就是了
contract StorageExample5 {
mapping(string => mapping(uint256 => uint128[])) a;
Entry entry;
constructor() {
// 初始化 a
a["example"][0] = new uint128[](1);
a["example"][0][0] = 123;
// 初始化 entry
entry.a1 = 1;
entry.a2 = 2;
entry.str1 = new string[](1);
entry.str1[0] = "example";
entry.map1["example"] = 123;
}
}
struct Entry {
uint128 a1;
uint128 a2;
string[] str1;
mapping(string => uint128) map1;
}
存储布局
"storage": [
{
"astId": 8,
"contract": "src/StorageExample4.sol:StorageExample5",
"label": "a",
"offset": 0,
"slot": "0",
"type": "t_mapping(t_string_memory_ptr,t_mapping(t_uint256,t_array(t_uint128)dyn_storage))"
},
{
"astId": 11,
"contract": "src/StorageExample4.sol:StorageExample5",
"label": "entry",
"offset": 0,
"slot": "1",
"type": "t_struct(Entry)88_storage"
}
]
合约部署后实际数据存储:
- 先把嵌套结构拆分,计算对应 hash 值
// 第一层映射的 slot keccak256(abi.encodePacked(key, base_slot))
bytes32 firstLevelSlot = keccak256(abi.encodePacked("example", uint256(0)));
console.logBytes32(firstLevelSlot);
// 第二层映射的 slot(对应 [0]) keccak256(abi.encodePacked(key, firstLevelSlotIndex))
bytes32 secondLevelSlot = keccak256(abi.encodePacked(uint256(0), firstLevelSlot));
console.logBytes32(secondLevelSlot);
// 数据是 uint128 数组存储的, slotIndex = keccak256(abi.encodePacked(secondLevelSlot));
bytes32 dataSlot = keccak256(abi.encodePacked(secondLevelSlot));
console.logBytes32(dataSlot);
// struct entry entry.str1 存储
console.logBytes32(keccak256(abi.encodePacked(uint(2))));
// struct entry entry.map1 存储
console.logBytes32(keccak256(abi.encodePacked("example",uint(3))));
===============》 hash 结果
Logs:
0x771bca25177373271f7eb02dba7a7e491f3e9a010594ae693c9d0589bd0ef0ad
0x28e659fa23069222f68a163658cf100eb389a1b2cfd84d0fbefb91c4d23185c5
0x98cbac730a90ebabfbd47256e75b8263143e055f96bbe256a4a46b484fc5ae37
0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace
0xd52f7bc46d31b7d53bc1961785cee789643168c30e0a5ec758e6635c766c4279
- 合约部署后查看数据存储
EVM 存储管理机制
// TODO EVM 中的合约状态变量 & 存储槽布局的管理和实现底层机制
最佳实践
- 新写合约时,根据需求定义变量类型,尽可能减少变量范围,也就占用更少的字节数, 同时可以调整变量定义顺序,让定长数据共享一个 slot,减少 gas 存储成本
struct Entry {
uint256 age;
uint256 nonce;
uint256 ethBalance;
}
就可以做如下优化, 让 3 个变量共享一个 slot
struct Entry {
uint24 age;
uint8 nonce;
uint128 ethBalance;
}
- 不要轻易调整已上线合约代码中的变量定义顺序,声明定义的顺序会影响到存储布局
- 考虑到后续合约可能会添加新变量,所以在创建合约的时候加一个占位变量,比如 uint256[10] occupy,后续如果新加变量,只需要在其上面定义即可, 然后修改占位变量数组长度
uint a;
address b;
...
uint[10] occupy // 占位变量