存储布局

了解以太坊存储布局有什么用

在讨论这个问题前我们要知道以太坊存储布局是什么。以太坊储存布局是指合约状态变量或数据在EVM storage中存储的方式。

了解以太坊的存储布局我们能够:

  • 对于未使用public,我们也能够获得其中的数据值
  • 优化存储以减少gas开销
  • 使用内联汇编直接操作storage
  • 理解溢出,变量覆盖原理

以太坊存储-Storage

在EVM深入中我们了解到以太坊通过key-value方式去存储数据的,读取数据也是从KV数据库中进行的。Storage中key和value大小都为32 byte,存储的方式与value字节大小相关。对于不同类型的值(值类型或引用类型),其存储方式也不同。

security_1_1

像映射和动态数组这种不知道其大小的类型,其存储过程要比值类型更加复杂

存储规则

合约的状态变量以一种紧凑的方式存储在区块链存储中,以这样的方式,有时多个值会使用同一个slot。 除了动态大小的数组和映射mapping ,数据(值类型数据)的存储方式是从位置 0 开始连续放置在存储storage 中。 对于每个变量,根据其类型确定字节大小。

插槽(slot)是以太坊key-value的一个存储槽单位。

基本存储规则

  • 存储插槽(storage slot) 的第一项会以低位对齐的方式储存。
  • 值类型仅使用存储它们所需的字节。
  • 如果存储插槽中的剩余空间不足以储存一个值类型,那么它会被存入下一个存储插槽。
  • 结构体(struct)和数组数据总是会开启一个新插槽(但结构体或数组中的各元素,则按规则紧密打包)。
  • 结构体和数组之后的数据也或开启一个新插槽。

对于使用继承的合约,状态变量的顺序由没有任何其他合约依赖的合约开始的 C3 线性顺序(C3-linearized order)决定。如果上述规则允许的话,不同合约的状态变量共享同一个存储槽。结构体和数组的元素依次存储,就好像它们是独立的值一样。

大端对齐(低位对齐)

这里先复习一下计算机组成的知识,大端对齐(低位对齐)和小端对齐(高位对齐)。一般把数据左端称为高位,右端称为低位。slot插槽使用的是大端对齐(低位对齐)

​ 高内存地址放整数的高位,低内存地址放整数的低位,这种方式叫倒着放,术语叫小端对齐。

​ 高内存地址放整数的低位,低内存地址放整数的高位,这种方式叫正着放,术语叫大端对齐。

security_1_2

紧凑存储

solidity中部分值类型的存储是确定的,比如,uint1~uint256类型、bool类型。这些值都不会超过32 byte。在发现使用的存储没有超过32 byte时,以太坊会使用紧凑存储

紧凑存储:以太坊为了节省存储量,会将小于32字节变量类型的存储和后面的字段尽可能在一个slot中存储。

我们可以通过代码和remix调试器分析紧凑存储中数据在storage中的布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
contract A{
uint32 v1;
uint128 v2;
uint256 v3;
bool v4;

function set(uint8 i) public {
v1 = i;
v2 = i + 1;
v3 = i + 2;
v4 = true;
}
}

通过上面代码,我们根据存储基本规则,v1和v2会被存储到同一slot,v3、v4独占一个存储slot。

security_1_3

调用set函数(输入i=1),使用调试器跳到函数最后一步,能看到storage中key-value分布和上图展示一致。

  • key=0,slot的左端8字节存储v1,7~23字节存储v2
  • key=1,slot全部用于存储v3
  • key=2,slot的左端1字节存储v4

security_1_4

紧凑存储原则能够有效降低存储占用,如果能有效利用该特性,就能够存储消耗的gas。比如将上面代码中v3和v4位置互换就能降低存储时消耗gas(可以在remix中尝试)。

动态数组和映射类型的存储规则

当数据大小不可预知时,比如:动态数组和映射类型,它们的存储位置是通过Keccak256 哈希计算来确定。

假设:映射或动态数组根据上面基本存储规则最终可确定某个位置 p

  1. 对于动态数组,此插槽中会存储数组中元素的数量(字节数组和字符串除外)。
  2. 对于映射,该插槽未被使用(为空),但它仍是需要的,以确保两个彼此挨着 映射,他们的内容在不同的位置上

动态数组存储

动态数组存在字符串和bytes这样特殊的动态数组,它们存储方式与动态数组有点差异,先分析字符串类型的存储方式。

字符串

字符串和bytes是特殊的动态数组,它们在存储时会有特殊的方式。

  • 如果字符串或bytes的长度小于31byte,其数据和长度会共用一个slot(p),且长度计算等于len * 2。
  • 将数据长度单独存储在slot(p)中,存储的value等于len * 2 + 1。数组中数据存储在keccak256(p)中,下一个数据的存储key等于上一个数据key+1

solidity中使用utf-8对string进行编码,中文需要占用3个字节

1
2
3
4
contract A{
string public s1 = unicode"短字符串";
string public s2 = unicode"一个字符串类型,计算我的插槽位置在哪里";
}

上面代码中,s1的长度和s1的数据共用一个slot。而s2的长度大于32字节,其长度为55 byte。

p为256位

keccak256(p=0x00…00)=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563

keccak256(p=0x00…01)=0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6

也可以使用线上的keccak256工具

security_1_6

使用remix进入调试器,跳到最后一步查看storage状态。和上图分析一致。

security_1_5

动态数组

对于动态数组,数据类型是小于128位使用紧凑存储的方式存储在一个slot中。下面展示一个uint32的动态数组

  • 数据长度存储在slot确定的位置p,计算数组第一个数据位置keccak256(p)。数组下一个数据是上一个数据的key+1
1
2
3
contract B {
uint32[] public v1 = [1,2,3,4,5,2,1,2,4,1];
}

其大小为0x0a(10),数据(10 * 32 = 320 < 256 * 2)会占用两个slot。计算keccak256(p=0) = 0x290decd…63

securit_1_7

如何确定 x[i][j] 元素的位置,其中 x 的类型是 uint24[][],计算方法如下(假设 x``本身存储在槽 ``p): 槽位于 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)) 且可以从槽数据 v``得到元素内容,使用 ``(v >> ((j % floor(256 / 24)) * 24)) & type(uint24).max.

映射存储

字典的存储布局是直接存储 Key 对应的 value,每个 Key 对应一份存储。

映射mapping 中的键 k 所对应的槽会位于 keccak256(h(k) . p) ,其中 . 是连接符, h 是一个函数,根据键的类型:

  • 值类型, h 与在内存中存储值的方式相同的方式将值填充为32字节。
  • 对于字符串和字节数组, h(k) 只是未填充的数据。
1
2
3
4
5
6
contract C{
mapping (uint256 => uint256) public bal;
function set(uint256 k, uint256 v) public {
bal[k] = v;
}
}

首先使用set向mapping中存储数据。根据上述规则,映射存储规则与下图计算结果一致。

笔者使用的参数:0,8、1,1、2,2、3,3

security_1_8

然后使用调试器跳到最后一步查看storage中存储情况。

security_1_9

实践-计算数据位置

实际编写合约场景中,合约数据类型可能会更加复杂,会存在多个值类型或引用类型的组合。但我们都可以通过以上规则计算每个数据对应的slot位置。

类似下图中合约的组合型的数据,我们可以逐步进行分析。

security_1_10

在进行实践时我们可以搭建本地node环境,自己编写一个合约进行验证,下面编写了几个简单的脚本去计算数据位置。

启动本地节点

1
2
3
4
cd contract
npm init -y
npm install --save-dev hardhat #下载hardhat
npx hardhat node #启动节点

可以在hardhat中使用以下命令部署合约。需要编写部署脚本。但是调用函数需要另外一些脚本。

1
npx hardhat run --network localhost scripts/deploy.js

更简单的是通过remix连接到本地网络部署合约。调用函数也更简单。

脚本

下载ethers.js v6。

1
2
3
4
cd js
npm init -y
npm install -s ethers
touch getStorage.js

在创建的文件下编写脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ethers = require("ethers")

const provider = new ethers.JsonRpcProvider('http://localhost:8545');
// 合约地址
const addressBridge = 'address'
// 合约所有者 slot
const slot = "slot_post"

const main = async () => {
const privateData = await provider.getStorage(addressBridge, slot)
console.log("读出数据:", privateData)
}

main()

引用:

  1. 在windows下详解:大端对齐和小端对齐 - 黑泽君 - 博客园 (cnblogs.com)

  2. 状态变量在储存中的布局 — Solidity中文文档 — 登链社区 (learnblockchain.cn)

  3. CUIT靶场题库