变量存储

当一个智能合约部署到以太坊,并被触发执行,EVM 会为合约分配五种存储空间:

  • 存储区(storage)
  • 内存区(memory)
  • 调用数据区(calldata)
  • 瞬时存储(Transient storage)
  • 栈(stack)

存储区(storage)

声明在合约中的变量,通常被称为 状态变量,被保存在存储区:

StorageCounter.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract Counter {
	uint number;
}

每一个部署后的合约拥有一个唯一的以太坊地址,每一个合约地址关联一个独立的存储区。所有存储区中的变量保存在以太坊区块链上,因此所有存储区变量的更改记录也会作为区块链历史而持久化记录下来。

由于存储区变量是保存在区块链上的,因此相比其他存储区域,它的读取和修改都非常昂贵,需要消耗更多的燃料(gas)。

内存区(memory)

内存区变量通常用于保存函数调用参数变量,函数返回变量,以及函数内部声明的变量。

StorageMemory.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract Counter {
    uint number;

    function computeSum(uint[] memory increments, uint len)
      private
      pure
      returns (uint sum)
    {
        uint i;
        for(i = 0; i < len; i++)
          sum += increments[i];
    }
}

在上面的函数 computeSum 中,函数参数 increments 以及 len,被保存在 memory 中。函数局部变量 i 以及返回值 sum 也被保存在 memory 中。

调用数据区(calldata)

当发生外部函数调用时,调用参数会被加载保存在一片被称为 calldata 的存储空间中。

跟存储区不一样的是,calldata 是不允许修改的。在 calldatamemorycalldata 中,calldata 的使用是最便宜的。

瞬时存储(Transient storage)

当我们需要使用一个全局变量时,我们通常需要将它定义为状态变量,保存在存储区。但是保存在存储区的话,读取和修改的费用都很贵。为了解决这一点,EIP-1153 提案增加了瞬时存储。

瞬时存储与状态变量基本相当,但是它将不会被持久性记录到区块链上。而是当一次交易执行完成之后,就自动销毁,因而它的成本更低。

目前 Solidity(v0.8.24) 还不支持直接声明瞬时存储变量,但可以使用内联汇编的方式,使用相关指令 tload tstore 指令。

StorageTransient.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.24;

contract Storage {
    modifier nonreentrant() {
        assembly {
            if tload(0) {
                revert(0, 0)
            }
            tstore(0, 1)
        }
        _;
        assembly {
            tstore(0, 0)
        }
    }
}

栈(stack)

栈对于 Solidity 开发人员而言,几乎是完全透明的。由于 EVM 是堆栈类机器,而不是寄存器类机器。因此其底层所有的操作都是在堆栈上进行的。

例如,在运算 c=a+b 时(假设 a, b, c 保存在 memory 中),EVM 操作如下:

  • a 压入栈顶
  • b 压入栈顶
  • 栈顶两元素相加,结果压回堆顶
  • 弹出栈顶元素保存回c中

对于每一次交易,Solidity 将为其分配大小为 1024 个字(每个字 256 位)的栈空间。开发人员需要注意的是,如果函数调用嵌套过深,可能会导致栈溢出的错误。因此应当避免使用 递归 等需要大量消耗栈空间的操作。

不同存储区域变量间的赋值

对于 值类型 永远都是直接拷贝值。因此,我们只需要考虑 引用类型 变量在不同的存储区域间的赋值。而 引用类型 实际上只有以下三种:

  • 数组(包括 string 字符串)
  • 结构体变量
  • mapping 类型

而不同的存储区域指的也是三种:

  • calldata
  • storage
  • memory

对于相同的存储区域间变量赋值,显然是合法的,而且不涉及到拷贝:

SameRefTypeAssignment.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract SameRefTypeAssignment {
    function fn(
        int[] calldata calldataA,
        int[] storage storageA,
        int[] memory memoryA
    ) internal pure {

        int[] calldata calldataB;
        int[] memory memoryB;
        int[] storage storageB;
        
        // 合法,calldataB 和 calldataA 现在指向 calldata 中的同一片数据,未发生数组拷贝
        calldataB = calldataA;

        // 合法,memoryB 和 memoryA 现在指向 memory 中的同一片数据,未发生数组拷贝
        memoryB = memoryA;

        // 合法,storageB 和 storageA 现在指向 storage 中的同一片数据,未发生数组拷贝
        storageB = storageA;
    }
}

要判断不同存储区域间的引用变量赋值是否合法(假设为 A = B),只需要判断以下两点:

  • 如果可以将 B 中的内容拷贝到 A 的存储区中,则拷贝,然后 A 指向该拷贝后的内容区域
  • 如果不能进行以上拷贝操作,则该操作非法

calldata 引用变量的赋值

calldata 是当发生外部函数调用时,用来保存函数参数的。因此,我们唯一能够新增一片 calldata 存储块的方法,就是调用外部函数,除此之外别无它法。基于此:

CalldataAssigment.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract CalldataAssigment {
    function fn(int[] memory memoryA, int[] storage storageA) internal view {
        int[] calldata calldataA;

        // 不合法,calldata 引用必须指向 calldata 存储区域
        // 因此需要创建一个 calldata 存储块,把 memoryA 中的内存拷贝到 calldata
        // 然后让 calldataA 指向该新创新的存储块,但是 calldata 存储区域是只读的
        // 且只有调用外部函数时才会为函数参数分配新的存储块,因此该拷贝不可能发生
        // 因此以下操作非法
        calldataA = memoryA;

        // 与上面同理
        calldataA = storageA;

        // 外部函数调用 this.externalFn,Solidity(EVM) 为该外部函数调用分配了一块 calldata
        // 存储块,用于保存函数参数 calldataB,因此可以将 memoryA 数组拷贝到 该新的 calldata 存储块中
        // 函数参数 calldataB 将指向该新的 calldata 存储块
        // 因此该参数变量赋值合法,该过程涉及到了数组拷贝
        this.externalFn(memoryA);

        // 与上面同理,该过程同样涉及到了数组拷贝,但是从 storage 拷贝到 calldata
        this.externalFn(storageA);
    }

    function externalFn(int[] calldata calldataB) external {}
}

storage 引用变量的赋值

storage 存储区的变量都是在合约内静态声明的。除此之外,我们没有其他方法来创建一块新的 storage 存储块。基于此:

StorageAssigment.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract StorageAssigment {
    int[] storageVar;
    function fn(int[] calldata calldataA, int[] memory memoryA) internal view {
        int[] storage storageA;

        // 不合法,storage 引用必须指向 storage 存储块
        // 因此需要创建一个 storage 存储块,把 calldataA 中的内存拷贝到 storage 中
        // 然后让 storageA 指向该新创新的存储块,但是 storageA 存储区域只能通过在合约内
        // 静态声明,而由自己不能动态分配,因此该拷贝不可能发生
        // 因此以下操作非法
        storageA = calldataA;

        // 与上面同理
        storageA = memoryA;

        // 该赋值合法。可以将 calldataA 中的数据拷贝到 storageVar 指向的存储区中
        // 然后 storageVar 仍指向该存储区数据块,该过程发生了数组拷贝
        storageVar = calldataA;

        // 与上面同理,该过程同样涉及到了数组拷贝,但是从 memory 拷贝到 storage
        storageVar = memoryA;
    }
}

memory 引用变量的赋值

对于 memory 引用变量的赋值,我们往往可以动态地申请一块新的 memory 内存块来使用。基于此:

MemoryAssigment.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract MemoryAssigment {
    function fn(
        int[] storage storageA,
        int[] calldata calldataA
    ) internal pure {
        int[] memory memoryA;

        // 该赋值合法,memory 引用必须指向 memory 存储块,memory 存储空间总是可以动态创建
        // 因此可以创建一个 memory 存储块,把 calldataA 中的内存拷贝到 memory 中
        // 然后让 memoryA 指向该新创新的内存块
        memoryA = calldataA;

        // 与上面同理
        memoryA = storageA;
    }
}