本文是《深入理解EVM系统》系列的第三部分,将建立在深入理解EVM系统(1)和深入理解EVM系统(2)之上。在这一部分中,我们将深入探讨合约存储的工作原理,提供一些心智模型来帮助你理解以及深入探索存储槽打包 。
作者:noxx
译者:Kurt Pan
本文是《深入理解EVM系统》系列的第三部分,将建立在深入理解EVM系统(1)和深入理解EVM系统(2)之上。
在这一部分中,我们将深入探讨合约存储 的工作原理,提供一些心智模型来帮助你理解以及深入探索存储槽打包 。
如果“槽打包”这个术语对你来说很陌生,也请不要担心。槽打包的知识对于黑客来说是至关重要的,在文章的最后你将会对它有一个深刻的理解。如果你曾经尝试过Ethernaut Solidity Wargame 系列或其他 Solidity “夺旗”类型的游戏,你就会知道槽打包的知识通常是破解挑战谜题/成功hack的关键。
下面链接的帖子对存储的基础知识进行了高层次的概述。我将复习本文所需的关键点,但我还是强烈建议你去阅读一下全文。
https://programtheblockchain.com/posts/2018/03/09/understanding-ethereum-smart-contract-storage/
我们将从合约存储的数据结构开始,这为其余知识奠定了坚实的基础。
合约存储 只是一个键值映射。它将一个 32 字节的键映射到一个 32 字节的值。因为键长为 32 字节,我们最多可以有 (2^256)-1 个键。(32 字节等于 256 位,这为我们提供了 (2^256)-1 个二进制数可供选择作为键。)
所有值都初始化为 0,并且0不被显式存储。这是有道理的,因为 2^256 大约是已知可观测宇宙中的原子数。没有一台计算机可以保存这么多数据。这也是将存储值设置为0会给你返还一些gas的原因,因为该键-值对不再需要由网络节点存储。
从概念上讲,存储可以被视为一个天文数字的大数组。我们的第一个二进制值为 0 的键表示数组中的第 0 项,二进制值为 1 的键表示数组中的第 1 项,依此类推。
声明为存储变量的合约变量可以分为定长和变长两大阵营。我们将专注于定长变量,以及 EVM 如何将多个变量打包到一个 32 字节的存储槽中。
要了解有关变长变量的更多信息,请参阅下面链接。
既然我们知道存储是一个键值映射,那么下一个问题是键是如何分配给变量的。假设我们有以下solidity代码。
contract StorageTest {
uint256 valuel;
uint256[2] value2;
uint256 value3;
}
因为所有这些变量都是定长的,EVM 可以从使用保留存储位置(键)槽 0(二进制值 0 的键)开始并线性向前移动到槽 1、2 等。将根据在合约中声明变量的顺序来执行此操作。第一个声明的存储变量将存储在槽 0 中。
本例中,槽 0 将保存变量value1
,变量value2
是一个固定大小的 2 数组,因此将占用槽 1 和 2,最后,槽 3 将保存变量value3
。如下图所示。
现在让我们看一下一个类似的合约,并看看变量在这种情况下是如何存储的。
contract StorageTest {
uint32 valuel;
uint32 value2;
uint64 value3;
uint128 value4;
}
注意变量类型不是 uint256。
你可能认为这会和上例一样占用槽 0 到 3。在前面的示例中有 4 个值要存储(考虑到大小为 2 的数组),在这个示例中我们也有 4 个值要存储。
你会惊讶地发现在此例中只使用了存储槽 0。
主要区别在于用于变量的uint
类型。上例中,所有变量都是 uint256
类型,代表 32 个字节的数据。这里我们使用 uint32
、uint64
和 uint128
分别代表 4、8 和 16 字节的数据。
这就是术语槽打包 出现的地方。solidity 编译器知道它可以在一个存储槽中存储 32 个字节的数据。这样一来,当uint32 value1
只占用4个字节存储在槽0时,编译器读取下一个变量时会看是否可以打包到当前存储槽中。
槽0有 32个字节的空间,而 value1
只占用了其中的 4 个,只要下一个变量的大小小于 28 个字节,它也会被打包到槽0中。
对于上面的例子,我们从槽0的 32 字节开始;
value1
存储在槽 0 中,占用 4 个字节value2
是 4 个字节 <= 28 因此它可以存储在槽 0value3
是 8 个字节, <= 24 因此它可以存储在槽 0value4
是 16 个字节,即 <= 16 因此它可以存储在槽 0注意
uint8
是最小的solidity类型,因此打包不能小于1字节(8位)
下图显示了槽 0 中的 32 字节数据如何保存所有 4 个变量。
现在我们了解了存储的数据结构和槽打包的概念,来快速看一下2个存储操作码SSTORE
和SLOAD
。
我们从 SSTORE
开始,它从调用栈中获取一个 32 字节的键和一个 32 字节的值,并将该 32 字节的值存储在该 32 字节的键的位置。查看如下EVM 游乐场,了解它是如何工作的。
SLOAD
,从调用栈中获取一个 32 字节的键,并将存储在该 32 字节键位置的 32 字节值压入调用栈。查看如下EVM 游乐场,了解它是如何工作的。
在这里你应该问自己的问题是,如果 SSTORE<span> </span>
和 SLOAD
只会处理 32 字节的值,你要如何提取出已打包到一个 32 字节槽中的一个变量。
以上面的示例为例,当我们在槽 0 上运行 SLOAD
时,我们将获得存储在该位置的完整 32 字节值。该值将包括 value1
、value2
、value3
和 value4
的数据。EVM 要如何提取 32 字节槽中的特定字节以返回我们需要的值呢?
当我们运行 SSTORE
时也是同样,如果我们每次都存储 32 个字节,那么EVM 如何确保当我们存储 value2
时它不会覆盖 value1
?当我们存储 value3
时,它不会覆盖 value2
?等等。
这些就是我们接下来要回答的问题。
下面是一个简单的合约,模仿了我们上面看到的例子。唯一的补充是一个store
函数,它设置变量值并且必须读取一个变量来执行一些算术运算。
pragma solidity >=0.7 .0<0.9.0;
/**
* @title Storage
* @dev Store & retrieve value in a variable
*/
contract StorageTwo {
uint32 value1;
uint32 value2;
uint64 value3;
uint128 value4;
function store() public {
value1 = 1 ;
value2 = 22;
value3 = 333;
value4 = 4444;
uint96 value5 = value3 + uint32(666);
}
}
上述 Solidity 中的 store()
函数将执行我们对之有疑问的操作:将多个变量存储在单个槽中而不覆盖现有数据并从 32 字节槽中检索变量的那些特定字节。
让我们从查看槽 0 的结束状态开始,然后从那里向前反推。下面是槽 0 的二进制和十六进制表示。
谨记机器最终会将十六进制数视为二进制数,这很重要,因为在槽打包中使用了许多位运算。
十六进制:
0000000000000000000000000000115c000000000000014d0000001600000001
二进制:
000000000000000000000000000000000000000000000000000000000000000 000000000000000000000000000000000000000000000000000010001010111 000000000000000000000000000000000000000000000000000000000101001 1010000000000000000000000000001011000000000000000000000000000000001
注意你可以看到的十六进制值,0x115c
等于十进制的 4444,0x14d = 333
, 0x16 = 22 & 0x01 = 1
。这些对应于我们在 Solidity 代码中看到的内容。一个槽可容纳 32 个字节的数据,即 64 个十六进制字符或 256 位。
槽打包使用 3 种按位运算,AND
、OR
& NOT
。这些对应于 3 个具有相同命名的 EVM 操作码。让我们快速浏览一下每一个。
下面我们有两个 8 位二进制数。在AND
操作中,第一个数字中的第一个位与第二个数字中的第一个位进行比较。
如果两个值都是 1,则 AND
语句返回 true
,结果的第一位将等于 1,否则语句返回 false
,该位等于 0。
继续进行,第一个数字的第 2 位与我们的第二个数字的第 2 位等进行比较。
在 OR 运算中,只有一个值需要为 1 才能使语句返回<span> </span>true
。输入和上面相同,但得到了完全不同的输出。
NOT
略有不同,它只接受一个值,而不是对两个值进行比较。NOT
对每一位执行逻辑非。为 0 的位变为 1,为 1 的位变为 0。
现在让我们看看在上面的solidity示例中如何使用它们。
我们将专注于solidity 代码的第18行:
value2 = 22;
在这个阶段,一些数据<span> </span>value1
已经存储在槽0 中,我们现在需要将一些额外的数据打包到同一个槽中。
我们在此例中看到的所有逻辑都会与存储value3
和 value4
时相同。我们将看看这是如何在概念上完成的,并将提供一个 EVM 游乐场供你进一步探索。
我们从以下值开始。
0x00000016 = 22 (value2)
0x00 = 0 (槽0)
0x04 = 4 (4 字节输入, value2的开始位置)
0x0100 = 256 (256位在1字节中)
0xffffffff = 4294967295 (二进制的1, 4字节大小)
注意0xffffffff
等于二进制的1111111111111111111111111111111
。
EVM 做的第一件事是使用 EXP
操作码,输入一个基整数和一个指数并返回值。
这里我们使用 0x0100
作为代表 1 个字节的基整数,其指数是 0x04
,即value2
的起始位置。
EXP
操作码产生了值:0x0000000000000000000000000000000000000000000000000000000100000000
如果将这个值乘以value2
的值,将在32字节槽中正确的位置得到我们想要的值0x016
:0x0000000000000000000000000000000000000000000000000000001600000000
我们可以看到 EXP
函数的结果使我们能够在正确的位置插入数据。
但是我们不能使用它,因为它会覆盖已经存储的 value1
。这就是要使用位掩码 的地方了。
EXP
操作码产生了值:0x0000000000000000000000000000000000000000000000000000000100000000
如果用0xffffffffff
乘以这个值就得到了一个在value2
的4个字节的位置的一个位掩码:
0x000000000000000000000000000000000000000000000000fffffffff00000000
在这个值上进行按位NOT
,得到除了value2
的4个字节的位置之外所有位置的位掩码:
0xffffffffffffffffffffffffffffffffffffffffffffffff00000000ffffffff
在槽0使用SSLOAD
,将返回:(注意value1
在返回值中出现)
0x0000000000000000000000000000000000000000000000000000000000000001
对槽0中的数据和位掩码使用AND
,将返回在赋值给value2
的4个字节之外的数据,在value2
的4个字节位置的数据返回0:0x0000000000000000000000000000000000000000000000000000000000000001
上面显示了如何使用位掩码从槽中获取所有数据,但要覆盖的字节除外。在本例中,value2<span> </span>
的字节已经设置为 0,但是如果没有设置,我们会看到这些数据被擦除。
下面是另一个具体说明正在发生的事情的例子。这是相同的过程,但这里所有 4 个值都已被存储了,我们希望将 value2
从 22 更新为 99 ,我们想看看会发生什么。注意现有的 0x016
值被清零。
位掩码:0xffffffffffffffffffffffffffffffffffffffffffffffff00000000ffffffff
在所用值被存储后在槽0上使用SSLOAD
:0x0000000000000000000000000000115c000000000000014d0000001600000001
对槽0中的数据和位掩码使用AND
:0x0000000000000000000000000000115c000000000000014d0000000000000001
你可能已经在想按位OR
可以如何帮助我们组合我们有的值。如下展示了下面的步骤:
Value2
:0x00000016
4 字节 0xffffffff
,二进制全1串:0xffffffff
对value2
和0xffffffff
进行按位AND
操作确保了如果提供的值大于4字节将会被截断:0x00000016
EXP
操作码生成值:0x0000000000000000000000000000000000000000000000000000000100000000
将此值和value2
相乘,将在32字节槽中的正确位置上得到想要的值0x016
:
0x0000000000000000000000000000000000000000000000000000001600000000
使用之前位掩码一节的结果:
0x0000000000000000000000000000000000000000000000000000000000000001
在之前的两个值上使用按位OR
,将在特定位置存储value2
,与此同时维持槽0中已经存入的值:0x0000000000000000000000000000000000000000000000000000001600000001
我们现在知道如何可以在槽 0 的这个 32 字节值上使用 SSTORE
了,其中包含处于正确字节位置的 value1
和 value2
的数据。
对于检索,我们将关注solidity的第22行:
uint96 value5 = value3 + uint32(666)
我们对算术本身不感兴趣,对如何检索出value3
以执行计算感兴趣。
我们有一组略有不同的起始值。
0x00 = 0(槽0)
0x08 = 8 (8 字节输入, value3的起始位置)
0x0100 = 256 (256位在1字节中)
0xffffffffffffffff = 18446744073709551615 (二进制的1, 8字节大小)
0x0000000000000000000000000000115c000000000000014d0000001600000001 = 槽0的值
我们已经看到的大部分内容,将重新用于检索,只有一些修改。
0x0100
和 0x08<span> </span>
上使用EXP
操作码将生成:0x0000000000000000000000000000000000000000000000010000000000000000
在存储槽0SSLOAD
:0x0000000000000000000000000000115c000000000000014d0000000000000001
DIV
操作码用EXP
值除以槽0的值,这在效果上截断了value1
和value2
的低8字节:0x00000000000000000000000000000000000000000000115c000000000000014d
对前8字节的位掩码:0x000000000000000000000000000000000000000000000000ffffffffffffffff
对此位掩码和从DIV
有效截断清零前16字节的返回值进行 AND
操作,返回变量value3
的8字节:0x000000000000000000000000000000000000000000000000000000000000014d
我们从打包的槽 0 中检索到了 value3
。十六进制 0x14d
等于 333,这正是我们在上面的 solidity 代码中设置的。
位掩码和按位运算再一次被用于帮助从 32 字节槽中提取特定字节。这个值现在在栈上,EVM 可以使用它来计算value3 + uint32(666)
。
我把我们刚刚探索的store()
函数中执行的所有操作码放入了EVM 游乐场。在这里,你将能够以交互方式使用所使用的操作码,并查看调用栈和合约存储在你跳转时如何变化。
我已经在我们探索的 2 个部分(solidity 第 18 和 22行)的操作码旁边留下了注释。强烈建议检查一下并单步运行一下操作码,这将大大增强你的理解。
你现在应该深入了解了存储槽打包的工作原理以及 EVM 如何能够在 32 字节槽的特定位置检索和存储字节。尽管 EVM 操作码SLOAD
和<span> </span>SSTORE
仅处理 32 字节块,但我们可以使用按位操作和位掩码来存储和加载我们想要的数据。
希望你喜欢这篇文章。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!