xyyme.eth

Posted on May 17, 2022Read on Mirror.xyz

深入理解合约升级(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) - 部署一个可升级合约

关于我

欢迎和我交流

参考

https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html

https://learnblockchain.cn/books/geth/part7/storage.html

http://aandds.com/blog/solidity-storage-layout.html