这篇文章我们来学习一下 Solidity 的内存布局。首先我们需要明白,我们这里谈到的内存是 Solidity 合约中的状态变量,而不是在函数方法中定义的临时变量。前者是存在于以太坊的存储结构中的,而后者只是运行时的临时内存变量。例如:
contract Storage {
uint256 public a;
bytes32 public b;
function foo() public {
uint256 c;
}
}
这段代码中,变量 a
和 b
是状态变量,属于我们讨论的范围,而 c
不属于,因为它是运行时临时变量。
Solidity 中的内存布局,有一个插槽(slot)的概念。每一个合约,都有 2 ^ 256
个内存插槽用于存储状态变量,但是这些插槽并不是实际存在的,也就是说,并没有实际占用了这么多空间,而是按需分配,用到时就会分配,不用时就不存在。插槽数量的上限是 2 ^ 256
,每个插槽的大小是 32
个字节。图示如下:
Solidity 中有这么多的数据类型,它们都是怎么存储在这些插槽中的呢?我们来看看。
我们知道,Solidity 中的数据类型有很多,常见的有 uint
,bytes(n)
, address
,bool
,string
等等。其中 uint
还有不同长度的,比如 uint8
,uint256
等,bytes(n)
也包括 bytes2
,bytes32
等。还有 mapping
以及 数组
类型等。前面提到过,一个插槽的大小是 32 个字节,那么像 uint256
,bytes32
这些 32 字节大小的类型就可以刚好放在一个插槽中。
来看一个简单的例子:
contract Storage {
uint256 public a;
uint256 public b;
uint256 public c;
function foo() public {
a = 1;
b = 2;
c = 123;
}
}
上面的合约中,a
,b
,c
三个变量都是 uint256
类型的,恰好每个变量都占用了一个插槽,分别是插槽0,1,2。我们部署合约,调用 foo
函数,读取它们的值来确认一下:
const {ethers} = require("ethers");
const provider = new ethers.providers.JsonRpcProvider()
const main = async () => {
// 第一个参数是部署的合约地址
// 第二个参数是插槽的位置,这里注意,如果是十进制,就直接写数字
// 如果是十六进制,需要加上引号,例如 '0x0'
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
0
)
console.log(a)
}
main()
这段代码使用了 ethersjs 库来读取合约插槽的数据,也可以使用其他的方法,例如 Python 可以使用 web3py 库。
我们分别读取0,1,2三个插槽的数据,分别为
0x0000000000000000000000000000000000000000000000000000000000000001
0x0000000000000000000000000000000000000000000000000000000000000002
0x000000000000000000000000000000000000000000000000000000000000007b
对应的 10 进制数为 1,2,123,验证正确。
我们再对上面的合约做一点小小的改动:
uint8 public a;
uint8 public b;
uint256 public c;
同样,我们部署并调用 foo
函数,再读取其插槽值,我们可以看到,插槽 0 的数据变成了:
0x0000000000000000000000000000000000000000000000000000000000000201
而插槽 1 的数据变成了:
0x000000000000000000000000000000000000000000000000000000000000007b
插槽 2 直接就没有数据了,这是为什么呢?因为一个插槽的大小是 32 字节,而 a
和 b
都只占用 1 个字节,Solidity 为了节省存储空间,会将它俩放在同一个插槽中,而下一个 c
变量,由于它占用了 32 字节,因此它要占用下一个插槽。
那么我们再做一点小改动,将 b
和 c
调换位置:
uint8 public a;
uint256 public c;
uint8 public b;
此时我们再去查看插槽数据,会发现,三个变量都各自占据了一个插槽,这是因为,虽然 a
只占据了插槽 0 中的 1 个字节,但是由于下一个变量 c
要占据一整个插槽,所以 c
只能去下一个插槽,那么 b
也就只能去第三个插槽了。
这里带给我们的思考就是,在开发合约时,内存的布局分配也是很重要的,合理地分配内存布局可以节省内存空间,也就节省了 gas 费用。
前面我们提到的 bytes(n)
类型,和 uint
类似,也是同样的道理。同时还有 bool
类型,它只占用 1 个字节。address
类型,占用 20 个字节。因此在开发过程中,可以将一些小字节类型放在一起,从而节省 gas 费用。
上面我们说到的,都是定长的数据类型。而像 string
,bytes
这种非固定长度的类型,它们的存储规则是:
31
字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2
。31
字节,则在主插槽存储 length * 2 + 1
, 数据照常存储在 keccak256(slot)
中。来看一个实际的例子验证一下:
contract Storage {
string public a;
string public b;
function foo() public {
// a是31个字节,b是32个字节
a = 'abcabcabcabcabcabcabcabcabcabca';
b = 'abcabcabcabcabcabcabcabcabcabcab';
}
}
查看插槽 0 和 1 的值,分别为:
0x616263616263616263616263616263616263616263616263616263616263613e(最后一个字节存储长度 0x3e,即 62 = 31 * 2)
0x0000000000000000000000000000000000000000000000000000000000000041(最后一个字节存储长度 0x41,即 65 = 32 * 2 + 1)
我们再去看看 keccak256(slot) 中存储的值,通过
keccak256(abi.encode(1));
计算出哈希值,这也就是插槽的位置,再去读取其值:
// 第二个参数为插槽的位置,使用 ethersjs 库需要加引号,否则报错
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
"0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"
)
结果为:
0x6162636162636162636162636162636162636162636162636162636162636162
验证成功,注意我们这里的使用的是数据长度恰好为 32 字节,如果大于 32 字节,那么剩余的长度就会继续往下一个插槽【即 keccak256(abi.encode(1)) + 1
】延伸。
接下来我们看看 mapping
和 数组
类型是怎么存储的。
对于 mapping
类型,规则是:
mapping
中的数据,存储在插槽 keccak256(key.slot)
中,也就是:keccak256(abi.encode(key, slot))
来看一个例子:
contract Storage {
mapping(uint256 => uint256) public a;
function foo() public {
a[1] = 123;
a[2] = 345;
}
}
通过 keccak256(abi.encode(1, 0))
和 keccak256(abi.encode(2, 0))
分别计算出, a[1]
和 a[2]
所处的插槽位置为:
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569
我们进行验证,插槽 0 的值为 0,上述这两个插槽的值分别为:
0x000000000000000000000000000000000000000000000000000000000000007b
0x0000000000000000000000000000000000000000000000000000000000000159
即分别为 123 和 345,验证成功。
再来看看数组类型,它所满足的规则是:
keccak256(slot)
插槽开始的位置同样来看一个例子:
contract Storage {
uint256[] public a;
function foo() public {
a.push(12);
a.push(34);
}
}
运行 foo
函数后,插槽 0 值就变成了 2,这里注意,如果运行了两次 foo
,那么就变成了 4,因为数组的长度变成了 4。我们来计算 keccak256(abi.encode(0))
的值为:
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
查询其插槽上的值为 12,再看看下一个插槽【即 keccak256(abi.encode(0)) + 1
】的值为 34,满足规则。
对于组合类型,例如 mapping(uint256 => uint256[])
,那么就按照组合的规则,从外到里进行计算即可。
Solidity 中的内存布局,都严格遵守既定规则,并不是杂乱无章的。理解了内存布局,对于我们后面学习可升级合约,帮助很大。
欢迎和我交流