人话讲解solidity合约的存储原理

编写solidity合约如何选择变量的数据类型

当你使用uint类型时有没有思考过到底用uint64呢?还是uint256呢?又或是uint32等等……如果你的回答是:yes! 说明你已经开始关注智能合约的存储了,这是编写高质量合约代码的必备技能。本文讨论一下solidity合约的存储原理。

你为什么要学习合约的存储原理?我给你5个理由:

  1. 提高智能合约的执行性能
  2. 减少合约执行消耗的gas成本
  3. 避免由存储引发的安全性问题
  4. 学习智能合约代理的必备知识
  5. 学习YUL汇编写合约的必备知识

我们已经知道,合约中的存储是消耗gas的,尤其是对于状态变量这样的持久化存储,是昂贵的。有没有办法通过合理选择变量的数据类型,来减少gas的成本呢?答案是肯定的!

存储位置

合约涉及的存储位置有4种:bytecode, memorycalldatastorage

例如:以下代码中,PERCENTowner、函数中的局部变量 _count 都属于bytecode,是编译时直接编码在合约的字节码中。

contract Test {
    uint256 public constant PERCENT = 100;
    address public immutable owner;

    constructor() {
        owner = msg.sender;
    }

    function count() public pure returns (uint256) {
        uint256 _count = 99;
        return _count;
    }
}

对于 memory,calldata,和storage,理解起来很直观,内存局部变量、只读的函数传参、状态变量,这里只提醒一下:不要给标记memory的变量赋值storage的值,这会发生变量的复制,成本很高。本文的重点放在状态变量的存储布局上。

存储槽

合约的storage存储是以键值对的数据结构实现的,键和值的长度都是是固定的32字节。其中,键是一个从0开始的序号,因为是32字节(256位),所以最大可表示2的256次方,这是一个天文数字,它比整个宇宙中所有的原子数量还要大,请记住这一点,对下文理解复杂数据类型的存储很重要。

我们用图片来感受下这个数据结构:

slot.png

接下来,我们看下面一段代码:

contract Test {
    address public owner = 0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C;
    uint256 public balance = 1 ether;
    bool public isValid = true;
    uint8 public count8 = 1;
    uint32 public count32 = 1;
    uint128 public count128 = 1;
}

这里一共6个变量,根据刚刚讲的数据结构,它们按顺序分别存储在序号为0、1、2、3、4、5的存储槽中,对吗?为了验证这一点,把Test合约部署到你熟悉的测试网,使用web3的getStorageAt函数go-ethereum的StorageAt方法都可以直接获取指定序号的存储槽数据,即使变量标记为private也能获取,因此不要在合约保存私钥、密码等敏感数据。这里给出go语言的代码示例:

client, _ := ethclient.Dial("...")
contract := common.HexToAddress("0xA70087e8E8bFC6f42f53E1B7d25c8e84A0Bd7eC5")

// 省略重复代码 ...
slot0, _ := client.StorageAt(context.Background(), contract, common.BigToHash(common.Big0), nil)
fmt.Println("0 ==> ", common.Bytes2Hex(slot0))

执行代码,得到的结果是:

0 ==> 00000000000000000000000014729a09acaf6d2a63dcdf7ba4aff308fddc360c  
1 ==> 0000000000000000000000000000000000000000000000000de0b6b3a7640000  
2 ==> 0000000000000000000000000000000000000000000000000001000000010101  
3 ==> 0000000000000000000000000000000000000000000000000000000000000000  
4 ==> 0000000000000000000000000000000000000000000000000000000000000000  
5 ==> 0000000000000000000000000000000000000000000000000000000000000000  

咦??在3、4、5的存储槽中都是0,并没有存储我们定义的count8、count32、count128变量的值,这是为什么呢?答案是变量打包。为了节约存储空间,EVM会把小于32字节连续变量打包到一个存储槽中,因此,在序号为2的存储槽保存了isValid、count8、count32、count128四个变量。

我们分析一下这个打包规则:

slot2.png

当遇到变量owner时:address占用20个字节,保存到槽0

当遇到变量balance时:uint256占用32字节,槽0剩余的12字节空间不足以存储,所以占用槽1整个空间

当遇到变量isValid时:bool占用1字节,存放在槽2的最右边2位,16进制的2位是一字节(注意,先遇到的变量占据右侧位置,以此类推

当遇到变量count8时:uint8占用1字节,因为槽2的最右边2位已有数据,所以存放在右边第3、4位

以此类推,count32count128占用槽2的从右往左数第5至12位第13至44位

简单数据类型

对于address, bool, uint8 至 uint256, bytes1 至 byte32这些简单的数据类型,以上的存储槽打包规则就已经讲完了。还有结构体, 固定长度数组, 动态长度数组mapping,我们分情况分别讨论。

固定长度数组和结构体

我们来看下面一段代码:

contract Test {
    // 定义结构体`Info`,不占用存储槽
    struct Info {
        uint8 id;
        uint32 value;
    }

    // 定义结构体`Account`,不占用存储槽
    struct Account {
        address player;
        uint256 score;
    }

    uint256[2] public counts = [1, 2];
    uint32[2] public ages = [1, 2];
    Info public info = Info(1, 2);
    Account public account = Account(0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C, 1);
}

我们已经知道了32字节变量会占用整个存储槽,不足32字节的有可能会被打包到一个存储槽。现在来思考上面的counts, ages, info, account几个变量分别占用了哪个存储槽?

  1. 对于counts,由于是固定2个uint256类型的元素,按顺序占用 第0个 和 第1个 存储槽。
  2. 对于ages,由于2个元素是uint32类型,不足32字节,一起被打包到 第2个 存储槽。
  3. 对于info,结构体包含一个uint8和uint32,不足32字节,一起被打包到 第3个存储槽。
  4. 对于account,地址player占用第4个存储槽,score占用第5个存储槽。

ages 和 info 的所有字段总共也不超过32字节,并且是连续的字段,为什么没有打包呢? 注意,数组和结构体是完全占用一个存储槽的,只有它们内部元素之间才会被打包。

可以看出来,固定数组和结构体一样,都是把字段(或元素)扁平化之后,按照顺序和简单数据类型的规则一样。

变长数据类型

当变量的数据大小不可预知时,没办法像上文那样按照顺序分配存储槽,下面我们就看看如何采用特定的算法解决这个问题。

mapping

mapping的每个key都对应一个存储槽位置,计算方式为:

keccak256(abi.encode(key,uint256(槽号)))

例如下面这个代码:

contract Test {
    mapping(address user => uint256 rewards) public rewardsOf;

    function setRewards() public {
        rewardsOf[0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C] = 1;
    }

    function getSlotIndex() public pure returns (bytes32) {
        return keccak256(abi.encode(0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C,uint256(0)));
    }
}

变量rewardsOf的槽号是0,通过调用setRewards设置一个key,之后调用getSlotIndex获得返回值,用这个返回值传入上面go语言的StorageAt方法,得到的结果就是这个key对应的值。

也许你会问,这种计算方法会不会导致碰撞,即两个key存到了同一个存储槽?还记得上文说的存储槽范围吗?那是个天文数字,就像区块链的地址难以碰撞一样,这个也无需担心。

动态数组

动态数组的存储槽计算公式是: uint256(keccak256(abi.encode(槽号))) + 数组元素索引

例如下面的代码:

contract Test {
    address[] public users;

    function setUsers() public {
        users.push(0x14729a09aCaf6D2A63dcdf7bA4aFf308FDDC360C);
        users.push(0x20249A09aCAF6d2a63dCdf7bA4AFf308FDdC289A);
    }

    function getSlotIndex(uint256 index) public pure returns (uint256) {
        return uint256(keccak256(abi.encode(0))) + index;
    }
}

变量users的槽号是0,通过调用setUsers设置两个元素,之后调用getSlotIndex并传入索引0和1获得这两个元素的存储槽,再传入go语言的StorageAt方法,得到的结果就是这两个元素值。

string

string的存储槽分为两种情况:

  1. 小于等于31字节时,内容 + 字符长度 * 2 一起保存在当前的存储槽中,因为一个槽足够存储,无需额外计算存储槽。
  2. 大于31字节时,一个槽不够存数据了,字符长度 * 2 + 1保存在当前存储槽,利用公式: uint256(keccak256(abi.encode(槽号)))计算出一个存储槽,内容就保存在以这个存储槽为开始的连续存储槽中
contract Test {
    string public text1 = "I am less than 31 bytes";
    string public text2 = "I am greater than 31 bytes, and the storage space I occupy has exceeded the maximum value that can be stored in a single storage slot";

    function getSlotIndex() public pure returns (uint256) {
        return uint256(keccak256(abi.encode(1)));
    }
}

如上面代码,text1加上它的(字符长度乘以2)保存在第0个存储槽,text2超出了31字节,所以第1个存储槽只保存了(text2的字符长度乘以2加上1),调用getSlotIndex获得text2的内容存储槽,把这个存储槽之后的连续几个槽内容拼接起来就是text2的值了。

  1. text1的字符长度为什么乘以2?因为字符用字节表示,1字节=8比特,也就是2个16进制位。
  2. text2的字符长度乘以2,为什么又加1? 为了变为奇数,因为短字符串乘以2恒为偶数,这样就可以方便的通过奇偶性判断字符串的长短了。

bytes

bytes类型 和 string类型 的存储槽计算方法一模一样。

利用存储槽降低gas成本

现在,理解了存储槽原理,我们再来思考文章开头提到的问题,编写合约代码时,该选择uint256还是uint128、或更小的数据类型呢?

这里引入一个新概念:变量的冷加载。 所谓冷加载,就是第一次访问一个存储槽时,称为冷加载,在同一笔交易中,后续对这个槽所有的访问都是热加载。冷加载会消耗更多的gas,热加载消耗gas比较少。因此,假设你的业务逻辑中,需要在同一笔交易批量访问某几个状态变量,而他们总的大小不超过32字节,可以考虑将他们打包,这样可以共享冷加载成本。

访问打包元素需要解包,如果一个变量在多个交易中需要单独访问,推荐用uint256,这是EVM中效率最高的类型,EVM 的大部分操作(包括算术和逻辑运算)在 256 位上执行,比 uint128、uint64等更加高效。

虽然打包变量可以节省存储成本,但也不可忽视访问成本。这里总结如下:

建议打包变量 避免打包变量(推荐uint256)
同一笔交易批量访问的变量 多个交易都要单独访问的变量
不常访问的变量 频繁访问的变量

思考题

  1. 下面代码中,每个变量的存储槽序号分别是多少?
contract Test {
    uint256 public constant PERCENT = 100;
    address public immutable owner;
    uint32 public count;
    address public token;
    bool public enable;
    bytes32 public data;
    uint8 public status;
}
  1. 思考 bytes1[]bytes 存储相同内容时的区别?

提示: bytes1[]是动态数组,bytes是动态类型,在内容小于等于31字节时,bytes更好,大于31字节时,二者一样。

  1. 你能自己分析出下面代码的存储布局吗?
contract Test {
    struct Account {
        address player;
        mapping(address user => mapping(uint256 id => uint256 score)) scoreOf;
    }

    Account public account;
}

对于第3个思考题,虽然我没有讲嵌套动态类型,但原理本质上都一样,你可以试着分析一下。我相信你如果认真看到这里,并理解了上文中所有的内容,以上的思考题都很简单。

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
认知那些事
认知那些事
0x2b62...95a0
人立于天地之间,必然有我们的出路。