合约的状态变量以一种紧凑的方式存储在区块链存储中,以这样的方式,有时多个值会使用同一个存储槽。
除了动态大小的数组和 mapping,数据的存储方式是从位置 0
开始连续放置在 storage 中。对于每个变量,根据其类型确定字节大小。
存储大小少于 32 字节的多个变量会被打包到一个存储插槽(storage slot)中,规则如下:
- 存储插槽的第一项会以低位对齐的方式储存。
- 值类型仅使用存储它们所需的字节。
- 如果存储插槽中的剩余空间不足以储存一个值类型,那么它会被存入下一个存储插槽。
- 结构体(struct)和数组数据总是会开启一个新插槽(但结构体或数组中的各元素,则按规则紧密打包)。
- 结构体和数组之后的数据也或开启一个新插槽。
对于使用继承的合约,状态变量的排序由 C3 线性化合约顺序(顺序从最基类合约开始)确定。如果上述规则成立,那么来自不同的合约的状态变量会共享一个存储插槽。
结构体和数组中的成员变量会存储在一起,就像它们单独声明时一样。
当我们在处理状态变量时,利用编译器会将多个元素(变量)缩减的存储大小打包到一个 存储插槽中,也许是有益,因为可以合并多次读写为单个操作。如果你不是在同一时间读或写一个槽中的所有值,这可能会适得其反。当一个值被写入一个多值存储槽时,必须先读取该存储槽,然后将其与新值合并,避免破坏同一槽中的其他数据,再写入。
当处理函数参数或 memory(内存)中的值时,因为编译器不会打包这些值,所以没有什么额外的益处。
最后,为了允许 evm 对此进行优化,请确保 storage 中的变量和 struct
成员的书写顺序允许它们被紧密地打包。例如,应该按照 uint128,uint128,uint256
的顺序来声明状态变量,而不是使用 uint128,uint256,uint128
,因为前者只占用两个存储插槽,而后者将占用三个。
由于 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
,因为 a
和b
被装在一个槽位中。 最后 data[4][9].c
的插槽位置是keccak256(uint256(9) . keccak256(uint256(4) . uint256(1)) + 1
.该值的类型是 uint256
,所以它使用一个槽。
bytes
和 string
编码是一样的。
一般来说,编码与 bytes1[]
类似,即有一个槽用于存放数组本身同时还有一个数据区,数据区位置使用槽的
keccak256
hash 计算。然而,对于短字节数组(短于 32 字节),数组元素与长度一起存储在同一个槽中。
具体地说:如果数据长度小于等于 31
字节,则元素存储在高位字节(左对齐),最低位字节存储值 length * 2
。如果数据长度大于等于 32 字节,则在主插槽 p
存储 length * 2 + 1
,数据照常存储在 keccak256(p)
中。因此,可以通过检查是否设置了最低位:短(未设置最低位)和长(设置最低位)来区分短数组和长数组。
Panic(0x22)
错误。
合约的存储布局可以通过 standard JSON interface 获取到。 输出 JSON 对象包含 2 个字段 storage
和 types
。storage
对象是一个数组。
文件: 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
意味着使用了一个以上的槽。
除了上述四个外,有些类型还有额外的信息。映射包含其 key
和 value
类型(再次引用该类型映射中元素类型),数组有其 base
类型,结构以与顶层storage
相同的格式列出其 members
(见
:ref:前面JSON 输出
).
下面的例子显示了一个合约和它的存储布局,包含值类型和引用类型、被编码打包的类型和嵌套类型。
// 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"
}
}
}
Solidity 保留了四个 32 字节的插槽,字节范围(包括端点)特定用途如下:
0x00
-0x3f
(64 字节): 用于哈希方法的暂存空间(临时空间)0x40
-0x5f
(32 字节): 当前分配的内存大小(也作为空闲内存指针)0x60
-0x7f
(32 字节): 零位插槽
暂存空间可以在语句之间使用 (例如在内联汇编中)。零位插槽用作动态内存数组的初始值,并且永远不应写入(空闲内存指针最初指向0x80
).Solidity 总是将新对象放在空闲内存指针上,并且内存永远不会被释放(将来可能会改变)。
Solidity 中的内存数组中的元素始终占据 32 字节的倍数(对于 bytes1[]
总是这样,但不适用与 bytes
和 string
)。
多维内存数组是指向内存数组的指针,动态数组的长度存储在数组的第一个插槽中,然后是数组元素。
尽管使用 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;
}
假定:函数调用的输入数据采用 ABI 规范。
其中,ABI 规范要求将参数填充为 32 的倍数 个字节。内部函数调用使用不同的约定。
合约构造函数的参数直接附加在合约代码的末尾,也采用 ABI 编码。构造函数将通过硬编码偏移量,而不是通过使用 codesize
操作码来访问它们,因为在将数据追加到代码时,它就会会改变。
当一个值短于 256 位时,在某些情况下,剩余位必须被清理。编译器在设计时,会在操作数据之前清理这些剩余位,以避免剩余位中潜在垃圾数据在操作产生任何不利影响。
- 在将一个值写入存储器之前,需要清除剩余的位,因为存储器的内容可以用于计算哈希值或作为消息调用的数据发送。
- 同样,在将一个值存储到存储器中之前,也需要清除剩余的位,因为否则可以观察到垃圾数据。
- 如果紧接着的操作不受影响,就不会清理位。例如,由于任何非零值都会被
JUMPI
指令认为是true
,所以在布尔值被用作条件判断之前,不需要清理它们。JUMPI
。 - 编译器会在将输入数据(input data)加载到堆栈时,会对其进行清理。
不同的类型有不同的清理无效值的规则:
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 变量,编译器不保证该值被正确清理。