本文深入探讨了以太坊架构中合约存储的实现,详细分析了以太坊区块的数据结构,以及如何通过Geth客户端查看合约存储的内部机制。文章详细解释了区块头、状态根和存储根的关系,并介绍了SSTORE和SLOAD操作码在Geth中的实现,帮助读者更好理解EVM和智能合约的存储机制。
这是“EVM 深入探索”系列的第四篇文章。在 第三部分中,我们对合约存储进行了深入探讨,现在我想向你展示单个合约的存储如何融入以太坊链的更广泛的“世界状态”中。
为此,我们将检查以太坊链架构、其数据结构,并深入“Go Ethereum”(Geth)客户端的内部。
我们将首先查看以太坊区块中包含的数据,并逆向追溯到具体合约的存储。最后,我们将研究 Geth 中 SSTORE 和 SLOAD 操作码的实现。
这将有多个目的。它将让你了解 Geth 代码库,教你以太坊“世界状态”,并加深你对 EVM 的整体理解。
订阅
我们将从下面的图像开始。不要感到害怕,到本文结束时你将完全理解这一切是如何契合在一起的。这表示以太坊架构和以太坊链中包含的数据。
以太坊架构 - 来源 Zanzu
我们将逐部分分析该图,而不是整体查看。现在,让我们关注“区块 N 头”及其包含的字段。
区块头包含关于以太坊区块的关键信息。下面是“区块 N 头”的片段及其数据字段。查看这个区块 14698834,看看你是否能在图中看到一些字段。
以太坊架构图中的区块 N 头
区块头包含以下字段:
Prev Hash - 父区块的 Keccak 哈希
Nonce - 在工作量证明计算中使用
Timestamp - UNIX 时间的输出的比例值
Uncles Hash - 举栋区块的 Keccak 哈希
Beneficiary - 收益地址,挖矿费用接收者
LogsBloom - 两个字段的布隆过滤器,日志地址和收据中的日志主题
Difficulty - 上一个区块的难度的标量值
Extra Data - 与此区块相关的 32 字节数据
Block Num - 祖先区块数量的标量值
Gas Limit - 当前每个区块气体使用限制的标量值
Gas Used - 本区块中在交易上花费的总气体的标量值
Mix Hash - 与 nonce 一起使用以证明工作量证明计算的 256 位值
State Root - 状态 trie(执行后)根节点的 Keccak 哈希
Transaction Root - 交易 trie 根节点的 Keccak 哈希
Receipt Root - 收据 trie 根节点的 Keccak 哈希
让我们看看这些字段如何映射到 Geth 客户端代码库中的内容。我们将查看在 block.go 中定义的“Header”结构,它表示一个区块头。
代码在 go-ethereum/core/types/block.go
我们可以看到代码库中的值与我们的概念图一致。我们的目标是从区块头到达单个合约的存储。
为此,我们需要关注红色高亮的区块头的“State Root”字段。
“状态根”类似于一个 merkle root,因为它是一个哈希,依赖于下方的所有数据。如果任何数据发生变化,根也会改变。
“状态根”下的数据结构是 Merkle Patricia Trie,其中存储网络上每个以太坊帐户的键值对,键是以太坊地址,而值是以太坊帐户对象。
实际上,键是以太坊地址的哈希,值是 RLP 编码的以太坊帐户,但我们暂时可以忽略这一点。
下面是“以太坊架构”图的部分,表示“状态根”的 Merkel Patricia Trie。
状态根下的 Merkle Patricia Trie - 来自以太坊架构图的片段
Merkle Patricia Trie 是一个复杂的数据结构,所以我们在这篇文章中不会深入探讨,而是可以继续保留地址到以太坊帐户的键值映射模型。
如果你对 Merkle Patricia Trie 感兴趣,我推荐查看这篇 优秀的入门文章。
接下来,让我们检查以太坊地址映射到的以太坊帐户值。
以太坊帐户是以太坊地址的共识表示,由 4 个部分组成。
Nonce - 该帐户进行过的交易数量
Balance - 以 Wei 计的帐户余额
Code Hash - 存储在合约/帐户中的字节码的哈希
Storage Root - 存储 trie(执行后)根节点的 Keccak 哈希
我们在原始以太坊架构图的这个片段中看到了这些内容。
以太坊帐户及其字段 - 来自以太坊架构图的片段
同样,我们可以跳入 Geth 代码库,找到相应的文件 state_account.go 和定义“以太坊帐户”的结构,称为 StateAccount。
代码在 go-ethereum/core/types/state_account.go
我们再次看到代码库中的值与我们概念图一致。
接下来,我们需要放大以太坊账户中的“Storage Root”字段。
存储根与状态根非常相似,下面是另一个 Merkle Patricia trie。
不同之处在于,此时键是存储槽,值是每个槽中的数据。
同样,实际上,值的 RLP 编码和键的哈希在这个过程中发生。
下面是“以太坊架构”图的部分,表示“存储根”的 Merkel Patricia Trie。
存储根下的 Merkle Patricia Trie - 嵌入自以太坊架构图的片段
与之前一样,“存储根”是一个 merkle root 哈希,如果任何底层数据(合约存储)发生变化,它将受到影响。
合约存储的任何变化都将影响“存储根”,进而影响“状态根”,从而影响“区块头”。
在本文的这个阶段,我们已经实现了我们的目标,从以太坊区块到达单个合约的存储。
文章的下一部分将深入 Geth 代码库。我们将简要查看合约存储是如何初始化的,以及当调用 SSTORE 和 SLOAD 操作码时会发生什么。
这将帮助你在我们迄今为止讨论的内容和你的 Solidity 代码及其底层存储操作码之间建立心智联系。
警告,下一个部分的内容代码较多,并假定有阅读代码的能力
为了开始,我们需要一个全新的合约。一个全新的合约意味着一个全新的 StateAccount。
在开始之前,我们将与 3 个结构进行交互:
StateAccount
stateObject
StateDB
让我们看看这3项如何相互关联,以及它们如何与我们一直在讨论的内容相关联。
StateDB → stateObject → StateAccount
StateDB struct,我们可以看到它有一个 stateObjects 字段,这是地址到 stateObjects 的映射(记住“状态根” Merkle Patricia Trie 是以太坊地址到以太坊账户的映射,而且 stateObject 是一个正在被修改的以太坊账户)
stateObject struct,我们可以看到它有一个数据字段,该字段的类型为 StateAccount(记住在文章早些时候我们将以太坊账户映射到 Geth 中的 StateAccount)
StateAccount struct,我们已经看到过这个结构,它表示一个以太坊账户,Root 字段表示我们之前讨论的“存储根”
在这个阶段,拼图的某些部分开始契合在一起。现在我们有了上下文来查看如何初始化一个新的“以太坊账户”(StateAccount)。
要创建一个新的 StateAccount,我们需要与 statedb.go 文件和 StateDB 结构进行交互。
StateDB 有一个 createObject 函数,该函数创建一个新的 stateObject 并传递一个空的 StateAccount。实际上这是在创建一个空的“以太坊账户”。
下面的图详细说明了代码流。
StateAccount 初始化
StateDB 有一个 createObject function,该函数接受一个以太坊地址并返回一个 stateObject(记住 stateObject 表示一个正在被修改的以太坊账户)
createObject 函数调用 newObject function,传入 stateDB、地址和一个空的 StateAccount(记住 StateAccount = 以太坊账户),它返回一个 stateObject。
newObject 函数的返回语句中,我们可以看到与 stateObject 相关的一些字段,地址、数据、dirtyStorage 等等。
stateObject 数据字段映射到函数中的空 StateAccount 输入 - 请注意 nil 值在第 103 - 111 行替换为 StateAccount 中的值。
创建的 stateObject 包含初始化的 StateAccount 作为数据字段并被返回。
好的,我们有了一个空的 stateAccount,接下来我们想做什么?
我们想存储一些数据,为此我们需要使用 SSTORE 操作码。
在我们深入 Geth 中 SSTORE 的实现之前,让我们快速提醒一下 SSTORE 的作用。
它从栈中弹出 2 个值,第一个是 32 字节键,第二个是 32 字节值,并将该值存储在由键定义的指定存储槽中。
下面是 Geth 中 SSTORE 操作码的代码流程,让我们看看它的作用。
SSTORE 操作码 Geth 实现
我们从 instructions.go 文件 开始,该文件定义了所有 EVM 操作码。在此文件中,我们找到了“opSstore”函数。
传入函数的作用域变量包含合约上下文,如栈、内存等。我们从栈中弹出 2 个值,并将它们标记为 loc(位置的缩写)和 val(值的缩写)。
从栈中弹出的 2 个值随后作为输入与合约地址一起用于与 StateDB 关联的 SetState 函数。SetState 函数使用合约地址检查该合约是否存在 stateObject,如果不存在,它将创建一个。然后,它在该 stateObject 上调用 SetState,传入 StateDB db、键和值。
stateObject 的 SetState 函数 会对假存储和值是否改变进行一些检查,然后执行日志追加。
如果你查看关于 journal struct 的代码注释,你会发现日志用于跟踪状态修改,以便在执行异常或请求回退时可以被恢复。
更新日志后,storageObject 的 setState 函数 被调用,传入键和值。这会更新 storageObjects 的 dirtyStorage。
好的,我们已将 stateObject 的 dirtyStorage 更新为键和值。这实际上意味着什么,它与我们迄今为止学习的内容有什么关系。
让我们从代码中开始 dirtyStorage 的定义。
dirtyStorage → 存储 → 哈希 → 32 字节
dirtyStorage 在 stateObject struct 中定义,它是 Storage 类型,描述为“当前事务执行中已修改的存储项”
对应于 dirtyStorage 的 Storage 类型 是一个简单的 common.Hash 到 common.Hash 的映射
Hash 类型 只是一种长度为 HashLength 的字节数组
HashLength 是一个定义为 32 的常量
这应该你会熟悉,一个 32 字节键映射到一个 32 字节值。这正是我们在 EVM 深入探索第三部分 中在概念上查看合约存储的方式。
你可能注意到 pendingStorage 和 originStorage 在 stateObject 中就在 dirtyStorage 字段上方。它们都是相关的,在终结时 dirtyStorage 会复制到 pendingStorage,而 pendingStorage 会在 trie 更新时复制到 originStorage。
在 trie 更新后,StateAccount 的“存储根”也将在 StateDB “提交”期间更新。这会将新状态写入底层的内存 trie 数据库。
现在进入拼图的最后一块,SLOAD。
再让我们快速提醒一下 SLOAD 的作用。
它从栈中弹出 1 个值,32 字节键,表示存储槽,并返回存储在该处的 32 字节值。
下面是 Geth 中 SLOAD 操作码的代码流程,让我们看看它的作用
SLOAD 操作码 Geth 实现
同样,我们从 instructions.go 文件 开始,在该文件中我们可以找到“opSload”函数。我们使用 peek 从栈顶获取 SLOAD 的位置(存储槽)。
我们在 StateDB 上调用 GetState 函数,传入合约地址和存储位置。GetState 获取与该合约地址相关的 stateObject。如果 stateObject 不为 nil,则它会在该 stateObject 上调用 GetState。
stateObject 的 GetState 函数 会对假存储和 dirtyStorage 进行检查。
如果 dirtyStorage 存在,则在 dirtyStorage 映射中返回该键位置处的值。(dirtyStorage 代表合约的最新状态,这就是为什么我们首先尝试返回它)
否则调用 GetCommitedState 函数 在存储 trie 中查找该值。同样,对假存储进行检查。
如果 pendingStorage 存在,则在 pendingStorage 映射中返回该键位置处的值。
如果所有以上情况都未返回,则转到 originStorage 并检索并返回那里的值。
你会注意到该函数尝试首先返回 dirtyStorage,然后是 pendingStorage,然后是 originStorage。这是有道理的,在执行期间,dirtyStorage 是最接近最新的存储映射,其次是 pending,最后是 originStorage。
单个交易可以多次操作同一个存储槽,因此我们必须确保我们有最新的值。
让我们想象一下,同一槽中的 SSTORE 在同一交易之前发生。在这种情况下,dirtyStorage 将在 SSTORE 中更新,而在 SLOAD 中将返回它。
就这样,你现在已了解 SSTORE 和 SLOAD 如何在 Geth 客户端级别实现。它们与状态和存储对象如何交互以及更新存储槽如何与更广泛的以太坊“世界状态”相关联。
这一切都非常紧凑,但你做到了。我猜这篇文章让你留下了比你开始时更多的问题,但这就是加密的乐趣。
继续努力,匿名者。
noxx
推特 @noxx3xxon
- 原文链接: noxx.substack.com/p/evm-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!