原文链接:https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-5a5?s=r
这是“深入理解EVM”系列的第四期。在第三期中我们了解了合约存储相关的知识,这期会说明以太链的“世界状态”是怎么容纳单个合约的存储区的。为此我们需要审视以太链的体系结构和数据结构,一探Geth客户端深处的秘密。
我们从以太区块的数据开始,反推回特定合约存储区。再用Geth如何实现SSTORE与SLOAD收尾。需要了解的知识有很多,我们会介绍Geth代码库,讲讲以太坊的世界状态,让你对EVM有一个更深的了解。
我们从下图开始,不要有恐惧心理,在文章的结尾你会对它有一个全面的认识,这里画出了以太坊的体系结构和以太链的数据。
以太坊体系结构 - 来源: Zanzu
相比看整个的图,我们不如一块一块看。现在我们看一下第N块的区块头和它包含的字段。
区块头包含以太区块的关键信息,下边的第N块区块头就划分出了这些信息。看看以太坊第14698834块 是否有下图的字段吧。
区块头包含以下字段:
让我们看看这些和Geth代码的对应关系,block.go里定义的“Header”结构体就代表一个区块头。
代码地址: go-ethereum/core/types/block.go
我们可以看到代码里声明的变量是和上边的概念图匹配的,我们的目标是从从区块头一路找到合约存储区。为此我们要关注被标红的“State Root”字段
“状态根(State Root)”是一个梅克尔根,它取决于其下所有的数据块,任何一块数据的变动都会改变它。这个状态树的数据结构是MPT,叶子结点存储这网络上每一个以太坊账户的数据。该数据为k-v结构,key是地址,value是账户信息。
实际上key是地址的哈希值而value是账户信息的RLP编码,但是我们可以暂时忽略这件事
以太坊体系结构图的这一部分正是代表了状态根的MRT。
MPT是一种复杂的数据结构,我们这篇文章不做深究。如果你对MPT感兴趣的话建议看这篇文章。接下来我们重点看看以太坊的账户信息是如何映射到地址的。
以太坊账户由以下四项构成:
(译者注:后两项是合约账户拥有的数据段,普通账户并没有)
我们在之前以太坊体系结构图的这一段可以看到:
现在我们去看看Geth的代码,找到对应的 state_account.go ,这个StateAccount结构体就是“以太坊账户”。
代码地址: go-ethereum/core/types/state_account.go
可以看到代码里声明的变量与概念图对应上了。接下来我们需要探讨以太坊账户的存储根。
存储根(storage root)很像状态根,在它的下面是另一棵MPT。区别就是这次的key是存储插槽,而value是插槽里的数据。
跟状态根一样,key其实是哈希值而value是RLP编码
以太坊体系结构图的这一部分正是代表了存储根的MRT。
存储根是一个梅克尔根,任何合约存储区的数据的改变都会改变存储根的值,从而影响区块头的值。现在我们知道怎么从区块找到合约存储区了。下一步我们继续研究Geth的代码,看看存储区是怎么初始化的,以及调用SSTORE和SLOAD是会发生什么。这会帮助你找到底层opcode和solidity代码之间的联系。
我们需要一个全新的合约,全新的合约意味着StateAccount也是全新的。
开始之前我们看三个结构:
我们看看这三个结构的内在关系:
StateDB → stateObject → StateAccount
现在一些令人疑惑的东西逐渐明晰了起来,我们看一看新的以太坊账户,或者说StateAccount是怎么初始化的。
我们需要操作statedb.go以及它的StateDB结构体去创建新的StateAccount。StateDB有一个名为createObject的函数,它会创建一个新的stateObject然后放进去一个空的StateAccount。
下图是代码细节:
现在我们有了一个空stateAccount,下一步我们存些数据吧,用SSTORE。
在深入了解SSTORE的Geth实现之前我们先回忆一下SSTORE是干什么的。
SSTORE会从栈上弹出两个值,一个32字节的key,一个32字节的value。key决定了value存在哪个插槽里。
下面就是Geth的SSTORE操作的源码,我们看看他做了什么:
现在我们已经更新了stateObject 的 dirtyStorage。而这究竟意味着什么呢?与我们学的东西有什么关系吗?
让我们看看定义dirtyStorage的代码
dirtyStorage → Storage → Hash → 32-byte
32字节key到32字节value的映射对你来说应该很熟悉,这完全就是第三篇我们提到过的合约存储区的概念。你现在可能注意到dirtyStorage字段上边的pendingStorage和originStorage了。他们是有相关性的,dirtyStorage在确定写入的过程中,会复制到pendingStorage,然后在MPT更新时复制到originStorage。MPT更新了之后,StateDB的commit过程中StateAccount的状态根也会更新。会将新的状态写进MPT的底层数据库。
下面轮到最后一个难点,SLOAD。
我们快速回忆一下SLOAD是干什么的:它会把一个32字节的key从栈里弹出来,然后返回key对应的插槽里的值。下面看一下Geth实现SLOAD的代码:
你会发现函数最先试图返回dirtyStorage,然后依次是pendingStorage和originStorage。这是合情合理的,在运行的过程中dirtyStorage是最新状态,紧接着是pendingStorage和originStorage。一个交易可能多次改变同一个插槽的数据所以我们必须保证拿到的是最新的值。
让我们设想在同一笔交易里,SLOAD之前在同一个插槽发生了SSTORE操作,在这种情况下dirtyStorage是被SSTORE更新过的,SLOAD返回的正应该是它。
现在您已经了解了Geth是如何实现SSTORE和SLOAD的。它们如何与状态和存储区交互,以及如何更新插槽与世界状态相关的知识。
这篇文章的强度很大但是你坚持下来了,我猜这篇文章会让你的问题比刚开始更多了,但这正是加密世界的乐趣不是吗?
只要功夫深,铁杵磨成针!