深入理解合约升级(2) - Solidity 内存布局

这篇文章我们来学习一下 Solidity 的内存布局。首先我们需要明白,我们这里谈到的内存是 Solidity 合约中的状态变量,而不是在函数方法中定义的临时变量。前者是存在于以太坊的存储结构中的,而后者只是运行时的临时内存变量。例如:

contract Storage {
    uint256 public a;
    bytes32 public b;

    function foo() public {
        uint256 c;
    }
}

这段代码中,变量 ab 是状态变量,属于我们讨论的范围,而 c 不属于,因为它是运行时临时变量。

概括

Solidity 中的内存布局,有一个插槽(slot)的概念。每一个合约,都有 2 ^ 256 个内存插槽用于存储状态变量,但是这些插槽并不是实际存在的,也就是说,并没有实际占用了这么多空间,而是按需分配,用到时就会分配,不用时就不存在。插槽数量的上限是 2 ^ 256,每个插槽的大小是 32 个字节。图示如下:

Solidity 中有这么多的数据类型,它们都是怎么存储在这些插槽中的呢?我们来看看。

插槽分配

固定长度类型

我们知道,Solidity 中的数据类型有很多,常见的有 uintbytes(n)addressboolstring 等等。其中 uint 还有不同长度的,比如 uint8uint256 等,bytes(n) 也包括 bytes2bytes32 等。还有 mapping 以及 数组 类型等。前面提到过,一个插槽的大小是 32 个字节,那么像 uint256bytes32 这些 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 字节,而 ab 都只占用 1 个字节,Solidity 为了节省存储空间,会将它俩放在同一个插槽中,而下一个 c 变量,由于它占用了 32 字节,因此它要占用下一个插槽。

那么我们再做一点小改动,将 bc 调换位置:

uint8 public a;
uint256 public c;
uint8 public b;

此时我们再去查看插槽数据,会发现,三个变量都各自占据了一个插槽,这是因为,虽然 a 只占据了插槽 0 中的 1 个字节,但是由于下一个变量 c 要占据一整个插槽,所以 c 只能去下一个插槽,那么 b 也就只能去第三个插槽了。

这里带给我们的思考就是,在开发合约时,内存的布局分配也是很重要的,合理地分配内存布局可以节省内存空间,也就节省了 gas 费用。

前面我们提到的 bytes(n) 类型,和 uint 类似,也是同样的道理。同时还有 bool 类型,它只占用 1 个字节。address 类型,占用 20 个字节。因此在开发过程中,可以将一些小字节类型放在一起,从而节省 gas 费用。

非固定长度类型

上面我们说到的,都是定长的数据类型。而像 stringbytes 这种非固定长度的类型,它们的存储规则是:

  1. 如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2
  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 类型,规则是:

  1. 所处的插槽,空置,不存储内容,
  2. 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,验证成功。

再来看看数组类型,它所满足的规则是:

  1. 所处的插槽,存储数组的长度
  2. 数组内存储的元素,存储在以 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 中的内存布局,都严格遵守既定规则,并不是杂乱无章的。理解了内存布局,对于我们后面学习可升级合约,帮助很大。

合约升级系列文章

  1. 深入理解合约升级(1) - 概括
  2. 深入理解合约升级(2) - Solidity 内存布局
  3. 深入理解合约升级(3) - call 与 delegatecall
  4. 深入理解合约升级(4) - 合约升级原理的代码实现
  5. 深入理解合约升级(5) - 部署一个可升级合约

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.