Solidity 复杂类型状态变量在 EVM 中存储布局

  • RareSkills
  • 更新于 2024-10-15 17:53
  • 阅读 617

全面理解复杂类型的存储布局

动态类型的存储槽(映射、数组、字符串、字节)

Solidity 中的动态大小类型(有时称为复杂类型)是具有可变大小的数据类型。它们包括映射、嵌套映射、数组、嵌套数组、字符串、字节以及包含这些类型的结构体。本文展示了它们如何被编码并保存在存储中。

映射

映射用于以键值对的形式存储数据。

下面的彩色键值将在即将到来的代码块中引用:

显示多个键分配给其各自值的图示

考虑这个使用映射将以太坊地址与值关联的示例。上图中显示的红色和绿色键值在下面的代码中设置:

// SPDX-License-Identifier: MIT
pragma solidity =0.8.26;

contract MyMapping {
    mapping(address => uint256) private balance; // storage slot 0

    function setValues() public {
        balance[address(0x01)] = 9;      //  红色
        balance[address(0x03)] = 10;     // 绿色
    }
}

函数 setValues 将地址 0x010x03 分别映射到 9 和 10,并将它们存储在映射变量 balance 中。使用 Solidity 获取分配给 address(0x01) 的值是直接的。但它使用了哪个存储槽,我们如何使用汇编访问它?

映射的存储槽

要计算值的存储槽,我们采取以下步骤:

  1. 将与值关联的键与映射变量存储槽(基槽 baseSlot)连接
  2. 对连接的结果进行哈希。

上述步骤的公式

$$ \texttt{byte32} \,\,\,\text{storageSlot}\,=\texttt{keccak256}(\texttt{byte32}(\text{key}) \, \oplus \texttt{byte32}(\text{baseSlot}));$$

其中 $\oplus$ 表示连接

以下动画展示了公式中数据的布局方式:

https://img.learnblockchain.cn/video/keccakanimr1_1.mp4

在底层,键和基槽都存储为 256 位(32 字节)值。当它们连接在一起时,它们是一个 64 字节的值。

下面的动画展示了这些值(键和基槽)如何连接。使用的值是:

  • 地址键 = 0x504DbB5Dc821445b142312b74693d778a1B60b2f
  • uint256 基槽 = 6

https://img.learnblockchain.cn/video/storageslot4.mp4

注意键和基槽值在连接之前首先用零填充为 32 字节值。连接的结果(64 字节数组)是通过哈希确定存储槽的。

计算映射存储槽

现在我们已经了解了如何计算键和基槽以获得映射的存储槽,我们准备看看如何在 Solidity 中手动完成。

记住,我们需要两个值来计算映射的槽(键和基槽)。实现此目的的代码在 getStorageSlot() 函数中:

contract MyMapping {
    mapping(address => uint256) private balance; // storage slot 0

    function setValues() public {
        balance[address(0x01)] = 9;      // RED
        balance[address(0x03)] = 10;     // GREEN
    }

    //*** 新添加的函数 ***//
    function getStorageSlot(address _key) public pure returns (bytes32 slot) {
            uint256 balanceMappingSlot;

            assembly {
                 // `.slot` 返回状态变量(balance)在存储槽中的位置。
                 // 在我们的例子中,balance.slot = 0
                 balanceMappingSlot := balance.slot
            }

            slot = keccak256(abi.encode(_key, balanceMappingSlot));
        }
}

getStorageSlot 函数接受 _key 作为参数,并使用汇编块获取 balance 变量的基槽 (balanceMappingSlot)。然后使用 abi.encode 将每个值填充到 32 字节并连接它们,然后使用 keccak256 对连接的值进行哈希以生成存储槽。

为了测试这一点,让我们以 address(0x01) 作为参数调用该函数,因为我们已经在 setValues 函数中为与此 _key 关联的存储槽分配了一个值。

调用后的返回槽:0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d

调用 getStorageSlot 函数时返回槽的 Remix 截图

接下来,我们创建一个 getValue() 函数,它将加载我们计算的存储槽。此函数用于证明 getStorageSlot() 计算的槽确实是保存该值的正确存储槽。

function getValue(address _key) public view returns (uint256 value) {
    // 调用辅助函数获取槽
    bytes32 slot = getStorageSlot(_key);

    assembly {
        // 加载存储在槽中的值
        value := sload(slot)
    }
}

address(1) 作为参数调用 getValue 函数返回 9,这是分配给 address(1) 键的正确值:

调用 getValue 函数时返回值的 Remix 截图

以下是完整代码,您可以在 Remix 上测试。

// SPDX-License-Identifier: MIT
pragma solidity =0.8.26; 

contract MyMapping {
    mapping(address => uint256) private balance; // storage slot 0

    function setValues() public {
        balance[address(0x01)] = 9;
        balance[address(0x03)] = 10;
    }

    function getStorageSlot(address _key) public pure returns (bytes32 slot) {
        uint256 balanceMappingSlot;

        assembly {
            // `.slot` 返回状态变量(balance)在存储槽中的位置。
            // 在我们的例子中,0
            balanceMappingSlot := balance.slot
        }

        slot = keccak256(abi.encode(_key, balanceMappingSlot));
    }
}

function getValue(address _key) public view returns (uint256 value) {
    // 调用辅助函数获取
    bytes32 slot = getStorageSlot(_key);

    assembly {
        // 加载存储在槽中的值
        value := sload(slot)
    }
}

嵌套映射

嵌套映射是另一个映射中的映射。一个常见的用例是为特定地址存储不同代币的余额,如下图所示。

显示不同地址的不同代币余额的图示

这表明 balance 变量持有两个不同的地址,0xbob0xAlice,每个地址都与多个代币相关联,这些代币又映射到不同的余额,因此是嵌套映射。

嵌套映射的存储槽

嵌套映射的存储槽计算与单一映射类似,不同之处在于映射的“层级”对应于哈希操作的次数。下面是一个动画和代码示例,演示了具有两个哈希操作的两级映射:

https://img.learnblockchain.cn/video/keccakanimr2_2.mp4

获取嵌套数组值代码示例

现在让我们展示一个使用汇编从存储中获取嵌套数组值的代码示例

在下面的截图中,值 5 被分配给 balance 映射,键为 address(0xb0b)(所有者)和 1111(tokenID),如黄色框中所示。合约有两个函数:

  • getStorageSlot 函数接收两个参数,即派生所需槽的键。函数中有两个哈希操作,如红色框中所示:
    • 第一个是 _key1(所有者)和 balance 映射槽的哈希,然后存储在 initialHash 变量中。
    • 第二个是 _key2(tokenID)和 initialHash 的哈希,以获取 balance[_key1][_key2] 的槽。如果是三级映射,第三个键(_key3)将与第二个哈希操作的值进行哈希以获得所需的存储槽,依此类推。
  • getValue 函数接收一个槽作为参数并返回其中的值,其行为与前一个示例相同。

remix 截图显示分配给嵌套映射的值以及获取槽和槽值的函数

调用 getStorageSlot 函数,参数为 address(0xb0b)1111,返回以下槽:

0x0b061f98898a826aef6fdfc2d8eb981af54b85700e4516b39466540f69aced0f

remix 截图显示调用 getStorageSlot 函数后返回的槽

为了证明计算出的槽持有值 5,我们将调用 getValue 函数并将槽作为参数传递。此函数使用 sload 操作码加载槽,然后返回其值:

remix 截图显示调用 getValue 函数后返回的槽值

是的!我们得到了在构造函数中插入的相同值 5

数组

这是 Solidity 中用于存储相同类型元素的索引集合的动态类型,可以是原始类型或动态类型。Solidity 支持两种数组类型:固定大小和动态,具有不同的存储分配方法。

固定大小数组

这种类型的数组具有在声明后无法更改的预定大小。

固定大小数组的槽分配

如果每个数组元素的类型占用一个存储槽容量(256 位,32 字节或 1 个字),Solidity 编译器将这些元素视为单独的存储变量,从数组的存储变量槽开始顺序分配槽。

请考虑下面的合约:

contract MyFixedUint256Array {
    uint256 public num; // 存储槽 0

    uint256[3] public myArr = [
                                4, // 存储槽 1 
                                9, // 存储槽 2
                                2  // 存储槽 3
                            ]; 
}

由于 numuint256 类型,并且是合约中的第一个状态变量,它占据了整个存储槽 0。第二个状态变量 myArr 是一个具有三个元素的 uint256 固定大小数组,这意味着每个元素将占据其自己的存储槽,从槽 1 开始。

下面的动画显示了如何为每个变量分配存储槽,详细说明了每个存储变量中的值如何存储在槽中。

https://img.learnblockchain.cn/video/storageslottanim.mp4

让我们看另一个示例,与前一个类似,但这次使用 uint32 作为数组的数据类型:

contract MyFixedUint32Array {
    uint256 public num; // 存储槽 0

    uint32[3] public myArr = [
                                4, // 存储槽 ??? 
                                9, // 存储槽 ???
                                2  // 存储槽 ???
                            ]; 
}

在继续阅读之前,你能说出数组中第三个元素的存储槽吗?如果你认为它可能是槽 3,类似于前一个示例,你可能需要重新考虑。

如果每个数组元素的类型占用整个存储槽,例如此示例中的 uint32,编译器会将多个元素打包在一个槽中,直到填满或没有足够的空间容纳下一个元素,然后再移动到下一个槽。这类似于当存储变量不单独占据整个槽时,编译器将它们打包在一起的方式。

打包值如何分配槽:

https://img.learnblockchain.cn/video/storageslottanim2_2.mp4

注意:访问打包元素将消耗更多 gas,因为 EVM 需要添加除通常的 sload 之外的额外指令。只有在元素通常在同一交易中访问并因此可以共享冷加载成本时,才建议打包元素。

动态数组

与在编译时已确定大小的固定大小数组不同,动态数组可以在运行时更改大小。

动态数组的槽分配

通常,动态数组的长度存储在某个地方,因为在编译时未知。Solidity 遵循这一原则,将动态数组的长度存储在单独的存储槽中。以下是如何为动态数组的长度和元素分配槽。

为数组长度分配的存储槽与数组存储变量(基槽)相同。以下是一个说明这一点的示例:

remix 截图显示槽 0 的值

myArr 变量有三个元素,长度为 3。getSlotValue 函数,顾名思义,接收一个槽号并返回存储在其中的值。在我们的例子中,我们传递槽 0 作为参数,因为这是为 myArr 存储变量分配的槽。然后我们使用 sload 操作码从槽中加载值。

数组值按顺序存储在存储槽中,每个存储槽是数组中的一个索引。第一个元素(索引 0)的存储槽由基存储槽的 keccak256 哈希确定。下图说明了这一点。

插槽 2 的 keccak 哈希指向存储第一个元素的插槽,然后我们不断将该值加 1,以获取数组中其他索引的存储位置:

显示插槽 keccak 哈希的图示

存储插槽的编号从 0 到 2²⁵⁶ – 1,这正是 keccak256 输出的值范围。图中的第一个红色值 (0x405787...5ace) 表示从插槽 2 派生的哈希存储位置,该位置存储数组的第一个元素。每个后续值(0x405787...5acf0x405787...5ad0)是前一个值的递增,对应于数组中的下一个元素。对于每个附加元素,这种模式继续,存储位置根据数组的大小顺序递增。

例如,考虑一个位于存储插槽 2 的长度为 5 的数组,包含类型为 uint256 的元素 [3, 4, 5, 9, 7]

contract MyDynArray {
    uint256 private someNumber;                   // storage slot 0
    address private someAddress;                  // storage slot 1
    uint256[] private myArr = [3, 4, 5, 9, 7];    // storage slot 2

    function getSlotValue(uint256 _index) public view returns (uint256 value) {
        uint256 _slot = uint256(keccak256(abi.encode(2))) + _index;
        assembly {
            value := sload(_slot)
        }
    }
}

要找到存储值 9 的存储插槽,我们首先使用 keccak256 哈希基插槽 (2)。然后将元素的索引(索引 = 3)添加到哈希值中。此计算为我们提供了存储值 9 的特定存储插槽。最后,我们在获得的 _slotsload 该值。

在 Remix 上测试:

Remix 中插槽 3 的值的截图

当元素没有用完存储插槽空间时会发生什么?

元素被打包到存储插槽中,直到可用空间被填满。只有像 128 位(16 字节)或更小的类型可以被打包。然而,地址每个占用 20 字节,不会被打包,因为两个地址(40 字节)超过了单个存储插槽的大小。

让我们在 MyDynArray 合约中将 myArr 改为使用 uint32 而不是 uint256

contract MyDynArray {
    uint256 private someNumber;                   // storage slot 0
    address private someAddress;                  // storage slot 1
    uint32[] private myArr = [3, 4, 5, 9, 7];     // storage slot 2

    function getSlotValue(uint256 _index) public view returns (bytes32 value) {
        uint256 _slot = uint256(keccak256(abi.encode(2))) + _index;
        assembly {
            value := sload(_slot)
        }
    }
}

进行了以下更改:

  • uint256[]uint32[]:动态数组的数据类型。
  • uint256 valuebytes32 value:返回值,以便我们可以轻松查看值是如何打包的。

每个元素占用每个存储插槽可用 32 字节中的 4 字节。对于 5 个元素,总大小为 4 * 5 = 20 字节。这意味着所有元素都可以放入单个存储插槽中,仍有一些空间剩余。

在 Remix 上测试:

显示插槽 0 中值的截图

返回值:

显示元素如何打包在单个插槽中的���示

嵌套数组

嵌套数组是包含其他数组的数组。它可以用于表示类似矩阵的数据,其中每行的元素是一个数组,列是该数组中的索引。

下面的解释动画使用 C 表示列,R 表示行。

C ⇒ 绿色

R ⇒ 红色

https://img.learnblockchain.cn/video/MatrixA_1.mp4

固定大小嵌套数组的存储插槽

编译器为固定大小嵌套数组中的元素分配插槽,就像为常规固定大小数组分配插槽一样。每个元素从基插槽开始递增分配插槽,如果它占用整个插槽。否则,它与其他元素一起打包,直到插槽空间被填满。

这里有一个简单的动画,说明了固定大小嵌套数组如何存储数据:

https://img.learnblockchain.cn/video/privAanim.mov

动态嵌套数组的存储插槽

正如我们已经知道的,确定动态数组中特定元素的存储插槽的步骤如下:

  1. keccak 哈希基插槽
  2. 然后将元素的索引添加到哈希值

对于动态嵌套数组,该过程涉及为每个嵌套级别重复上述步骤,以找到最终插槽。

假设我们有一个两级嵌套数组,即数组中的数组:

$$\texttt{uint256[][]}\,\,\text{array}\,=\,[\,[a,\,b,\,c],\,[d,\,e,\,f]\,]$$

确定元素 f 的存储插槽的步骤是:

  1. keccak 哈希数组基插槽,然后添加包含该元素的子数组的索引。在我们的例子中,是第二个子数组。
  2. keccak 哈希步骤 1 的结果,然后添加子数组中元素 f 的索引。

这里有一个动画说明了上述步骤:

https://img.learnblockchain.cn/video/keccakanimr2_5.mp4

我们首先哈希基插槽并添加子数组的索引(sub-array1,即基数组中的索引 1),这给我们初始哈希(存储子数组的插槽)。接下来,我们哈希此初始哈希并添加 sub-array1 中元素 f 的索引(即 2),以确定最终插槽。

uint256 动态嵌套数组中获取元素插槽的实际示例:

contract MyNestedArray { 
    uint256 private someNumber;                     // storage slot 0

        // Initialize nested array
    uint256[][] private a = [[2,9,6,3],[7,4,8,10]]; // storage slot 1

    function getSlot(uint256 baseSlot, uint256 _index1, uint256 _index2) public pure returns (bytes32 _finalSlot) {
                // keccak256(baseSlot) + _index1
        uint256 _initialSlot = uint256(keccak256(abi.encode(baseSlot))) + _index1;

                // keccak256(_initialSlot) + _index2
        _finalSlot = bytes32(uint256(keccak256(abi.encode(_initialSlot))) + _index2);
    }

    function getSlotValue(uint256 _slot) public view returns (uint256 value) {
        assembly {
            value := sload(_slot)
        }
    }
}

假设我们想在上述合约中的数组 [[2,9,6,3],[7,4,8,10]] 中找到存储元素 8 的存储插槽。

  1. 我们需要识别三件事:

    1. 嵌套数组的基插槽
    2. 包含该元素的子数组的索引,
    3. 以及该子数组中元素的索引。

    这些索引是获取我们所需插槽所必需的。

  2. 我们调用 getSlot 函数,传递基插槽和索引的值:

    1. baseSlot:数组 a 的插槽,即插槽 1。
    2. _index1:包含该元素的子数组([7,4,8,10])位于索引 1。
    3. _index2:子数组中元素 8 位于索引 2。

    调用后的返回插槽:

    0xea7809e925a8989e20c901c4c1da82f0ba29b26797760d445a0ce4cf3c6fbd33

  3. 最后,调用 getSlotValue 函数,传递步骤 2 中返回的插槽。

字符串

在 Solidity 中,字符串是动态类型,这意味着它们没有固定长度。有些字符串可能适合单个存储槽,而其他字符串可能需要多个槽。

考虑以下示例合约:

contract String {
    string public myString;
    uint256 public num; 
}

string 的存储槽是 0,而 uint256 的存储槽是 1。

如果我们在 myString 中存储一个短字符串数据(小于 32 字节,我们稍后会讨论为什么 32 字节的字符串也被认为是长字符串),我们可以从槽 0 中检索它而不会有任何问题。

然而,如果我们存储一个更长的字符串数据,比如占用 42 字节,它将溢出槽 0 并覆盖槽 1,而槽 1 最初是为 num 变量保留的。

这是因为槽 0 不足以容纳更长的字符串。为防止此问题,Solidity 根据字符串的长度使用不同的方法为 string 类型分配存储槽。

📔 1 个字节的十六进制字符串长度为 2 个字符,因此,当我们说 “一个字符串在插槽中占用 31 个字节 ”时,意思是该字符串的十六进制字符为 62 个。字符串的长度就是它的字节数,一个字节就是两个十六进制字符。

字符串的存储槽

存储变量槽(基础槽)存储短字符串及其长度信息,或仅存储长字符串的长度信息,这些情况将在下面的不同部分中研究。

短字符串(≤ 31 字节):

字符串数据及其长度一起存储在基础槽中。字符串从左侧打包,其长度存储在槽的最右侧字节。对于短字符串,字符串的最大长度为 31 个字符。然而,协议实际存储的是字符串长度乘以 2,因为每个字符在存储中占用一个字节。这意味着短字符串可以存储的最大值是 31 * 2 = 62,即十六进制的 0x3e

下面是一个短字符串 Hello World 的十六进制示例。零是可以用来存储最长达 31 字节的字符串的空闲空间,最后一个字节保存 (字符串长度) * 2

槽中存储 "Hello World" 字符串及其长度的示意图

这里 0x16 = 22 是 2 * 11,其中 11 是字符串 Hello World 的长度

长字符串(> 31 字节):

(字符串长度 * 2) + 1(我们稍后会解释为什么要加 1)存储在基础槽中,然后字符串的十六进制数据存储在连续的存储槽空间中。字符串数据的前 32 字节存储在基础槽的 keccak256 哈希中。接下来的 32 字节存储在基础槽加一的哈希中,接下来的存储在哈希加二中,依此类推,直到整个字符串存储完毕。

以下动画展示了长度和长字符串(以十六进制表示)如何存储在存储槽中:

https://img.learnblockchain.cn/video/storageslot2_4.mp4

在存储长字符串的长度之前,编译器会在其上加 1(使其从偶数变为奇数)。例如,上述动画中的字符串占用 47 字节(32 + 15),这意味着其长度为 47 * 2 = 94(十六进制为 0x5e)。Solidity 编译器然后在此长度上加 1,使其变为 95(十六进制为 0x5f),并将此值存储在基础槽中。

这样做的原因是允许运行时字节码有效地区分短字符串和长字符串。对于短字符串,长度始终为偶数,因此存储在基础槽中的值的最后一位始终为零。另一方面,长字符串(32 字节或更长)始终具有奇数长度,这意味着最后一位始终为一。

https://img.learnblockchain.cn/video/storageslot2_1_1.mp4

优化的偶数和奇数检查

大多数编程语言中检查一个数字是偶数还是奇数的常用方法是使用模运算符(num % 2)并检查余数是否为 0。这在 Solidity 中也适用。然而,更优化的方法是使用按位与操作:num & 1 == 0。下面是两种方法及其各自的成本示例:

contract ModMethod {
        // Gas cost: 761 
    function isEven(uint256 num) public pure returns (bool x) {
        x = (num % 2) == 0;
    }
}

contract BitwiseAndMethod {
        // Gas cost: 589
    function isEven(uint256 num) public pure returns (bool x) {
        x = (num & 1) == 0;
    }
}

获取字符串的长度

Solidity 中的字符串类型没有长度属性。这是因为某些字符,特别是非 ASCII 字符,可能占用多个字节,因此跟踪字符数量时可能会有不同的大小,导致过多的开销。然而,我们可以通过将字符串转换为字节来查看字符串占用了多少字节,如下面的示例所示:

remix 截图显示如何获取字符串的长度

text2 中,每个字符占用 3 个字节,总共 6 个字节。要在字符串上使用 length 属性,你需要将字符串转换为 bytes,如截图所示。

Bytes

与字符串类似,bytes 在 Solidity 中是动态类型,并遵循相同的槽分配规则。

  • 短 bytes ( ≤ 31 字节):完全存储在基础槽中,包括其长度(字节数 * 2)。
  • 长 bytes (> 31 字节):基础槽存储长度((字节数 * 2) + 1),实际数据存储在从基础槽的 keccak256 哈希开始的连续槽中。

固定大小字节

它们是用于存储固定数量字节的类型。这些类型范围从 bytes1bytes32,这意味着你可以拥有固定大小的字节数组,存储 1 到 32 字节。

存储一个大于或小于使用的字节大小的值将抛出编译时错误。在下图中,变量 value2value4 被分配了不符合其预期字节大小的值,导致编译错误。

remix 截图显示不同字节大小分配的不同值

我们在大多数之前的代码示例中使用 bytes32 来保存 keccak256 哈希。

访问单个字节

可以使用索引访问固定大小 bytes 数组中的字节。

例如,以下合约访问第一个字节:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

contract FixedBytes {
    bytes4 value = hex"01020304";

    function accessFirstByte() public view returns (bytes1) {
            bytes1 individualByte = value[0];  // 访问第一个字节

        return individualByte;    // 返回第一个字节
    }
}

accessFirstByte 函数返回一个单字节(bytes1)。在函数内部,value[0] 访问 value 数组的第一个字节。然后返回此字节。

在 Solidity 版本 0.8.0 之前,byte 类型用于表示单个字节。在 0.8.0 及以上版本中,bytes1 现在是用于保存单字节值的首选类型。

字符串/字节和 bytes1[] 的比较

两者都是存储字节值的动态类型,在这两种情况下,字节都是通过其索引访问的。然而,关键区别在于字节值的存储方式。

考虑以下合约,它们为类型 bytesbytes1[] 存储相同的字节值:

contract Bytes {
    bytes foo_bytes = hex"ffeedd";

    // helper to get slot value
    function getSlotValue() public view returns (bytes32 x) {
        assembly {
            x := sload(0)
        }
    }
}

contract Bytes1Array {
    bytes1[] bar_bytes = [bytes1(hex"ff"), bytes1(hex"ee"), bytes1(hex"dd")];

    // helper to get slot value
    function getSlotValue() public view returns (bytes32 x) {
        bytes32 _slot = keccak256(abi.encode(0));
        assembly {
            x := sload(_slot)
        }
    }
}

由于分配给 foo_bytes 变量的值是一个短的 bytes 序列(即,≤ 31 字节),因此值及其长度(字节数 * 2)存储在同一个存储槽(基础槽)中,如下所示:

另一方面,bar_bytes 变量是类型为 bytes1[](动态数组),其数组长度和值存储在不同的槽中:

长度存储在基础槽中:

$$\texttt{0x000000000000000000000000000000000000000000000000000000000000000}\textcolor{orange}{3}$$

值存储在基础槽的哈希中: $$ \texttt{0x0000000000000000000000000000000000000000000000000000000000}\textcolor{red}{\texttt{ddeeff}}$$

换句话说,短序列的 bytes 类型使用的存储槽比 bytes1[] 少。然而,对于超过 31 字节的序列,bytes 类型使用与 bytes1[] 相同的槽计算,导致使用相同数量的槽。

bytesbytes1[] 之间的另一个区别在于它们的值如何存储在槽中。对于 foo_bytes,整个值一次性放置在其槽中。相反,对于 bar_bytes,第一个元素存储在最低有效字节中,接着是下一个元素,这种模式持续到最后一个字节。

动画显示了分配给 foo_bytesbar_bytes 变量的新值如何占用两个槽(绿色和黄色的槽),其中 foo_bytes 占用槽 0,bar_bytes 占用槽 1:

https://img.learnblockchain.cn/video/keccak256_5.mp4

结构体

Solidity 中的结构体允许我们将多种数据类型的多个变量组合在一个名称下,并将其用作新类型。例如,如果我们需要一个合约来存储玩家信息,如 playerId、score 和 level,使用结构体将是理想的选择。这样,我们可以将每个玩家的所有相关细节组合在一个单一的、有组织的结构中。

结构体中的存储槽

Solidity 中的结构体充当变量的容器,结构体中每个字段的存储槽分配遵循我们之前讨论的相同规则。

让我们看一个例子:

contract MyStruct {
    // Define a Player struct
    struct Player {
        address playerId;
        uint256 score;
        uint256 level;
    }

    uint256 private someNumber = 99; 

    /*
    AFTER DIFFERENT DECLARATIONS ABOVE, THE NEXT AVAILABLE SLOT IS: 6
    */

    // Declare a state variable of type Player
    Player private thePlayer;
}

在不运行代码的情况下,你能猜出槽 0 的值吗?如果你认为是 99,你是正确的。这是因为 在 Solidity 中定义结构体不会占用存储槽空间,直到它被声明,因此编译器将 someNumber 变量视为第一个存储变量。

声明一个 Player 结构体类型的变量:

让我们通过声明一个结构体来检查结构体的存储工作原理——这将导致它实际占用存储。注意,我们是在声明它,而不是像之前那样定义它。

/*
AFTER DIFFERENT DECLARATIONS ABOVE, THE NEXT AVAILABLE SLOT IS: 6
*/

// Declare a state variable of type Player
Player private thePlayer;

Player 结构体中的字段将占用从基础槽开始的三个连续存储槽。playerId 字段是类型为 address,占用槽中可用的 32 字节中的 20 字节。scorelevel 字段是类型为 uint256,每个占用 256 位(32 字节)的完整槽空间。已知这些,我们可以说字段的存储槽分别是 6、7 和 8。

结构体中动态类型的存储槽分配

另一个例子是结构体中包含动态类型。让我们修改之前的例子以使用映射并为其分配一些值:

contract MyStruct {
    // Define a Player struct
    struct Player {
        address playerId;
        mapping(uint256 level => uint256 score) playerScore;
    }

    uint256 private someNumber = 23; // storage slot 0
    uint256 private someNumber1 = 77; // storage slot 1

    // Declare a state variable of type Player
    Player private thePlayer;

    constructor () {
        // Set deployer's address as player's id
        thePlayer.playerId = msg.sender;

        // Set player's score to 100 for level 1 and 68 for level 2
        thePlayer.playerScore[1] = 100;
        thePlayer.playerScore[2] = 68;
    }
}

计算结构体中映射值的存储槽的步骤是:

  1. 确定 thePlayer 结构体的基础槽:此槽在合约中声明结构体时确定。
  2. 计算结构体中 playerScore 映射的槽:此槽由结构体中映射声明的顺序确定。
  3. 哈希键和映射基础槽的连接,即步骤 2 中获得的槽。

了解这些步骤后,我们可以计算存储玩家等级 2 分数的存储槽。

步骤 1:确定 thePlayer 的基础槽

  • thePlayer 是在上述合约中声明的结构体,由于它是在 someNumbersomeNumber1 变量之后声明的,其基础槽将是槽 2(因为 someNumber 占用槽 0,someNumber1 占用槽 1)。

步骤 2:计算结构体中 playerScore 映射的槽

// Define a Player struct
struct Player {
     address playerId;
     mapping(uint256 level => uint256 score) playerScore;
}
  • 从结构体的基础槽(槽 2)开始,每个字段按顺序分配槽。这意味着第一个字段 playerId 类型为 address,占用槽 2(基础槽),而第二个字段 playerScore 映射,放置在下一个槽,即槽 3(映射的基础槽)。

步骤 3:哈希键和映射基础槽的连接

  • 了解键和映射的基础槽后,我们可以通过连接和哈希它们来计算目标存储槽。

下图显示了如何通过传递正确的键(在本例中为等级 2)和映射的基础槽(3),然后 sload 目标槽来确定目标槽(绿色框):

remix 截图显示结构体中映射的槽及其值

在蓝色框中,是从 sload 目标槽返回的值(玩家等级 2 的分数)。

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/