Solidity 中的存储类型

Solidity 中的变量大体上分为三种存储类型:

  1. Storage(存储): 永久存储,数据会持续存在,直到合约被销毁或数据被更新, 也是 gas 成本最高的
  2. 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;
    }
}
  1. 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 存储 solidity-store1
  • 定长数据是直接按 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 的数据很短。那么它们的长度也会和数据一起存储到同一个插槽。 具体为:

  1. 如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length
  2. 如果数据长度超出 31 字节,则在主插槽存储 length + 1, 数据按顺序存储在 keccak256(slot)、keccak256(slot)+1 ... keccak256(slot) + n
contract StorageExample2 {
    string str1 = "aaaaaaa"; 
    string str2 = unicode"啦啦啦已经超过了一个插槽存储量";
} 

然后用 forge 工具查看其存储布局: solidity-store2

不定长数据存储 - 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 solidity-store3

不定长数据存储 - 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 工具实际验证一下合约的存储布局:

  1. 首先计算一下映射的实际存储位置:
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
  1. 查看合约存储: solidity-store4

其中因为 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"
    }
  ]

合约部署后实际数据存储:

  1. 先把嵌套结构拆分,计算对应 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
  1. 合约部署后查看数据存储 solidity-store5

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 // 占位变量