变量的布局

状态变量在 storge 中的布局

合约的状态变量以一种紧凑的方式存储在区块链存储中,以这样的方式,有时多个值会使用同一个存储槽

除了动态大小的数组和 mapping,数据的存储方式是从位置 0 开始连续放置在 storage 中。对于每个变量,根据其类型确定字节大小。

存储大小少于 32 字节的多个变量会被打包到一个存储插槽(storage slot)中,规则如下:

  • 存储插槽的第一项会以低位对齐的方式储存。

  • 值类型仅使用存储它们所需的字节。

  • 如果存储插槽中的剩余空间不足以储存一个值类型,那么它会被存入下一个存储插槽。

  • 结构体(struct)和数组数据总是会开启一个新插槽(但结构体或数组中的各元素,则按规则紧密打包)。

  • 结构体和数组之后的数据也或开启一个新插槽。

对于使用继承的合约,状态变量的排序由 C3 线性化合约顺序(顺序从最基类合约开始)确定。如果上述规则成立,那么来自不同的合约的状态变量会共享一个存储插槽。

结构体和数组中的成员变量会存储在一起,就像它们单独声明时一样。

使用时候的注意

⚠️ 注意: 在使用小于 32 字节的变量时,合约的 gas 使用量可能会高于使用 32 字节的元素。这是因为 EVM 每次操作 32 个字节,所以如果元素比 32 字节小,EVM 必须执行额外的操作以便将其大小缩减到到所需的大小。

当我们在处理状态变量时,利用编译器会将多个元素(变量)缩减的存储大小打包到一个 存储插槽中,也许是有益,因为可以合并多次读写为单个操作。如果你不是在同一时间读或写一个槽中的所有值,这可能会适得其反。当一个值被写入一个多值存储槽时,必须先读取该存储槽,然后将其与新值合并,避免破坏同一槽中的其他数据,再写入。

当处理函数参数或 memory(内存)中的值时,因为编译器不会打包这些值,所以没有什么额外的益处。

书写时的注意

最后,为了允许 evm 对此进行优化,请确保 storage 中的变量和 struct 成员的书写顺序允许它们被紧密地打包。例如,应该按照 uint128,uint128,uint256 的顺序来声明状态变量,而不是使用 uint128,uint256,uint128,因为前者只占用两个存储插槽,而后者将占用三个。

⚠️ 注意: storage 中状态变量的布局被认为是 solidity 外部接口的一部分, 因此 storage 变量指针可以传递给库(library)函数。这意味着,本节所述规则的任何变更均被视为语言破坏性变更,并且由于其关键性质,在执行之前应该非常仔细地考虑,在发生这种破坏性变化的情况下,我们希望发布一种兼容模式,在这种模式下,编译器将生成支持旧布局的字节码。

mapping 和动态数组

由于 mapping 和动态数组不可预知大小,不能在状态变量之间存储他们。相反,他们自身根据以上规则仅占用 32 个字节,然后他们包含的元素的存储的起始位置,则是通过 Keccak-256 哈希计算来确定。

起始位置

假设 mapping 或动态数组根据上述存储规则最终可确定某个位置 p

  • 对于动态数组,此插槽中会存储数组中元素的数量(字节数组和字符串除外,见下文)。

  • 对于 mapping,该插槽未被使用(为空),但它仍是需要的,以确保两个彼此挨着 mapping,他们的内容在不同的位置上。

数组的元素会从 keccak256(p) 开始;它的布局方式与静态大小的数组相同。一个元素接着一个元素,如果元素的长度不超过 16 字节,就有可能共享存储槽。

动态数组的数组会递归地应用这一规则,例如,如何确定 x[i][j]元素的位置,其中 x 的类型是 uint24[][],计算方法如下(假设x本身存储在槽 p): 槽位于 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)),且可以从槽数据 v得到元素内容,使用 (v >> ((j % floor(256 / 24)) * 24)) & type(uint24).max.

mapping 中的键 k 所对应的槽会位于 keccak256(h(k) . p) ,其中 .是连接符, h 是一个函数,根据键的类型:

  • 值类型, h 与在内存中存储值的方式相同的方式将值填充为 32 字节。

  • 对于字符串和字节数组, h(k) 只是未填充的数据。

如果映射值是一个非值类型,计算槽位置标志着数据的开始位置。例如,如果值是结构类型,你必须添加一个与结构成员相对应的偏移量才能到达该成员。

例子说明

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

contract C {
    struct S {
        uint16 a;
        uint16 b;
        uint256 c;
    }
    uint256 x;
    mapping(uint256 => mapping(uint256 => S)) data;
}

让我们计算一下 data[4][9].c 的存储位置。映射本身的位置是 1(前面有 32 字节变量 x )。 因此 data[4] 存储在keccak256(uint256(4) . uint256(1))data[4] 的类型又是一个映射,data[4][9] 的数据开始于槽位keccak256(uint256(9). keccak256(uint256(4). uint256(1))

在结构 S 的成员 c 中的槽位偏移是 1,因为 ab被装在一个槽位中。 最后 data[4][9].c 的插槽位置是keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)) + 1.该值的类型是 uint256,所以它使用一个槽。

bytesstring

bytesstring 编码是一样的。

一般来说,编码与 bytes1[]类似,即有一个槽用于存放数组本身同时还有一个数据区,数据区位置使用槽的 keccak256 hash 计算。然而,对于短字节数组(短于 32 字节),数组元素与长度一起存储在同一个槽中。

具体地说:如果数据长度小于等于 31字节,则元素存储在高位字节(左对齐),最低位字节存储值 length * 2。如果数据长度大于等于 32 字节,则在主插槽 p 存储 length * 2 + 1,数据照常存储在 keccak256(p) 中。因此,可以通过检查是否设置了最低位:短(未设置最低位)和长(设置最低位)来区分短数组和长数组。

⚠️ 注意: 目前不支持处理无效编码的插槽,但可能在将来添加。如果你通过 IR 编译,读取一个无效的编码槽会导致 Panic(0x22) 错误。

JSON 输出

合约的存储布局可以通过 standard JSON interface 获取到。 输出 JSON 对象包含 2 个字段 storagetypesstorage 对象是一个数组。

文件: fileA 合约: contract A { uint x; }存储布局,它的每个元素有如下的形式。

{
    "astId": 2,
    "contract": "fileA:A",
    "label": "x",
    "offset": 0,
    "slot": "0",
    "type": "t_uint256"
}

每个字段说明如下:

  • astId 是状态变量声明的 AST 节点的 id。

  • contract 是合约的名称,包括其路径作为前缀。

  • label 是状态变量的名称。

  • offset 是根据编码在存储槽内以字节为单位的偏移量。

  • slot 是状态变量所在或开始的存储槽。这个数字可能非常大,因此它的 JSON 值被表示为一个字符串。

  • type 是一个标识符,作为变量类型信息的关键(如下所述)。

给定的 type,在本例中 t_uint256 代表 types中的一个元素,其形式为:

{
    "encoding": "inplace",
    "label": "uint256",
    "numberOfBytes": "32",
}

  • encoding 数据在存储中如何编码,可能的数值是:

    • inplace: 数据在存储中连续排列 (见 前面状态变量储存结构`).

    • mapping: Keccak-256 基于哈希的方法 (见 前面前面映射和动态数组`).

    • dynamic_array: Keccak-256 基于哈希的方法 (见 前面映射和动态数组`).

    • bytes: 单槽或基于 Keccak-256 哈希的方法,取决于数据大小 (见 前面 bytes).

  • label 是规范的类型名称 。

  • numberOfBytes 是使用的字节数(十进制字符串) 注意,如果numberOfBytes>32 意味着使用了一个以上的槽。

除了上述四个外,有些类型还有额外的信息。映射包含其 keyvalue类型(再次引用该类型映射中元素类型),数组有其 base 类型,结构以与顶层storage 相同的格式列出其 members (见 :ref:前面JSON 输出).

⚠️ 注意: 合约的存储布局的 JSON 输出格式仍被认为是实验性的,即使在 Solidity 的非突破性版本更新中也可能会发生变化。

例子

下面的例子显示了一个合约和它的存储布局,包含值类型和引用类型、被编码打包的类型和嵌套类型。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;
contract A {
    struct S {
        uint128 a;
        uint128 b;
        uint[2] staticArray;
        uint[] dynArray;
    }

    uint x;
    uint y;
    S s;
    address addr;
    mapping (uint => mapping (address => bool)) map;
    uint[] array;
    string s1;
    bytes b1;
}
{
	"storage": [
		{
			"astId": 15,
			"contract": "fileA:A",
			"label": "x",
			"offset": 0,
			"slot": "0",
			"type": "t_uint256"
		},
		{
			"astId": 17,
			"contract": "fileA:A",
			"label": "y",
			"offset": 0,
			"slot": "1",
			"type": "t_uint256"
		},
		{
			"astId": 20,
			"contract": "fileA:A",
			"label": "s",
			"offset": 0,
			"slot": "2",
			"type": "t_struct(S)13_storage"
		},
		{
			"astId": 22,
			"contract": "fileA:A",
			"label": "addr",
			"offset": 0,
			"slot": "6",
			"type": "t_address"
		},
		{
			"astId": 28,
			"contract": "fileA:A",
			"label": "map",
			"offset": 0,
			"slot": "7",
			"type": "t_mapping(t_uint256,t_mapping(t_address,t_bool))"
		},
		{
			"astId": 31,
			"contract": "fileA:A",
			"label": "array",
			"offset": 0,
			"slot": "8",
			"type": "t_array(t_uint256)dyn_storage"
		},
		{
			"astId": 33,
			"contract": "fileA:A",
			"label": "s1",
			"offset": 0,
			"slot": "9",
			"type": "t_string_storage"
		},
		{
			"astId": 35,
			"contract": "fileA:A",
			"label": "b1",
			"offset": 0,
			"slot": "10",
			"type": "t_bytes_storage"
		}
	],
	"types": {
		"t_address": {
			"encoding": "inplace",
			"label": "address",
			"numberOfBytes": "20"
		},
		"t_array(t_uint256)2_storage": {
			"base": "t_uint256",
			"encoding": "inplace",
			"label": "uint256[2]",
			"numberOfBytes": "64"
		},
		"t_array(t_uint256)dyn_storage": {
			"base": "t_uint256",
			"encoding": "dynamic_array",
			"label": "uint256[]",
			"numberOfBytes": "32"
		},
		"t_bool": {
			"encoding": "inplace",
			"label": "bool",
			"numberOfBytes": "1"
		},
		"t_bytes_storage": {
			"encoding": "bytes",
			"label": "bytes",
			"numberOfBytes": "32"
		},
		"t_mapping(t_address,t_bool)": {
			"encoding": "mapping",
			"key": "t_address",
			"label": "mapping(address => bool)",
			"numberOfBytes": "32",
			"value": "t_bool"
		},
		"t_mapping(t_uint256,t_mapping(t_address,t_bool))": {
			"encoding": "mapping",
			"key": "t_uint256",
			"label": "mapping(uint256 => mapping(address => bool))",
			"numberOfBytes": "32",
			"value": "t_mapping(t_address,t_bool)"
		},
		"t_string_storage": {
			"encoding": "bytes",
			"label": "string",
			"numberOfBytes": "32"
		},
		"t_struct(S)13_storage": {
			"encoding": "inplace",
			"label": "struct A.S",
			"members": [
				{
					"astId": 3,
					"contract": "fileA:A",
					"label": "a",
					"offset": 0,
					"slot": "0",
					"type": "t_uint128"
				},
				{
					"astId": 5,
					"contract": "fileA:A",
					"label": "b",
					"offset": 16,
					"slot": "0",
					"type": "t_uint128"
				},
				{
					"astId": 9,
					"contract": "fileA:A",
					"label": "staticArray",
					"offset": 0,
					"slot": "1",
					"type": "t_array(t_uint256)2_storage"
				},
				{
					"astId": 12,
					"contract": "fileA:A",
					"label": "dynArray",
					"offset": 0,
					"slot": "3",
					"type": "t_array(t_uint256)dyn_storage"
				}
			],
			"numberOfBytes": "128"
		},
		"t_uint128": {
			"encoding": "inplace",
			"label": "uint128",
			"numberOfBytes": "16"
		},
		"t_uint256": {
			"encoding": "inplace",
			"label": "uint256",
			"numberOfBytes": "32"
		}
	}
}

变量在 memory 布局

Solidity 保留了四个 32 字节的插槽,字节范围(包括端点)特定用途如下:

  • 0x00 - 0x3f (64 字节): 用于哈希方法的暂存空间(临时空间)

  • 0x40 - 0x5f (32 字节): 当前分配的内存大小(也作为空闲内存指针)

  • 0x60 - 0x7f (32 字节): 零位插槽

暂存空间可以在语句之间使用 (例如在内联汇编中)。零位插槽用作动态内存数组的初始值,并且永远不应写入(空闲内存指针最初指向0x80).Solidity 总是将新对象放在空闲内存指针上,并且内存永远不会被释放(将来可能会改变)。

Solidity 中的内存数组中的元素始终占据 32 字节的倍数(对于 bytes1[]总是这样,但不适用与 bytesstring )。

多维内存数组是指向内存数组的指针,动态数组的长度存储在数组的第一个插槽中,然后是数组元素。

⚠️ 警告: Solidity 中有一些需要临时存储区的操作需要大于 64 个字节,因此无法放入暂存空间。它们将被放置在空闲内存指向的位置,但是由于使用寿命短,指针不会更新。内存可以归零,也可以不归零。因此,不应指望空闲内存指针指向归零内存区域。

尽管使用 msize到达绝对归零的内存区域似乎是一个好主意,但使用此类非临时指针而不更新空闲内存指针可能会产生意外结果。

与存储中布局的不同

如上所述,在内存中的布局与在 存储中 有一些不同。下面是一些例子:

数组的不同

下面的数组在存储中占用 32 字节(1 个槽),但在内存中占用 128 字节(4 个元素,每个 32 字节)。

uint8[4] a;

结构体的不同

下面的结构体在存储中占用 96 (1 个槽,每个 32 字节) ,但在内存中占用 128 个字节(4 个元素每个 32 字节)。

struct S {
    uint a;
    uint b;
    uint8 c;
    uint8 d;
}

Call Data 布局

假定:函数调用的输入数据采用 ABI 规范。

其中,ABI 规范要求将参数填充为 32 的倍数 个字节。内部函数调用使用不同的约定。

合约构造函数的参数直接附加在合约代码的末尾,也采用 ABI 编码。构造函数将通过硬编码偏移量,而不是通过使用 codesize 操作码来访问它们,因为在将数据追加到代码时,它就会会改变。

清理变量

当一个值短于 256 位时,在某些情况下,剩余位必须被清理。编译器在设计时,会在操作数据之前清理这些剩余位,以避免剩余位中潜在垃圾数据在操作产生任何不利影响。

  • 在将一个值写入存储器之前,需要清除剩余的位,因为存储器的内容可以用于计算哈希值或作为消息调用的数据发送。

  • 同样,在将一个值存储到存储器中之前,也需要清除剩余的位,因为否则可以观察到垃圾数据。

  • 如果紧接着的操作不受影响,就不会清理位。例如,由于任何非零值都会被 JUMPI 指令认为是 true,所以在布尔值被用作条件判断之前,不需要清理它们。 JUMPI

  • 编译器会在将输入数据(input data)加载到堆栈时,会对其进行清理。

⚠️ 注意:通过内联汇编的访问数据没有此操作。如果使用内联汇编来访问短于 256 位的 Solidity 变量,编译器不保证该值被正确清理。

不同的类型有不同的清理无效值的规则:

Type Valid Values Invalid Values Mean
enum of nmembers 0 until n - 1 exception
bool 0 or 1 1
signed integers sign-extended word currently silently wraps;
in the future exceptions will be thrown
unsigned integers higher bits zeroed currently silently wraps;
in the future exceptions will be thrown

问答题

  • 存储大小少于 32 字节的多个变量会被打包到一个存储插槽(storage slot)中,规则是什么?

    • 存储插槽的第一项会以低位对齐的方式储存。

    • 值类型仅使用存储它们所需的字节。

    • 如果存储插槽中的剩余空间不足以储存一个值类型,那么它会被存入下一个存储插槽。

    • 结构体(struct)和数组数据总是会开启一个新插槽(但结构体或数组中的各元素,则按规则紧密打包)。

    • 结构体和数组之后的数据也或开启一个新插槽。

  • 在使用小于 32 字节的变量时,合约的 gas 使用量可能会高于使用 32 字节的元素。为什么?

    • 这是因为 EVM 每次操作 32 个字节,所以如果元素比 32 字节小,EVM 必须执行额外的操作以便将其大小缩减到到所需的大小。

  • Solidity 保留了四个 32 字节的插槽,分别是什么,用来做什么?

    • 0x00 - 0x3f (64 字节): 用于哈希方法的暂存空间(临时空间)

    • 0x40 - 0x5f (32 字节): 当前分配的内存大小(也作为空闲内存指针)

    • 0x60 - 0x7f (32 字节): 零位插槽

    • 暂存空间可以在语句之间使用 (例如在内联汇编中)。零位插槽用作动态内存数组的初始值,并且永远不应写入(空闲内存指针最初指向0x80).Solidity 总是将新对象放在空闲内存指针上,并且内存永远不会被释放(将来可能会改变)。

  • memory 与 storge 之间不同之处有哪些?

    • 数组的不同

    • 结构体的不同

    • 原因都是因为内存中每条数据都单独占 32 字节,而在 storge 里,可以储存在一个存储插槽中。

  • calldata 布局

    • 函数调用的输入数据采用 ABI 规范。

    • ABI 规范要求将参数填充为 32 的倍数 个字节。

    • 合约构造函数的参数直接附加在合约代码的末尾,也采用 ABI 编码。构造函数将通过硬编码偏移量,而不是通过使用 codesize 操作码来访问它们,因为在将数据追加到代码时,它就会会改变。

  • 聊一聊清理变量

    • 当一个值短于 256 位时,在某些情况下,剩余位必须被清理。编译器在设计时,会在操作数据之前清理这些剩余位,以避免剩余位中潜在垃圾数据在操作产生任何不利影响。

    • 在将一个值写入存储器之前,需要清除剩余的位,因为存储器的内容可以用于计算哈希值或作为消息调用的数据发送。

    • 同样,在将一个值存储到存储器中之前,也需要清除剩余的位,因为否则可以观察到垃圾数据。

    • 如果紧接着的操作不受影响,就不会清理位。例如,由于任何非零值都会被 JUMPI 指令认为是 true,所以在布尔值被用作条件判断之前,不需要清理它们。 JUMPI

    • 编译器会在将输入数据(input data)加载到堆栈时,会对其进行清理。

    • ⚠️ 注意:通过内联汇编的访问数据没有此操作。如果使用内联汇编来访问短于 256 位的 Solidity 变量,编译器不保证该值被正确清理。