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

  • A+
所属分类:以太坊ETH

chatGPT账号

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

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

映射

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

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

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

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

// 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 将地址 0x01 和 0x03 分别映射到 9 和 10,并将它们存储在映射变量 balance 中。使用 Solidity 获取分配给 address(0x01) 的值是直接的。但它使用了哪个存储槽,我们如何使用汇编访问它?

映射的存储槽

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

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

上述步骤的公式

byte32   storageSlot =keccak256(byte32(key) ⊕byte32(baseSlot));byte32storageSlot=keccak256(byte32(key)byte32(baseSlot));

其中  表示连接

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

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

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

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

注意键和基槽值在连接之前首先用零填充为 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

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

接下来,我们创建一个 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) 键的正确值:

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

以下是完整代码,您可以在 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)
    }
}

嵌套映射

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

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

嵌套映射

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

嵌套映射的存储槽

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

获取嵌套数组值代码示例

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

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

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

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

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

0x0b061f98898a826aef6fdfc2d8eb981af54b85700e4516b39466540f69aced0f

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

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

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

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

数组

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

固定大小数组

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

固定大小数组的槽分配

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

请考虑下面的合约:

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

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

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

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

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

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

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

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

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

打包值如何分配槽:

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

动态数组

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

动态数组的槽分配

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

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

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

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

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

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

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

存储插槽的编号从 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 的特定存储插槽。最后,我们在获得的 _slot 中 sload 该值。

在 Remix 上测试:

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

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

元素被打包到存储插槽中,直到可用空间被填满。只有像 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 value ⇒ bytes32 value:返回值,以便我们可以轻松查看值是如何打包的。

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

在 Remix 上测试:

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

返回值:

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

嵌套数组

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

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

C ⇒ 绿色

R ⇒ 红色

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

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

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 是在上述合约中声明的结构体,由于它是在 someNumber 和 someNumber1 变量之后声明的,其基础槽将是槽 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 目标槽来确定目标槽(绿色框):

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

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

免责声明

免责声明:

本文不代表知点网立场,且不构成投资建议,请谨慎对待。用户由此造成的损失由用户自行承担,与知点网没有任何关系;

知点网不对网站所发布内容的准确性,真实性等任何方面做任何形式的承诺和保障;

网站内所有涉及到的区块链(衍生)项目,知点网对项目的真实性,准确性等任何方面均不做任何形式的承诺和保障;

网站内所有涉及到的区块链(衍生)项目,知点网不对其构成任何投资建议,用户由此造成的损失由用户自行承担,与知点网没有任何关系;

知点区块链研究院声明:知点区块链研究院内容由知点网发布,部分来源于互联网和行业分析师投稿收录,内容为知点区块链研究院加盟专职分析师独立观点,不代表知点网立场。

  • 我的微信
  • 这是我的微信扫一扫
  • weinxin
  • 我的电报
  • 这是我的电报扫一扫
  • weinxin
chatGPT账号
知点

发表评论

您必须登录才能发表评论!