19.Solidity 变量的布局

1️⃣ 状态变量在 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;
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
{
	"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"
		}
	}
}

2️⃣ 变量在 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;
}

3️⃣ Call Data 布局

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

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

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

4️⃣ 清理变量

当一个值短于 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 变量,编译器不保证该值被正确清理。

关于站长

我叫朱安邦,本站的站长。如果您对网站有什么好的建议,欢迎在 Twitter 上与我交流