搞定EVM中的内存数据区

搞定 evm 中的 memory

第一部分[2],我们分析了 remix 的第一个合约示例 1_Storage.sol。

// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

/**
 * @title Storage
 * @dev Store & retrieve value in a variable
 */
contract Storage {

    uint256 number;

    /**
     * @dev Store value in variable
     * @param num value to store
     */
    function store(uint256 num) public {
        number = num;
    }

    /**
     * @dev Return value
     * @return value of 'number'
     */
    function retrieve() public view returns (uint256){
        return number;
    }
}

然后我们编译生成了字节码,并关注了与函数选择有关的部分。在这篇文章中,我们将重点讨论合约运行时字节码的前 5 个字节。

6080604052
60 80                       =   PUSH1 0x80
60 40                       =   PUSH1 0x40
52                          =   MSTORE

这 5 个字节代表了 "自由内存指针(free memory pointer)"的初始化。为了充分了解这意味着什么,以及这些字节的作用,我们必须首先建立你对管理合约内存(memory)的数据结构的认知。

这种功能是由 3 个操作码决定的,这些操作码对内存进行操作。当执行完上面的操作码,你可能会注意到一些奇怪的现象。首先,当我们写入一个单字节数据 0x22 到内存中,然后用 MLOAD8 到内存位置 0x20(十进制:32)取数据时,得到的不是而是

内存(memory)的数据结构

合约内存是一个简单的字节数组,数据可以以 32 字节(256 位)或 1 字节(8 位)为单位存储,以 32 字节(256 位)为单位读取。下面的图片说明了这种结构以及合约内存的读/写功能。

  • MSTORE(x, y) - 从内存位置 "x "开始存储一个 32 字节(256 位)的值 "y"。
  • MLOAD(x) - 从内存位置 "x "开始读取一个 32 字节(256 位)的值并加载到调用栈(stack)上
  • MSTORE8(x, y) - 在内存位置 "x"(32 字节栈值的最小有效字节)存储一个 1 字节(8 位)的值 "y"。你可以把内存位置看成是开始写/读数据的数组索引。如果你想写/读超过一个字节的数据,你只需从下一个数组索引继续写或读。

EVM Playground

这个 EVM 操练场(EVM Playground)将有助于巩固你对这 3 个操作码的理解以及内存位置的工作原理。点击 "运行 "和右上方的卷曲箭头,步进操作码,看看堆栈和内存是如何被改变的。(在操作码的上方有注释,描述了每一部分的作用)

你也许会问了,我们只写入了一个字节,怎么会有这么多的零?

Memory Expansion(内存拓展)

当你的合约写到内存时,你必须为所写的字节数付费。如果你写到一个以前没有被写过的内存区域,那么第一次使用该区域会有一个额外的内存扩展费用。当写到以前未使用的内存空间时,内存以 32 字节(256 位)的增量进行扩展。

  • 内存拓展成本在最初的 724 字节成线性比例增加,之后会以二次方比例增加 上面例子中我们在写入 1 个字节前已经使用了 32 字节内存。再继续写入这一字节,我们开始向之前未使用的内存写入,结果,内存又被扩大了 32 字节的增量,达到 64 字节。请注意,内存中的所有位置最初都被很好地定义为零,这就是为什么我们看到 2200000000000000000000000000000000000000000000000000000000000000000000 添加到我们的内存。
  • 记住 EVM 中的内存存储器是个字节数组

    第二个关键现象你可能已经注意到了,当我们运行 MLOAD 操作码从内存位置 0x21(十进制:33)读取数据时。我们获得一个返回值,并被压入了栈区(stack)。这意味着我们可以从非 32 字节对齐的内存位置读取数据。记住内存是一个字节数组,这意味着我们可以从任何内存位置开始读(和写)。我们不受限制于 32 的倍数。内存是线性的,可以在字节级别上寻址。

  • 内存只能在一个函数中新创建。它可以是新实例化的复杂类型,如数组/结构(例如通过 new int[...])或从存储引用(storage)的变量中复制。现在我们对数据结构有了一丢丢的了解,让我们回到自由内存指针(free memory pointer)的问题上。
  • Free Memory Pointer(自由内存指针)

    Free Memory Pointer 只是一个指向自由内存开始位置的指针。它确保智能合约知道已写入和未写入的内存位置。这可以防止合约覆盖一些已经分配给另一个变量的内存。当一个变量被写入内存时,合约将首先参考 Free Memory Pointer,以确定数据应该被存储在哪里。然后,它更新 Free Memory Pointer,指出有多少数据将被写入新的位置。这两个值的简单相加将产生新的自由内存的起始位置。

    freeMemoryPointer + dataSizeBytes(数据大小) = newFreeMemoryPointer

    字节码

    如前面所写,freeMemoryPointer 是通过这 5 个字节码相对应的操作码定义的

    60 80                       =   PUSH1 0x80
    60 40                       =   PUSH1 0x40
    52                          =   MSTORE

    这些实际上说明了 freeMemoryPointer 在内存中位于 memory 中的 0x40(十进制:64)位置,其值为 0x80(十进制 128). 你可能立马想问咯,为啥会使用 0x40 和 0x80 呢?这个问题下面会解释。

    Solidity 的内存布局保留了 4 个 32 字节的插槽:

    • 0x00 - 0x3f (64 bytes): scratch space
    • 0x40 - 0x5f (32 bytes): free memory pointer
    • 0x60 - 0x7f (32 bytes): zero slot 我们可以看到,0x40 是 solidity 为 freeMemoryPointer 预留的位置。值 0x80 只是在 4 个保留的 32 字节的插槽之后第一个可写的位置。我们快速浏览下每个保留部分的作用。
    • Scratch space: 可以在语句之间使用,例如在内联汇编语句和哈希方法中使用。(译者注:我也没看懂:(
    • Free Memory Pointer: 当前分配的内存大小,自由内存的起始位置,最初为 0x80
    • Zero slot: 被用作动态内存数组的初始值,永远不能被写入。

    实际合约中的内存

    为了巩固我们到目前为止所学到的知识,我们要看一下内存和空闲内存指针是如何在真实的 solidity 代码中更新的。我创建了一个 MemoryLane 合约,并有意让它变得非常简单。它有一个函数,只是定义了两个长度为 5 和 2 的数组,然后给 b[0]赋值为 1。尽管很简单,但当这 3 行代码被执行时,会发生很多事情。

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.3

    contract MemoryLane {

        function memoryLane() public pure {
            bytes32[5] memory a;
            bytes32[2] memory b;
            b[0] = bytes32(uint256(1));
        }
    }

    为了查看这个 solidity 代码在 EVM 中的执行细节,可以把它复制到一个 remix IDE 中。复制后,你可以编译代码,部署它,运行 memoryLane()函数,然后进入调试模式,逐步浏览操作代码(关于如何做的说明,见这里[3])。我已经将一个简化版本提取到 EVM Playground 中,并将在下面运行它。简化版按顺序组织操作码,去掉了 JUMP 和任何与内存操作无关的操作码。代码中加入了注释,以解释现在在做啥。这段代码被分成 6 个不同的部分,我们将深入研究。我不能不强调,使用 EVM Playground 和自己按步执行操作码是多么重要。这将大大促进你的学习。现在让我们来看看这 6 个部分。

    Free Memory Pointer 初始化(EVM Playground Lines 1-15)

    首先,咱已经在上面初步讨论过 FreeMemoryPointer 的初始化,一个 0x80 的值被放入 stack 中。这是 FreeMemoryPointer 的值,由 solidity 的内存布局决定。在这个阶段,我们的内存没有任何东西.

    接下来,把 FreeMemoryPointer 的内存位置 0x40 放到 stack 中。

    最后,我们调用 MSTORE,从 stack 中弹出 0x40,以确定写入内存的位置,第二个值 0x80 作为写入内容。这样 stack 就空了,但现在内存中存在一些值了。这个内存表示是十六进制的,每个字符代表 4 位。我们在内存中有 192 个十六进制字符,这意味着我们有 96 个字节(1 字节=8 比特=2 个十六进制字符)。如果我们参考 Solidity 的内存布局,我们知道前 64 个字节将被分配为 scratch 空间,接下来的 32 个字节将是 Free Memory Pointer。

    内存分配变量 "a "和更新 Free Memory Pointer(EVM Playground 第 16-34 行)

    对于剩下的部分,为了简洁起见,我们将跳到每一部分的结束状态,并对所发生的事情做一个高层次的概述。各个操作码的步骤可以通过 EVM 操场看到。接下来为变量 "a"(bytes32[5])分配内存,并更新 Free Memory Pointer。编译器将通过数组大小和默认的数组元素大小确定需要多少空间。

  • 请记住 Solidity 中内存数组中的元素总是占据 32 字节的倍数(这甚至对 bytes1[]来说也是如此,但对 bytes 和字符串来说不是如此) 数组的大小乘以 32 个字节,告诉我们需要分配多少内存。在这种情况下,5*32 的计算结果是 160,即十六进制的 0xa0。我们可以看到这个值被推入 stack,并与当前的 Free Memory Pointer 位置 0x80(十进制为 128)相加,得到新的 Free Memory Pointer。这将返回 0x120(十进制 288),我们可以看到它已被写入 Free Memory Pointer 位置。调用堆栈将变量 "a "的内存位置 0x80 保留在 stack 中,这样它就可以在以后需要时引用它。0xffff 代表一个 JUMP 位置,可以被忽略,因为它与内存操作无关。
  • 内存初始化变量"a"(EVM Playground 第 35-95 行)

    现在,内存已经分配完毕,Free Memory Pointer 也已更新,我们需要为变量 "a "初始化内存空间。由于该变量只是被声明而没有被分配,它将被初始化为零值。为了做到这一点,EVM 使用 CALLDATACOPY,它接收了 3 个变量。

    • memoryOffset (要复制数据到哪个内存位置)
    • calldataOffset (要复制的 calldata 中的字节偏移)
    • size (要复制的字节大小) 在我们的例子中,memoryOffset 是变量 "a "的内存位置(0x80)。calldataOffset 是我们 calldata 的实际大小,因为我们不想复制任何 calldata,我们想用零值初始化内存。最后,大小是 0xa0 或 160 字节,因为这是该变量的大小。我们可以看到我们的内存已经扩展到 288 字节(这包括 zero slot),stack 再次持有变量的内存位置和调用堆栈上的一个 JUMP 位置。

    内存分配变量 "B"和更新 Free Memory Pointer(EVM Playground 第 96-112 行)

    这与变量 "a "的内存分配和 Free Memory Pointer 更新相同,只是这次是针对 "byte32[2]内存 b"。Free Memory Pointer 被更新为 0x160(十进制 352),这等于之前的 Free Memory Pointer 位置 288 加上新变量的大小(字节 64)。

    请注意,Free Memory Pointer 在内存中已经更新到了 0x160,我们现在在 stack 上有变量 "b "的内存位置(0x120)。

    内存初始化变量"b"(EVM Playground 第 113-162 行)

    与变量 "a "的内存初始化相同。请注意,内存已经增加到 352 字节。stack 仍然保存着 2 个变量的内存位置。

    为 b[0]赋值(EVM Playground 第 163-207 行)

    最后,我们要给数组 "b "的索引 0 赋值。代码指出 b[0]应该有一个 1 的值。这个值 0x01 被 push 到 stack 的。接下来会发生一个位的左移,但是位移的输入是 0,意味着我们的值不会改变。接下来,要写到 0x00 的数组索引位置被推到 stack 中,并进行检查以确认这个值小于数组的长度 0x02。如果不是,执行就会跳到其他字节码,以处理这个错误状态。MUL(乘法)和 ADD 操作码用于确定在内存中需要写入的数值,以使其对应于正确的数组索引。

    0x20 (32 in decimal) * 0x00 (0 in decimal) = 0x00

    记住内存数组是 32 字节的元素,所以这个值代表数组索引的起始位置。鉴于我们正在向索引 0 写入,所以偏移值为 0。

    0x00 + 0x120 = 0x120 (288 in decimal)

    ADD 是用来将这个偏移值添加到变量 "b "的内存位置。鉴于我们的偏移量是 0,我们将直接把数据写到指定的内存位置。最后,一个 MLOAD 将数值 0x01 加载到这个内存位置 0x120。下面的图片显示了函数执行结束时的系统状态。stack 里所有的变量都已经被弹出了。注意在实际的 remix 中,有几个变量留在 stack 上了,一个 JUMP 位置和函数签名,但是它们与内存操作无关,因此在 EVM playground 中被省略了。我们的内存已经被更新,包括 b[0]=1 的赋值,在我们内存的倒数第三行,一个 0 值已经变成了 1。你可以验证该值是否在正确的内存位置,b[0]应该占据位置 0x120 - 0x13f(字节 289 - 320)。

    我们终于搞定啦,吸收这么多知识,帮助我们对合约内存的工作有了坚实的理解。下次我们需要写一些 solidity 代码时,这将对我们有好处。当你执行一些合约操作码,看到某些内存位置不断 pop 出(0x40)时,你现在就会知道它们的确切含义。

  • 原文链接:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b?s=r
  • 猜你喜欢

    比特币提现会被银行查吗?

    比特币提现会被银行查吗? 首先,根据《中华人民共和国反洗钱法》、《金融机构大额交易和可疑交易报告管理办法》、《金融机构报告涉嫌恐怖融资的可疑交易管理办法》等法律法规的相关规定,银行会对大额资金的流动做监控,主要是审查来源是否合法,是否涉嫌洗钱。

    2022-05-21

    比特币暴跌50%!30岁老公玩比特币输了好多钱

    比特币暴跌50%!30岁老公玩比特币输了好多钱 过去的一周里,作为一个游曳在币圈边缘的键盘侠,见识了币圈度日如年的跌宕后,仍可以笑看潮起潮落。

    2022-05-21

    UST爆雷之后,USDT也要爆雷了?

    这几天的行情,证明了良心哥的推测非常准确。 首先是5月10日分析luna背后是被人开了黑枪,并且持续看空luna。 次日消息实锤,luna再次跌了个99%。 昨天分析说,luna的死亡螺旋会带崩大盘。

    2022-05-21

    Luna币7天蒸发2000亿,但更怕的是熊市即将到来!

    心哥昨天虽然不知道这里边的细节,但依然非常确定的告诉大家,这是一场狙击战,找的就是这个空档,打出来的子弹是要人命的。 另外排队枪毙这个场景,估计今天很多人也领教了。

    2022-05-21

    一天蒸发400亿人民币,Luna是如何被狙击的?

    你们也都知道良心哥炒币是个渣渣,但良心哥的判断大体还是准确的。 可能这就是从业时间久了的盘感吧。 有人说luna的暴跌,ust抛锚,都他吗赖孙宇晨。 从5月5号孙宇晨宣布进军算法稳定币之后,大盘就崩了

    2022-05-21

      上一篇

      web3与ethers调用对比

      下一篇

      使用solidity实现一个printf函数

    评论