在第 3 部分中,我们将深入探讨合约存储的工作原理,通过提供一些思维模式来帮助理解并深入了解存储插槽包装(slot packing)。
By: Flush
在第 1、2 部分中,我们探讨了 EVM 如何通过被调用的合约函数知道需要运行哪个字节码,了解了调用栈、calldata、函数签名和 EVM 操作码指令,合约的内存以及它在 EVM 上的工作方式。
在第 3 部分中,我们将深入探讨合约存储的工作原理,通过提供一些思维模式来帮助理解并深入了解存储插槽包装(slot packing)。如果对于 slot packing 这个术语感到陌生,不用担心,slot packing 的知识对于 EVM 学习者来说至关重要,你将在这篇文章结束后对它有一个深刻的理解。
如果曾经尝试过 Ethernaut Solidity Wargame 系列 或其他 Solidity CTF 类型的游戏,你就会知道 slot packing 知识通常是破解谜题的关键。
存储基础
在 Program the Blockchain 这篇文章中,对存储基础知识进行了高度概括,建议可以先阅读这篇文章。
我们将从合约存储的数据结构开始学习,为我们学习其他知识提供一个坚实的基础。
合约存储是一个简单的键值的映射。它将一个 32 字节的 key 映射到一个 32 字节的 value。鉴于我们的 key 是 32 字节大小,我们最多可以有(2^256)- 1 个 key。
所有的值都被初始化为 0,0 没有被明确地存储。因为 2^256 大约是已知的、可观测的宇宙中的原子数量。没有一台计算机可以容纳这么多的数据。这也是将一个存储值设置为零的原因,因为该键值不再需要由网络上的节点存储,所以可以退还一些 gas。
从概念上讲,存储可以被看作是一个天文数字的大阵列。我们的第一个二进制值为 0 的 key 代表阵列中的第 0 项,二进制值为 1 的 key 代表阵列中的第 1 项,等等。
被声明为存储变量的合约变量可以分为定长和不定长两种。我们将专注于定长变量,以及 EVM 如何将多个变量装入一个 32 字节的插槽中。要了解更多关于不定长变量,可以看 “Program the Blockchain” 的文章。
现在我们知道存储是一种键值映射,接下来的问题是知道如何将 key 分配给 value。假设我们有以下的代码:
contract StorageTest {
uint256 value1;
uint256[2] value2;
uint256 value3;
}contract StorageTest { uint256 value1; uint256[2] value2; uint256 value3;}
鉴于这些都是定长变量,EVM 可以使用保留的存储位置(keys),从插槽 0(key 的二进制值为 0)开始,线性地向前移动到插槽 1、2 等。这是将根据变量在合同中的声明顺序来做的。第一个声明的存储变量将被存储在槽位 0。在这个例子中,插槽 0 将存放变量 "value1",变量 "value2" 是一个定长为 2 的数组,所以将占用插槽 1 和插槽 2,最后,插槽 3 将存放变量 "value3"。
接下来我们再来看另一个类似的合约,了解一下它的变量是如何存储的。
contract StorageTest {
uint32 value1;
uint32 value2;
uint64 value3;
Uint128 value4;
}
你可能会认为这和前面的例子是一样的,需要占用 0 到 3 个插槽位。在上一个例子中,我们有 4 个值需要存储(其中存在一个大小为 2 的数组),在当前这个例子中我们有 4 个值需要存储。然而你会惊讶地发现,在这个例子中只使用了存储插槽 0,其关键的区别在于用于变量的单位类型。之前所有的变量都是 uint256 类型,代表 32 个字节的数据。这里我们使用 uint32、uint64 和 uint128,分别代表 4、8 和 16 字节的数据。
这就是插槽包装(slot packing)的由来。Solidity 编译器知道它可以在一个存储槽中存储 32 字节的数据。因此,当 "uint32 value1",只占 4 个字节,被存储在插槽 0 时,当下一个变量被读入时,编译器会看它是否能被打包到当前的存储插槽。鉴于插槽 0 有 32 个字节的空间,而 value1 只占用了其中的 4 个字节,只要下一个变量的大小小于 28 个字节,它也将被装入插槽 0。在上面的例子中,我们从 0 存储插槽的 32 字节开始。
需要注意的是:uint8 是最小的 Solidity 类型,因此在打包时不能小于 1 字节(8位)。
下图显示了插槽 0 中的 32 字节数据如何保存所有 4 个变量的:
现在我们了解了存储的数据结构和插槽包装的概念,让我们快速看看 2 个存储操作码 SSTORE 和 SLOAD。
1)SSTORE
SSTORE ,它从调用栈中接收一个 32 字节的键和一个 32 字节的值,并将该 32 字节的值存储在该 32 字节的键位置。可以在 EVM PlayPlayground 里查看它的运行原理。
2)SLOAD
SLOAD,它从调用栈中接收一个 32 字节的键,并将存储在该 32 字节的键(key)位置的 32 字节的值(value)推到调用栈。可以在 EVM PlayPlayground(https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-3ea#:~:text=stack.%20Check%20out-,this,-EVM%20playground%20to)里查看它的运行原理。
如果 SSTORE 和 SLOAD 只处理 32 字节的值,那怎么能提取一个已经打包装入 32 字节插槽的变量呢?
如果拿我们上面的例子来说的讲,当我们在插槽 0 上运行 SLOAD 时,我们将得到存储在该位置上全部 32 字节的值。这个值将包括 value1、value2、value3 和 value4 的数据。那 EVM 如何在这个 32 字节的插槽中提取特定的字节来返回我们所需要的值呢?
运行 SSTORE 时也是如此,如果我们每次都存储 32 字节,EVM 如何确保当我们存储 value2 时不会覆盖 value1。当我们存储 value3 时,它不会覆盖 value2 等等。
接下来让我们一起寻找答案。
下面用一个合约来仿照之前给出的例子。合约中我们增加了一个设置变量值的存储函数,它通过读取一个变量来进行一些运算操作。
// SPDX-License-Identifier: GPL-3.0
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);
}
}
合约中的 store() 函数会执行前面我们存在疑问的操作。在一个插槽中存储多个变量而不覆盖现有数据,并从 32 字节的插槽中检索一个变量的特定字节。 让我们先看看插槽 0 的结束状态,下面是它的二进制和十六进制的表示。十六进制数字被机器识别为二进制数字,并且在 slot packing 中使用了一些位操作。
十六进制的 0x115c 十进制表示为 4444,0x14d 为 333,0x16 为 22,0x01 为 1。这些对应于我们在 Solidity 代码中看到的 value 值。一个插槽可以容纳 32 个字节的数据,也就是说 64 个十六进制的字符或 256 位,正好容纳上面的 4 个变量。
1、位运算
Slot packing 使用了 3 个位运算操作:AND、OR 和 NOT。对应于 3 个具有相同命名的 EVM 操作码。
1)AND
如下例。在 AND 操作中,第一个数字的第一位与第二个数字的第一位进行比较。如果两个值都是 1,那么 AND 返回真,结果为 1,反之为 0,以此类推。
2)OR
在 OR 位运算中,只有其中一个值为 1,就返回真,反之为 0。
3)NOT
NOT 略有不同,它只接受一个值,而不是对 2 个数进行比较。NOT 对每个位进行逻辑否定,每位取反。
现在让我们来看看这些位运算操作是如何在上述例子中实际使用的。
插槽操作 - 插槽包装 SSTORE
让我们看到代码的第 18 行。
value2 = 22;
此时 value1 已经被存储在插槽 0 中,我们现在需要将一些额外的数据打包到同一个插槽中,其中 value3 和 value4 存储逻辑相同。我们将从理论上来解释这是如何做到的,并将提供一个 EVM playground 实例进一步探索。
我们从下面的值开始。
0x00000016 = 22 (value2)
0x00 = 0 (slot 0)
0x04 = 4 (4 个字节,value2 的起始位置)
0x0100 = 256 (1 字节的最大值 255 + 1)
0xffffffff = 4294967295 (二进制为 1 的大小为 4 个字节的数)
0xffffffff 等于二进制的 11111111111111111111111111111111。EVM 做的第一件事是使用 EXP 操作码,它接收一个基数整数和一个指数,并返回结果数值。这里 0x0100 作为基整数,代表一个 1 字节的偏移量,指数为 0x04,其是 "value2" 的起始位置。下图显示了为什么返回的值是有用的。(0x100 的 0x04 次幂,也就是 0x100000000)
EXP 操作码产生值
0x0000000000000000000000000000000000000000000000000000000100000000
如果用这个值乘以 value2,就会得到想要的值 0x016 在 32 字节插槽的位置。
0x0000000000000000000000000000000000000000000000000000001600000000
我们可以看到,EXP 函数的返回结果能够在正确的位置(4 字节偏移,插槽从右往左填充,相当于从右到左偏移八位)插入数据 0x16 。然而,我们还暂时不能写入这个值,因为它将覆盖已经被存储的 value1。这就是位掩码发挥的地方了。
EXP 操作码产生值
0x0000000000000000000000000000000000000000000000000000000100000000
如果用这个值乘以 0xffffffff,可以得到 value2 所在的 4 个字节的位掩码。
0x000000000000000000000000000000000000000000000000ffffffff00000000
如果对这个值进行按位非运算,我们会得到一个除了 value2 所在的 4 个字节之外的所有字节的位掩码。
0xffffffffffffffffffffffffffffffffffffffffffffffff00000000ffffffff
在插槽 0 中使用 SSLOAD(注意值 value1 存在于返回的数据中)
0x0000000000000000000000000000000000000000000000000000000000000001
对插槽 0 中的数据和位掩码使用 AND 将返回分配给 value2 的 4 个字节之外的所有数据,并将位于 value2 的 4 个字节中的所有数据清零。
0x0000000000000000000000000000000000000000000000000000000000000001
上面显示了如何利用位掩码从一个插槽中获取除了待写入的数据外的所有的数据。在这个例子中,value2 所用的字节已经被设置为 0,但是如果没有被设置,这些数据就将会被抹去(位掩码:用一串二进制数字(掩码)去操作另一串二进制数字。这里及用 AND 操作码的与运算,通过 0 和其他数想与都为 0 的情况抹去不需要的数据)。
下面是另一个例子通过同样的过程,了解一下如果所有 4 个值都已经被存储,我们把 value2 从 22 更新到 99 会发生,现有的 0x016 值被清零。
位掩码
0xffffffffffffffffffffffffffffffffffffffffffffffff00000000ffffffff
在存储所有值后在插槽 0 上使用 SSLOAD
0x0000000000000000000000000000115c000000000000014d0000001600000001
对插槽 0 中的数据和位掩码使用 AND 将向我们返回分配给 value2 的 4 个字节之外的所有数据,并将位于 value2 的 4 个字节中的所有数据清零
0x0000000000000000000000000000115c000000000000014d0000001600000001
看到这里,你可能已经在考虑按位或运算怎么样帮助组合我们已有的值了。步骤如下:
Value2
0x00000016
0xffffffff 的二进制表示为 4 字节全 1
0xffffffff
对 Value2 和 0xffffffff 进行按位与操作,确保如果提供的值大于4个字节,它将被裁剪下来
0x00000016
EXP 操作码产生值
0x0000000000000000000000000000000000000000000000000000000100000000
如果用这个值乘以 value2 ,我们将在 32 字节插槽中的正确位置得到我们想要的值 0x016
0x0000000000000000000000000000000000000000000000000000001600000000
使用前一个位掩码部分的结果
0x0000000000000000000000000000000000000000000000000000000000000001
对前面的 2 个值使用按位或操作将 value2 存储在指定位置,同时保留已存储在插槽 0 中的所有值
0x0000000000000000000000000000000000000000000000000000001600000001
现在我们可以在插槽 0 的 32 字节值上使用 SSTORE,它包含了 value1 和 value2 在正确字节位置的数据。
3、插槽操作 - 检索一个打包的变量 SLOAD
让我们来看第 22 行:
uint96 value5 = value3 + uint32(666)
实际上我们只需要关心 value3 是怎么被检索到的。下面就是取出 value3 需要的数据的过程,我们用跟上边的不太一样的数据来展示。
0x00 = 0 (slot 0)
0x08 = 8 (8 个字节,value3 的起始位置)
0x0100 = 256 (1 字节的最大值 255 + 1)
0xffffffffffffffff = (二进制全为 1 的大小为 8 个字节的数)
0x0000000000000000000000000000115c000000000000014d0000001600000001 = slot 0 value
可以看到大部分内容将在进行一些修改后被重新用于检索。
0x0100 和 0x08 上的 EXP 操作码产量
0x0000000000000000000000000000000000000000000000010000000000000000
存储插槽 0 上的 SSLOAD
0x0000000000000000000000000000115c000000000000014d0000001600000001
DIV 操作码将插槽 0 的值除以 EXP 的值,这实际上是将 value1 和 value2 所使用的底部 8 个字节修剪掉。
0x00000000000000000000000000000000000000000000115c000000000000014d
前 8 个字节的位掩码
0x000000000000000000000000000000000000000000000000ffffffffffffffff
在此位掩码上使用 AND 和 DIV 的返回值有效地修剪/清零前 16 个字节并返回给8 个字节的变量"value3"
0x000000000000000000000000000000000000000000000000000000000000014d
我们已经从包装的插槽 0 中检索到 value3。十六进制的 0x14d 十进制为 333,这就是我们在代码中所设定的。
位掩码和位操作再次被用来帮助从 32 字节的插槽中提取特定的字节。而这个值现在在栈中,然后可以被 EVM 用来计算 "value3 + uint32(666)"。
刚才探索的 store() 函数中执行的所有操作码都放到了 EVM Playgrpund 中,代码如下:
// --------------------------------
// Solidity Line 17 - "value1 = 1;"
// --------------------------------
PUSH1 0x01
PUSH1 0x00
DUP1
PUSH2 0x0100
EXP
DUP2
SLOAD
DUP2
PUSH4 0xffffffff
MUL
NOT
AND
SWAP1
DUP4
PUSH4 0xffffffff
AND
MUL
OR
SWAP1
SSTORE
POP
// ---------------------------------
// Solidity Line 18 - "value2 = 22;"
// ---------------------------------
PUSH1 0x16 // value2 = 22 decimal = 0x16 in hex
PUSH1 0x00 // slot 0 - storage location for "value2"
PUSH1 0x04 // 4 bytes in - start position for "value2"
PUSH2 0x0100 // 0x100 in hex = 256 in decimal, 256 bits in 1 byte
EXP // exponent of 0x0100 & 0x04 = 0x100000000
DUP2 // duplicate 0x00 to top of stack
SLOAD // load data at slot 0
DUP2 // duplicate exponent of 0x0100 & 0x04 = 0x100000000
PUSH4 0xffffffff // bitmask 4 bytes length
MUL // multiply to get bitmask for the 8 bytes assigned to "value2"
NOT // NOT operation to get bitmask for all bytes except the 8 bytes assigned to "value2"
AND // AND of bitmask and slot 0 value to zero out values in the 8 bytes assigned to "value2" and retain all other values
SWAP1 // bring 0x100000000 to top of the stack
DUP4 // duplicate value2 value = 22 = 0x16
PUSH4 0xffffffff // bitmask 4 bytes length
AND // AND to ensure the value is no more than 4 bytes in length
MUL // returns value2 at the correct position - 4 bytes in
OR // OR with previous value and the value AND yielded on line 38 gives us the 32 bytes that need to be stored
SWAP1 // slot 0 to top of the stack
SSTORE // store the 32 byte value at slot 0
POP // pop 0x16 off the stack
// ----------------------------------
// Solidity Line 19 - "value3 = 333;"
// ----------------------------------
PUSH2 0x014d
PUSH1 0x00
PUSH1 0x08
PUSH2 0x0100
EXP
DUP2
SLOAD
DUP2
PUSH8 0xffffffffffffffff
MUL
NOT
AND
SWAP1
DUP4
PUSH8 0xffffffffffffffff
AND
MUL
OR
SWAP1
SSTORE
POP
// -----------------------------------
// Solidity Line 20 - "value4 = 4444;"
// -----------------------------------
PUSH2 0x115c
PUSH1 0x00
PUSH1 0x10
PUSH2 0x0100
EXP
DUP2
SLOAD
DUP2
PUSH16 0xffffffffffffffffffffffffffffffff
MUL
NOT
AND
SWAP1
DUP4
PUSH16 0xffffffffffffffffffffffffffffffff
AND
MUL
OR
SWAP1
SSTORE
POP
// ----------------------------------------------------------
// Solidity Line 22 - "uint64 value5 = value3 + uint32(666);"
// ----------------------------------------------------------
PUSH1 0x00
PUSH2 0x029a // uint32(666)
PUSH4 0xffffffff // bitmask 4 bytes length
AND // ensure uint32(666) does not exceed 8 bytes, trim if it does
PUSH1 0x00 // slot 0 - location of value3
PUSH1 0x08 // 8 bytes in - start position for "value3"
SWAP1 // bring 0x00 to top of stack for SLOAD of slot 0
SLOAD // load data at slot 0
SWAP1 // bring 0x08 to top of stack for EXP
PUSH2 0x0100 // 256 bits in 1 byte
EXP // exponent of 0x0100 & 0x08 = 0x10000000000000000
SWAP1 // get slot 0 value to top of stack
DIV // DIV of slot 0 value with 0x10000000000000000 remove bottom 8 bytes
PUSH8 0xffffffffffffffff // bitmask 8 bytes length
AND // Zero out bytes outside of the 8 byte mask to return variable "value3"
// To see the rest of the opcodes for this calculation recreate the contract in remix and enter debugging mode
这篇文章我们深入了解存储插槽打包的工作原理,以及 EVM 是如何在 32 字节插槽的特定位置检索和存储字节的。尽管 EVM 的操作码 SLOAD & SSTORE 只处理 32 字节的数据块,但我们可以使用位操作和位掩码来存储和加载我们想要的数据。
本系列的第四部分,我们将一起探寻 Go Ethereum (Geth) 客户端的内部,看看它是如何实现 SSTORE & SLOAD 操作码的,敬请期待。
本文首发于:https://mp.weixin.qq.com/s/A2js34s4CEvkOzDn-likpg 原文:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-3ea?s=r
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!