solidity学习笔记05

存储中状态变量的存储结构

合约的状态变量以一种紧凑的方式存储,除了动态大小的数组和映射之外,数据是被逐项存储的,从第一个状态变量开始,它被存储在槽 0 中。对于每个变量,根据它的类型确定一个字节的大小。如果可能的话,少于32字节的多个连续变量将被打包到同一个存储槽中,根据以下规则:

  • 存储插槽的第一项会以低位对齐(即右对齐)的方式储存。

  • 值类型只使用存储它们所需的字节数。

  • 如果一个值类型不适合一个存储槽的剩余部分,它将被存储在下一个存储槽。

  • 结构和数组数据总是从一个新的存储槽开始,它们的项根据这些规则被紧密地打包。

  • 结构或数组数据之后的变量总是开辟一个新的存储槽。

  • 结构体和数组中的元素都是顺序存储的,就像它们被明确给定的那样

对于使用继承的合约,状态变量的排序是从最基础的合约开始,由合约的C3线性化顺序决定的。来自不同合约的状态变量有可能共享同一个存储槽。所以uint128, uint128, uint256的定义顺序要比uint128, uint256, uint128更好。

当使用小于32字节的元素时,合约的燃料用量可能会更高。 这是因为EVM每次对32字节进行操作。因此如果元素小于这个大小, EVM必须使用更多的操作,以便将元素的大小从32字节减少到所需的大小。

由于映射和动态数组的大小是不可预知的,所以不能被存储在其前后的状态变量之间。认为映射和动态数组只占用32个字节,也就是一个插槽,假设插槽编号是p,则映射和动态数组的元素实际被存储在一个用Keccak-256(p)哈希计算的存储槽中。

也就是说插槽p只是在主插槽中占个位置,然后其插槽编号作为盐值进行哈希计算,算出映射和动态数组的元素实际存储的位置,这个位置很远,远到几乎不会出现存储位置碰撞!

为什么说Keccak-256(p)计算出来的位置几乎不会碰撞?

keccak256 生成的是一个 256 位(256-bit) 的数值。这意味着存储插槽的总数是2^256也就是1.15*10^77,而宇宙中的原子总数大约是 10^80,可以说碰撞的概率微乎其微。

如果存储位置无限远,那么是否意味着主插槽到无限远的插槽中间要浪费很多存储空间?占用很多存储空间呢?

并不是,插槽号只是一个逻辑上的地址或者说房间号,并不对应实际磁盘的存储位置,只有真正往某个“房间号”里写数据(执行 SSTORE)时,以太坊底层的数据库(通常是 LevelDB 或 RocksDB)才会真正分配磁盘空间来记录:{键: 房间号, 值: 数据}。

主插槽到无限远的插槽中间的很多空间,它在底层数据库里只是不存在的记录。数据库底层只会存两条记录,这两条记录之间并没有“空的磁盘空间”,它们在数据库文件里是紧挨着存放的。

数据库(LevelDB/RocksDB)和 MPT

简单来说:数据库(LevelDB/RocksDB)是“仓库”,而 MPT 是“账本索引”。以太坊客户端软件(如 Geth)在逻辑层实现了 MPT,然后把 MPT 的每一个“节点”作为一条数据存进数据库里的。

为什么不直接用数据库,非要套一层 MPT?

如果只用数据库(键值对存储),确实可以很快地查到某个账户有多少钱。但区块链有一个特殊的需求:“证明”和“共识”。

轻客户端验证:如果手机上只有一个“区块头”(只有几十字节),如何相信别人发给你的“你有 100 个 ETH”的数据是真的?

状态根(State Root):MPT 将几千万个账户的状态,通过层层哈希,最终压缩成一个 32 字节的根哈希(Root Hash)。只要这个根哈希对得上,就能证明底层任何一个微小数据的真实性。

防篡改:如果有人改了数据库里某个余额。那么它对应的 MPT 叶子节点哈希会变,导致路径上所有的节点哈希全变,最后根哈希也变了。全网节点一对比根哈希,立刻就知道这台机器的数据坏了。

MPT 是如何存进数据库的?

数据库(LevelDB)本质上是一个简单的 Key -> Value 存储。以太坊把 MPT 拆散了存进去:Key是 MPT 节点的哈希值,Value是MPT 节点的内容(比如它的子节点是谁,或者它存的余额是多少)。

当查找一个账户余额时,过程是这样的:

  1. 从区块头拿到 State Root(这是第一个 Key)。

  2. 去 LevelDB 里查Key,得到根节点的内容。

  3. 根据账户地址(比如 0xabc…)作为导航,找到下一个子节点的哈希(第二个 Key)。

  4. 再去 LevelDB 里查第二个 Key……,层层向下,直到找到叶子节点。

为什么MPT不像MYSQL和B+树一样也集成到DB底座

MYSQL底层存储用B+树结构,而MPT和DB存储分离,MPT算法在以太坊客户端,客户端把Account -> Balance 变成了 MPT 树,然后把这棵树的几百万个节点分别存进 LevelDB。这个MPT树很高,而且节点的哈希值是随机的,每个节点都依附前面的节点的哈希值,如果修改一个余额,就要新存入从叶子到根的所有路径节点,查询和修改代价都很大。

内存中的存储结构

与 Storage(永久存储在硬盘,按 Slot 0, 1, 2 排队)不同,Memory 是合约在单次交易执行过程中使用的运行内存。为了让内存管理既简单又高效,Solidity 强制预留了最开头的 128 字节(即 4 个 32 字节插槽)作为特殊用途。

  1. 0x00 - 0x3f (64 字节):临时空间,前两个插槽

主要用于计算哈希(keccak256)。当你需要对一些小数据进行哈希运算时,Solidity 会把数据临时放在这里,然后调用哈希指令。

因为它不保证数据的持久性。上一个语句可能刚在这里存了数,下一个语句(比如汇编代码)可能就会把它覆盖掉。

  1. 0x40 - 0x5f (32 字节):空闲内存指针,内存管理的核心

它就像一个“记事本”,记录了当前内存已经用到哪了。

合约刚启动时,这个位置存的值是 0x80(即 128)。当创建一个动态数组(比如 new uint)时,Solidity 会看一眼 0x40 里的值,把数组放在那里,然后把 0x40 里的值调大。

  1. 0x60 - 0x7f (32 字节):零值插槽 (Zero Slot)

这个插槽永远存的是 0。当需要初始化一个空的动态数组时,Solidity 不会去真的分配内存,而是直接让它指向这个 0x60 位置。

禁忌: 绝对不能往这里写数据。如果汇编代码弄脏了这个插槽,整个合约的初始化逻辑就会乱套!

Solidity内存只增不减,当函数执行完毕,或者某个对象不再使用了,Solidity 不会回收内存,没有垃圾回收机制 GC。

所以只要不断创建新对象,空闲内存指针就一直往后移。这意味着在同一个交易中,如果反复创建大的临时数组,内存消耗会越来越大,Gas 费也会因为内存扩展而变得越来越贵!

calldata调用数据的存储结构

一个函数调用的输入数据的格式被认为会遵循 ABI规范 所定义的格式。 其中,ABI规范要求参数被填充为32字节的倍数。而内部函数调用会使用不同规则。

合约的构造函数的参数直接附加在合约的字节码末尾,也是ABI编码的。构造函数将通过一个硬编码的偏移量来访问它们, 而不是通过使用 codesize 操作码,因为在向代码追加数据时它会发生改变。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计