深入Solidity数据存储位置

2022-11-07 12:39:03 浏览数 (1)

译文出自:登链翻译计划[1] 译者:翻译小组[2] 校对:Tiny 熊[3]

图片来源:Ant Rozetski[4] on Unsplash

文章较长,内容很详细、很深入。但是不要吓到,坐下来,喝杯咖啡或你最喜欢的饮料,慢慢体会。

我们来探索 Solidity 的一个新的和必不可少的部分:数据存储位置。具有挑战性的话题。非常底层,因为它与以太坊虚拟机(EVM)的架构有关。

但通过类比,我们总是可以更好地理解复杂的编程概念、架构、智能合约和一般的区块链。

在这篇文章中,我们将通过类比 EVM 如果是一个 "巨大的工业工厂 "来学习每个数据位置(我希望能帮助你理解)。

然后我们将进入 Solidity 代码,学习每个数据位置的参考类型的规则和行为。所有这些都有图画、架构图、代码片段以及你可能知道的流行项目的源代码例子来学习。

这个系列还有另外二篇文章分别介绍存储(Storage)及 内存(memory).

目录

  • 简介
  • 为什么要了解 Solidity 中的 EVM 数据位置?
  • 数据位置 → 概述
  • 数据位置 → 规则
  • 函数参数的规则
  • 函数主体的规则
  • 数据位置 → 行为
  • 映射的(边缘)情况
  • 总结
  • 参考文献

简介

作为一个对自己事业充满热情的人,一个工业炉的建造者和翻新者,我的父亲决定把我送到工业工厂去工作。

非常大的工厂! 熔化和制造钢铁和铝的工厂(Arcelor Mittal 和 Constellium)。

你需要一辆车在工业工厂的不同部门之间来回穿梭,比如钢铁厂和轧钢厂。

这是取自 StackExchange 的以太坊和 EVM 架构的代表图[5]

和 EVM 架构图一样,安赛乐米塔尔工业厂房的道路规划图一开始也是很难理解的!

我还记得的是我第一次进入包含轧机的机库(看视频链接!)[6]工业炉是如此巨大,从这头走到那头需要几分钟时间!

机库里挤满了其他建筑工人、砌砖工人、电工,叉车在四周移动和行驶,还有人控制着机器用巨大的机械臂把东西搬进和搬出炉子。

就像我父亲一样,我是一个不同类型的建设者,也就是人们在 web3 中所说的 "建设者"。但当我回头看这个工业工厂时,我发现它的内部与 EVM 的内部有很多共同之处。

材料被储存在许多不同的地方,从架子上拿出来,放在炉子和其他随机的机器里。它们会被熔化、加工或处理。

EVM 是一个工业工厂

图片来源:Patrick Henry[7] on Unsplash

在我之前的文章中 关于 ABI 的一切[8] 我提到。"智能合约是非常放松的小生命"。

但是,一旦智能合约被调用,它们就会变得非常活跃! 他们的底层逻辑作为字节码运行并被执行。而这多亏了 EVM。

EVM 从未放松过。它不断地在运行软件客户端的每台机器上重复运行。这是为了更新区块链的状态,让每个人都保持同步。

一个大的工业工厂也是如此。它一直在运行,24/7。门口总是有人,夜班工人在机库里整理东西,卡车进进出出,机器和熔炉在熔化、燃烧和加工更多材料。

当你调用一个智能合约时,EVM 会运行并执行其字节码中的一组指令(=操作码)。其中一些操作码指示 EVM 从/向不同的位置读写数据。EVM 需要这些多个数据位置来正确完成其工作。

在一个工厂里,操作工作和材料可以在多个地方找到。

  • 叉车从位于工厂专用储存区的大而高的货架上抓取托盘上的材料。(又称存储 Storage)
  • 操作员从控制室控制机器和机器人,将无法通过炉门的大块钢铁/铝材分解成小块。(又称内存)
  • 要加工的材料通过承运人(卡车或轮船)运送,装在密封的容器中,你只能知道/看到其中的内容,但不能触摸它。(又称calldata)
  • 由于传送带的存在,更多的材料被加工并通过工厂运送。(又称)
  • 最后,工厂有自己的道路交通规则。里面到处都是路标! (a.k.a 代码)

你明白了,就像工厂一样,EVM 使用很多不同的区域来计算和做处理工作。

EVM 就像一个工业工厂。

为什么要在 Solidity 中理解 Evm 数据位置?

学习每个数据位置是如何工作的,需要学习很多东西,比如 "存储"、"内存 "和 "calldata"的结构和布局,或者 "什么内容可以存储在哪里"。

但最重要的是,它教会了你与它们每一个相关的(Gas)成本,以及我所说的可变性/安全性的权衡。

作为一个 Solidity 开发者,对 EVM 中的数据位置以及如何充分使用它们的良好理解将使你能够:

  1. 提高你的智能合约的性能。
  2. 最小化其执行成本(调用其公共或内部函数时使用的 Gas 差异)。
  3. 强化安全性,防止潜在的错误。

数据位置 → 概述

本文旨在对这些不同的数据位置做一个很好的概述,数据可以被写入和读出。我们将看到,有些位置是只读的,不能写入,而其他位置是可变的,里面存储的值可以被编辑。

EVM 有五个主要的数据位置:

  • 存储(Storage)
  • 内存(Memory)
  • 调用数据(Calldata)
  • 堆栈(Stack)
  • 代码(Code)

EVM 中可用的数据位置概览,来源:精通以太坊 [9]

主要 EVM 数据位置的基础知识也在以太坊黄皮书 第 9 章节中有说明[10]

存储

智能合约的存储相当于工业机库上的存储架单元,Petrebels[11] on Unsplash

在以太坊中,每个特定地址的智能合约都有自己的 "存储",由一个键值存储组成,将 256 位映射到 256 位。存储中的数据在函数调用和交易之间持续存在。

存储是所有合约状态变量所在的地方。每个合约都有自己的存储。存储中的变量在函数调用之间持续存在。然而,存储空间的使用是相当昂贵的。

由于存储指的是合约存储,它指的是永久存储在区块链上的数据。

你可以从/向合约存储中读取和写入。在低层,用于这样做的 EVM 操作码是SSTORESLOAD

内存

(图来源: Simon Kadula[12] on Unsplash)

EVM 内存是用来保存临时值的,在外部函数调用之间被擦除。然而,它的使用成本比较低。

在 EVM 中,内存是易失性的,是特定合约(环境)的上下文。这意味着,当执行环境从一个合约变为另一个合约时,“白板/写字板”被清除。在每一个新的消息调用中,都会获得一个新的被清除的内存实例。

因此,内存变量是暂时的。它们在对其他合约的外部函数调用之间被擦除。

你可以从/到 EVM 内存中读取和写入。在低层,用于从/向内存读写的 EVM 操作码是MLOAD, MSTORE, 和MSTORE8

某些 EVM 操作码,如 "CALL"、"DELEGATECALL "或 "STATICCALL" 从 EVM 内存中消耗其参数。

Calldata

calldata 相当于从船上或卡车上取出的一个集装箱。这些集装箱包含送到工厂进行加工的材料。Calldata 是只读的。

calldata是交易的数据或外部函数调用的参数所在的位置。它是一个只读的数据位置。你不能写到它。

Calldata 的行为主要类似于内存,是一个可由字节编址的空间。你必须为你想读取的字节数指定一个准确的字节偏移。

在低层,可用于从 calldata 读取的 EVM 操作码是CALLDATALOAD, CALLDATASIZECALLDATACOPY

堆栈(Stack)

(来源:Arno Senoner[13] on Unsplash)

堆栈是用来存放小型局部变量的。它的使用几乎是免费的(用 Gas 很低),但大小有限,能容纳的项目数量也有限。

堆栈是大多数在函数内部创建的局部变量所在的地方。它是 EVM 的一个重要部分。

在低层,可以用来对堆栈进行操作的 EVM 操作码,包括PUSHPOPSWAPDUP指令。大多数其他的 EVM 操作码从堆栈中消耗数据(通过从堆栈中取出),并将结果推回堆栈中。

代码

(资料来源: Waldemar Brandt [14]on Unsplash)

代码指的是合约的字节码。你只能从合约字节码中读取,而不能写到它。通常是你在 Solidity 中定义为 constant的变量。大多数的 EVM 操作码从堆栈中消耗它们的参数。

字节码包含了很多关于合约的信息和逻辑,包括调度器,以及合约元数据。

在低层,从智能合约的代码中读取的 EVM 操作码是CODESIZECODECOPY及操作码EXTERNALCODESIZEEXTERNALCODECOPY

来源:https://github.com/CJ42/All-About-Solidity/blob/data-locations/articles/Data-Locations.md

数据位置 - 规则

变量的默认位置

Solidity 语言定义了一些默认的规则,围绕着一些变量的默认位置,取决于它们被定义的位置。

  • 定义为 constant的变量 = 合约代码(=bytecode)。

这些变量是不可改变的,一旦合约被部署就不能改变。它们是只读的,可以被内联使用。

  • 状态变量(在函数之外声明) = 默认情况下在存储中。

这些被称为状态变量,因为它们是合约状态的一部分,反过来也是区块链全局状态的一部分(=以太坊中所有智能合约的状态)。这些变量被永久地写入区块链。

  • 本地变量(在函数体内声明)= 在堆栈中。

值类型的变量(例如,uint256, bytes8 , address)驻留在堆栈中。

大多数时候,你不需要使用数据位置关键字(storagememory,或calldata),因为 Solidity 通过上面解释的默认规则处理数据的位置。

然而,有时你确实需要使用这些关键字并指定数据位置,即在处理复杂类型的变量时,如函数内的结构体和数组。

参考类型

对于数组(固定或动态大小的数组, 如uint256[]), bytes, string, 结构和映射, 你必须明确提供存储值的数据区域. 这可以是storagememorycalldata

通过使用这些关键字,你可以创建一个 "引用" 类型的变量。这种类型必须比值类型更仔细地处理。

下一个问题自然就出现了:

什么时候使用关键字存储(storage),内存(memory),calldata

你只能在函数中的 3 个地方指定引用一个变量的数据位置。

  • A) 对于参数(=函数定义)
  • B) 对于函数内部的局部变量(=函数主体)
  • C) 返回值总是在内存中(=函数定义)。

备注: 在 Solidity 0.5.0 之前,当一个复杂类型的变量(例如,动态大小的数组)被作为一个函数参数传递时,可以不指定这个变量的数据位置。

在函数参数上的规则

来源:https://github.com/CJ42/All-About-Solidity/blob/data-locations/articles/Data-Locations.md#data-location-rules-for-function-parameters

storage被用作一个函数参数的引用时,它是一个指向合约存储的指针。

对于memorycalldata也是如此。这样的关键字指向 EVM 内存中的某个位置或从交易中进来的输入数据(=calldata)的指针。

在函数体内的规则

在函数内部,无论函数的可见性如何,都可以指定所有三个数据位置:

然而,引用类型之间的赋值是受特定规则约束的。(这里是变得复杂和 "略微扭曲舌头的地方!")。

  • storage 引用:总是可以直接从合约存储中(=状态变量)或通过另一个 "存储" 引用 给一些变量赋值,但它们不能赋值一个 "内存 "或 "calldata "引用。
  • memory引用:可以被分赋值任何东西(直接的状态变量,或storagememorycalldata引用)。它总是创建一个副本。
  • calldata引用:总是可以直接从 calldata(= tx/message 调用的输入),或通过另一个calldata引用赋值,但它们不能从storagememory引用赋值。

为了更简单地描述它。

对于 memory =总是可以复制内存中的任何数据(无论它来自合约的存储还是 calldata)。 对于存储和 calldata = 我们只能分配来自指定数据位置的值(无论是直接类型还是通过相同类型的引用)。

让我们看看一些真实的、实用的 Solidity 例子:

代码语言:javascript复制
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;

contract StorageReferences {

  bytes someData;

  function storageReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // storage variables can reference storage variables as long as the storage
    reference they refer to is initialized.
    bytes storage d = a;

    // if the storage reference it refers to was not initiliazed, it will lead
    to an error
    // "This variable (refering to a) is of storage pointer type and can be
    accessed without prior assignment,
    // which would lead to undefined behaviour."
    // basically you cannot create a storage reference that points to another
    storage reference that points to nothing
    // f -> e -> (nothing) ???
    /// bytes storage e;
    /// bytes storage f = e;

    // storage pointers cannot point to memory pointers (whether the memory
    pointer was initialized or not
    /// bytes storage x = b;
    /// bytes memory r = new bytes(3);
    /// bytes storage s = r;

    // storage pointer cannot point to a calldata pointer (whether the calldata
    pointer was initialized or not).
  /// bytes storage y = c;
  /// bytes calldata m = msg.data;
  /// bytes storage n = m;
}

}
代码语言:javascript复制
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;

contract DataLocationsReferences {

  bytes someData;

  function memoryReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // this is valid. It will copy from storage to memory
    bytes memory d = a;

    // this is invalid since the storage pointer x is not initialized and does
    not point to anywhere.
    /// bytes storage x;
    /// bytes memory y = x;

    // this is valid too. `e` now points to same location in memory than `b`;
    // if the variable `b` is edited, so will be `e`, as they point to the same
    location
    // same the other way around. If the variable `e` is edited, so will be `b`
    bytes memory e = b;

    // this is invalid, as here c is a calldata pointer but is uninitialized,
    so pointing to nothing.
    /// bytes memory f = c;

    // a memory reference can point to a calldata reference as long as the
    calldata reference
    // was initialized and is pointing to somewhere in the calldata.
    // This simply result in copying the offset in the calldata pointed by the
    variable reference
    // inside the memory
    bytes calldata g = msg.data[10: ];
    bytes memory h = g;

    // this is valid. It can copy the whole calldata (or a slice of the
    calldata) in memory
  bytes memory i = msg.data;
  bytes memory j = msg.data[4: 16];
}

}
代码语言:javascript复制
// SPDX-License-Identifier: Apache-2
pragma solidity ^ 0.8 .0;

contract DataLocationsReferences {

  bytes someData;

  function calldataReferences() public {
    bytes storage a = someData;
    bytes memory b;
    bytes calldata c;

    // for calldata, the same rule than for storage applies.
    // calldata pointers can only reference to the actual calldata or other
    calldata pointers.
  }
}

你可以在这里找到 Solidity 合约代码[15]

内存 ← 状态变量
代码语言:javascript复制
contract MemoryCopy {
  bytes someData;
  constructor() {
    someData = bytes("All About Solidity");
  }

  function copyStorageToMemory() public {

    // assigning memory <-- storage
    // this load the value from storage and copy in memory
    bytes memory value = someData;
    // changes are not propagated down in the contract storage
    value = bytes("abcd");

  }
}

当我们将一个状态变量赋值给一个内存引用的变量时,基本上是将数据从存储空间 → 复制到内存。

= 我们正在向内存写入 =新的内存被分配。

这意味着对变量的任何修改都不会影响到合约存储(=合约状态)。

= 合约存储将不会被重写。

在上面的例子中,运行函数后,状态变量someData没有被改变。

状态变量 ← 内存

这是前一个例子 "内存 ← 状态变量 "的反例。同样会进行整体拷贝,这个例子可以在 Solidity 文档部分找到。

存储指针
代码语言:javascript复制
contract StoragePointer {
  uint256[] someData;
  uint256[] moreData;

  function createStoragePointer() public {

    // pointer to storage
    uint256[] storage value = someData;
    // pointer to somewhere else in storage
    value = moreData;
  }
}

当一个storage变量在一个函数中被创建时,这基本上是作为一个存储指针。存储指针简单地引用已经分配到存储空间的数据。

你可以重新分配存储指针,将其指向存储中的其他地方。

= 引用存储中的一些现有值 = 不创建新的存储

然而,我们可以通过直接给查找变量分配一个新的值来覆盖合约存储。看一下这个例子。

代码语言:javascript复制
contract StoragePointer {
  uint256[] public someData;

  function pushToArrayViaPointer() public {

    uint256[] storage value = someData;
    value.push(1);
  }
}

如果你接着查询someData的第一个索引,你会得到1

内存 ← 存储指针

当我们将一个storage引用的数据分配给一个memory引用的变量时, 我们是在从 storage → memory 中复制数据。

= 我们是在向内存写数据 = 新的内存被分配。

这等同于我们之前涉及的第一种情况 "内存 ← 状态变量",并通过存储引用增加了 "中间的额外路径"。存储引用将解析到状态变量,然后将其复制到内存。下面是一个建立在前面的代码片段上的基本例子。

代码语言:javascript复制
contract MemoryCopy {
  uint256[] public someData;

  function copyStorageToMemoryViaRef() public {

    uint256[] storage storageRef = someData;
    uint256[] memory copyFromRef = storageRef;
  }
}

这里copyFromRef是整个数组someData的拷贝。这个数组是通过存储引用storageRef在内存中复制的。

同样在此案例中,由于我们从存储空间复制到内存,我们是在操作数据的副本,而不是在存储空间中的实际数据上。因此,对copyFromRef所做的任何修改都不会影响合约存储,也不会修改合约状态。

为了说明这一点,请在 Remix 中复制以下合约,然后。

  1. 运行函数test()
  2. 在索引1处读取someData数组。
代码语言:javascript复制
contract MemoryCopy {
  uint256[] public someData;
  constructor() public {
    someData.push(1);
    someData.push(2);
    someData.push(3);
  }

  function doesNotPropagateToStorage() public {
    uint256[] storage storageRef = someData;
    uint256[] memory copyFromRef = storageRef;

    copyFromRef[1] = 12345;
  }
}

你会看到在运行函数后,someData[1]的值仍然是2,而且12345并没有传播回合约存储。

calldata 引用

Calldata 引用的行为与storage引用相同。它只能作为交易数据的引用,或者作为用calldata关键字提供的复杂类型的函数参数的引用。

简而言之,一个 calldata 类型的变量总是创建一个引用。

唯一的主要区别是,作为calldata引用的变量不能被修改,因为 calldata 是只读的。

这与storage引用恰恰相反。当你给storage引用分配一个新的值时,这个变化会传播到合约状态。

数据位置 - 行为

本节借鉴了 Forest Fang 的文章[16] 我强烈推荐你阅读它! 我曾用它来分析更多的底层 EVM 操作代码,以了解在背后发生了什么。

Solidity 文档中提到了以下内容:

"数据位置不仅与数据的持久性有关,而且还与赋值的语义有关"。

在指定函数体内部的数据位置时,必须考虑两个主要问题:效果和 Gas 消耗。

让我们用一个简单的合约作为例子来更好地理解。这个合约在存储中持有一个结构体的映射。为了比较每个数据位置的行为,我们将使用不同的函数,使用不同的数据位置关键字。

  • 使用存储 "storage"的 getter。
  • 使用内存 "memory"的 getter。
  • 使用存储 "storage"的 setter。
  • 使用内存 "memory"的 setter。
代码语言:javascript复制
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^ 0.8 .0;

contract Garage {
  struct Item {
    uint256 units;
  }
  mapping(uint256 => Item) items;

  // gas (view): 24,025
  function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
    Item storage item = items[_itemIndex];
    return item.units;
  }

  // gas (view): 24,055
  function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
    Item memory item = items[_itemIndex];
    return item.units;
  }

  // gas: 50,755 (1st storage write)
  function setItemUnitsStorage(uint256 _id, uint256 _units) public {
    Item storage item = items[_id];
    item.units = _units;
  }

  // gas: 27,871
  function setItemUnitsMemory(uint256 _id, uint256 _units) public {
    Item memory item = items[_id];
    item.units = _units;
  }
}
使用存储的 Getter 与 内存的 Getter 对比

我们要问自己的第一个问题是:使用内存的 getter 还是使用存储的 getter 更便宜?它们各自是如何工作的?

代码语言:javascript复制
// gas (view): 24,025
function getItemUnitsStorage(uint _itemIndex) public view returns(uint) {
  Item storage item = items[_itemIndex];
  return item.units;
}

// gas (view): 24,055
function getItemUnitsMemory(uint _itemIndex) public view returns(uint) {
  Item memory item = items[_itemIndex];
  return item.units;
}

使用 "storage "的 getter 比使用 "memory "的 getter 稍微便宜一些,因为它充当了一个存储指针。

使用memory的 getter 更贵,花费更多的 Gas,因为要创建一个新的变量。在上面的例子中,除了读取存储映射items[_itemIndex]里面的值之外,它还会复制内存中的值。

让我们确保理解这里到底发生了什么。根据关键字 "storage "或 "memory",EVM 在幕后做了什么?

我在下面列出了两种获取器类型的操作码序列(为了清晰和简洁,左边没有写程序计数器)。你可以通过在 Remix 中调试代码来查看它们。

如果我们比较两个 getter 函数的操作码,我们会发现,与使用内存的 getter(45 条指令)相比,使用存储的 getter 包含更少的操作码(30 条指令)。

建议:我强烈建议使用 evm.codes 网站来测试这两个函数,并分析操作码和存储/内存中一个又一个操作码的变化。

代码语言:javascript复制
; getItemUnitsStorage = 30 instructions
PUSH1 00   ; 1) manipulate   prepare the stack
DUP1
PUSH1 00
DUP1
DUP5
DUP2
MSTORE     ; 2.1) prepare the memory for hashing (1)
PUSH1 20
ADD
SWAP1
DUP2
MSTORE     ; 2.2) prepare the memory for hashing (2)
PUSH1 20
ADD
PUSH1 00
SHA3       ; 3) compute the storage number to load via hashing
SWAP1
POP
DUP1
PUSH1 00
ADD
SLOAD      ; 4) load mapping value from storage
SWAP2
POP
POP
SWAP2
SWAP1
POP
JUMP
JUMPDEST
; getItemUnitsMemory = 47 instructions
PUSH1 00
DUP1
PUSH1 00
DUP1
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40  ; <------ additional opcodes start here
MLOAD     ; 1) load the free memory pointer
DUP1      ; 2) reserve the free memory pointer by duplicating it
PUSH1 20
ADD       ; 3) compute the new free memory pointer
PUSH1 40
MSTORE    ; 4) store the new free memory pointer
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD     ; 5) load mapping value from storage
DUP2
MSTORE    ; 6) store mapping value retrieved from storage in memory
POP
POP ; <------------ additional opcodes end here
SWAP1
POP
DUP1
PUSH1 00
ADD
MLOAD
SWAP2
POP
POP
SWAP2
SWAP1
POP

在底层,EVM 对一个使用storage的 getter 执行以下步骤。

  1. 操作 准备堆栈
  2. 为 hash 准备内存((1)和(2))。
  3. 计算要通过 hash 和SHA3加载的值的存储槽(=来自映射的值在哪个存储槽。见我的文章 `关于映射`,以更好地理解[17]如何计算/计算映射的存储槽)。
  4. 通过SLOAD从存储空间加载值。

你可以在这里[18]看到存储版本的 getter 函数堆栈的所有细节说明 。

然后出现的下一个问题是: 为什么内存的 getter 函数底层字节码指令中多了 17 条?

答案就在上面的汇编代码的描述注释中。这 17 条额外的 EVM 指令执行以下内容:

  1. 它在内存中预留了一些空间用于存储数值,方法是:1.1)加载空闲内存指针,1.2)预留,1.3)计算内存中下一个空闲空间,4)更新新的空闲内存指针。(mload mstore)
  2. 一旦数值通过SLOAD从合约存储中加载,然后通过MSTORE写入内存。

你可以在这里[19]看到内存版的 getter 堆栈的所有细节的说明。

使用 storage 与 memory 的 Setter 函数对比

我推荐来吃 Rob Hitchens 这篇文章Solidity 中的存储指针:'这里有龙'[20]

以更好地理解使用 setter 函数的行为:

代码语言:javascript复制
// gas: 50,755 (1st storage write)
function setItemUnitsStorage(uint256 _id, uint256 _units) public {
  Item storage item = items[_id];
  item.units = _units;
}

// gas: 27,871
function setItemUnitsMemory(uint256 _id, uint256 _units) public {
  Item memory item = items[_id];
  item.units = _units;
}

当为一个变量指定storage并设置其值时,它将修改状态变量并覆盖合约存储。这将使用SSTORE操作码。

相反,如果使用关键字memory,它将仅仅覆盖本地变量,而不覆盖合约存储。这将使用MSTORE操作码。

当涉及到 EVM 指令集时,我们可以从下面的片段中看到,使用storage产生的指令要少很多(storage产生 28 条,而memory产生 48 条)。然而,使用storage将花费更多的 Gas,因为存储读/写是 EVM 中最昂贵的操作。

代码语言:javascript复制
; setItemUnitsStorage = 28 instructions

PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
SWAP1
SSTORE
POP
POP
POP
POP

; setItemUnitsMemory = 46 instructions

PUSH1 00
DUP1
PUSH1 00
DUP5
DUP2
MSTORE
PUSH1 20
ADD
SWAP1
DUP2
MSTORE
PUSH1 20
ADD
PUSH1 00
SHA3
PUSH1 40
MLOAD
DUP1
PUSH1 20
ADD
PUSH1 40
MSTORE
SWAP1
DUP2
PUSH1 00
DUP3
ADD
SLOAD
DUP2
MSTORE
POP
POP
SWAP1
POP
DUP2
DUP2
PUSH1 00
ADD
DUP2
DUP2
MSTORE
POP
POP
POP
POP
POP

映射的(边缘)情况

映射可以作为参数传递给函数或定义在函数体内。然而,它们是一个边缘案例,有两个特定的规则:

  • 它们只能被分配到storage数据位置,作为对已经存在于合约存储中的映射的引用。
  • 它们必须被初始化为一个值。

这是因为映射不能被动态地创建。它们只能被定义在函数体内,作为对已经作为状态变量存在的映射的引用。

如果你在 Remix 中这样写...

这就是你将得到的错误。

结论

你应该使用storage, memory, 还是calldata取决于你在合约中试图做什么。

对于某些数据类型,如果数据很大,把它们从存储空间复制到内存中可能会很昂贵。

然而,在某些情况下,如果你想在函数中覆盖变量,而不把结果传播到合约存储中,这可能是必要的。

另一方面,calldata提供了比内存更多的好处:

  1. 节省 Gas=通过避免将数据复制到内存中(从而在之后不得不从内存中加载)。
  2. 更安全(在某些情况下) = 因为calldata是一个只读的数据位置,它可以确保数据不能被修改。这可能会增加安全性,特别是当传递给函数的参数代表 敏感数据(例如 64 字节的签名)时。

calldata在 Solidity 文档中确实被推荐:

来源:https://learnblockchain.cn/docs/solidity/types.html#data-location

然而,在某些情况下,使用memory而不是calldata可以提高可组合性

最后,请注意,在你的函数中不使用适当的数据位置会导致潜在的错误和漏洞。一个现实世界的例子是对 Cover 协议的无限铸币攻击。

看看这个来自 Cover 协议的Blacksmith.sol合约的代码片断:

对池变量所做的任何改变都是在内存中进行的,而不是传回合约存储。

关于这个错误的更多细节,请看 Mudti Gupta 的博文:https://mudit.blog/cover-protocol-hack-analysis-tokens-minted-exploit/

参考文献

Solidity 中的数据表示[21]

以太坊 Solidity: 内存与存储以及在本地函数中使用哪一种?存储和内存数据位置之间的区别[22]

深入以太坊, Part 2 [23]

类型 - Solidity 0.8.15 文档 [24]

All-About-Solidity/Data-Locations.md[25]

EVMillustrated[26]以太坊虚拟机[27]

深入 EVM[28]

cover 分析[29]


本翻译由 Duet Protocol[30] 赞助支持。

原文链接: https://betterprogramming.pub/solidity-tutorial-all-about-data-locations-dabd33212471

参考资料

[1]

登链翻译计划: https://github.com/lbc-team/Pioneer

[2]

翻译小组: https://learnblockchain.cn/people/412

[3]

Tiny 熊: https://learnblockchain.cn/people/15

[4]

Ant Rozetski: https://unsplash.com/@rozetsky

[5]

取自StackExchange的以太坊和EVM架构的代表图: https://ethereum.stackexchange.com/questions/268/ethereum-block-architecture

[6]

包含轧机的机库(看视频链接!): https://www.youtube.com/watch?v=kiyIaKynqsU

[7]

Patrick Henry: https://unsplash.com/@worldsbetweenlines

[8]

关于 ABI 的一切: https://coinsbench.com/solidity-tutorial-all-about-abi-46da8b517e7

[9]

精通以太坊 : https://github.com/ethereumbook/ethereumbook/blob/develop/13evm.asciidoc

[10]

以太坊黄皮书 第9章节中有说明: https://ethereum.github.io/yellowpaper/paper.pdf

[11]

Petrebels: https://unsplash.com/@petrebels

[12]

Simon Kadula: http://simonkadula

[13]

Arno Senoner: https://unsplash.com/@arnosenoner

[14]

Waldemar Brandt : https://unsplash.com/@waldemarbrandt67w

[15]

你可以在这里找到Solidity合约代码: https://github.com/CJ42/All-About-Solidity/blob/master/articles/data-locations/DataLocationsReferences.sol

[16]

本节借鉴了Forest Fang 的文章: https://medium.com/coinmonks/ethereum-solidity-memory-vs-storage-which-to-use-in-local-functions-72b593c3703a

[17]

见我的文章 关于映射,以更好地理解: https://medium.com/coinmonks/solidity-tutorial-all-about-mappings-29a12269ee14

[18]

这里: https://github.com/CJ42/All-About-Solidity/blob/1d25ade27299aac229e5413830a5d5aef3f160dd/articles/data-locations/getter-storage.asm

[19]

这里: https://github.com/CJ42/All-About-Solidity/blob/master/articles/data-locations/getter-memory.asm

[20]

Solidity 中的存储指针:'这里有龙': https://blog.b9lab.com/storage-pointers-in-solidity-7dcfaa536089

[21]

Solidity中的数据表示: https://ethdebug.github.io/solidity-data-representation/

[22]

以太坊 Solidity: 内存与存储以及在本地函数中使用哪一种?存储和内存数据位置之间的区别: https://medium.com/coinmonks/ethereum-solidity-memory-vs-storage-which-to-use-in-local-functions-72b593c3703a

[23]

深入以太坊, Part 2 : https://blog.openzeppelin.com/ethereum-in-depth-part-2-6339cf6bddb9/

[24]

类型 - Solidity 0.8.15 文档 : https://docs.soliditylang.org/en/v0.8.15/types.html#data-location

[25]

All-About-Solidity/Data-Locations.md: https://github.com/CJ42/All-About-Solidity/blob/master/articles/Data-Locations.md

[26]

EVMillustrated: https://drive.google.com/viewerng/viewer?url=https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf&embedded=true

[27]

以太坊虚拟机: https://www.evm.codes/about

[28]

深入EVM: https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy?s=r

[29]

cover 分析: https://mudit.blog/cover-protocol-hack-analysis-tokens-minted-exploit/

[30]

Duet Protocol: https://duet.finance/?utm_souce=learnblockchain

0 人点赞