本篇文章介紹Geth 代碼庫,了解以太坊的“世界狀態”。
By: Flush
導語
這是“EVM 深入探討” 系列的第四部分。在第3 部分中,我們了解了合約存儲的相關知識,這期我們將探討單個合約的存儲如何融入以太坊鏈更廣泛的“世界狀態”。我們將了解以太坊鏈的架構,數據結構,以及”Go Ethereum”(Geth)客戶端的內部結構。
我們將從以太坊區塊中包含的數據開始,並倒退到一個特定合約的存儲。最後,我們追溯到Geth 中的SSTORE 和SLOAD 操作碼的實現。
本篇文章將介紹Geth 代碼庫,了解以太坊的“世界狀態”,以此加深對EVM 的整體理解。
以太坊架構
我們將從下面的圖片開始,不要被圖中復雜的結構框架給嚇到,在本文結束時,我們會對此有一個全面的認識。這代表了以太坊的架構和以太坊鏈中包含的數據。
(https://ethereum.stackexchange.com/questions/268/ethereum-block-architecture)
接下來我們對圖中內容逐塊分析。首先我們把重點放在第N 個區塊頭和它包含的字段上。
區塊頭
區塊頭包含了一個以太坊區塊的關鍵信息。下面是第N 個區塊頭劃分出的區塊數據字段。讓我們來看一下Etherscan 上的區塊14698834 (https://etherscan.io/block/14698834),看看能否看到圖中的一些字段。
區塊頭包含以下字段:
Prev Hash – 父區塊的Keccak 哈希
Nonce – 區塊中用於滿足PoW 的隨機值
Timestamp – 寫入當前區塊的UNIX 時間戳
Uncles Hash – 叔塊Keccak 哈希
Beneficiary – 收款人地址,礦工費接收者
LogsBloom – Bloom 過濾器,提取自receipt,由可索引信息(日誌地址和日誌主題)組成
Difficulty – 當前出塊的難度,設置產生單位工作量證明需要消耗多少算力
Extra Data – 與該區塊相關的32 個字節的數據,由礦工自定義
Block Num – 區塊高度
Gas Limit – 一個區塊允許消耗的最大gas 量
Gas Used – 此區塊內交易所消耗的總gas 量
Mix Hash – 256 位的值與nonce 一起使用,以證明工作證明的計算,代表區塊不含nonce 時的哈希值
State Root – 執行完此區塊中的所有交易後以太坊中,所有賬戶狀態的默克爾樹根Keccak 哈希值
Transaction Root – 交易生成的默克爾樹的根節點哈希值
Receipt Root – 交易回執生成的默克爾樹的根節點哈希值
讓我們看看這些字段如何與Geth 客戶端代碼庫中的內容相對應。 block.go 中定義的”Header” 結構體表示一個區塊頭。
(代碼地址:https://github.com/ethereum/go-ethereum/blob/master/core/types/block.go)
可以看到,代碼庫中所述的值與概念圖中的相匹配。而我們的目標是從區塊頭開始一路尋找到到單個合約的存儲區。要做到這一點,我們需要關注塊頭的State Root 字段,該字段以紅色標示。
狀態根(State Root)
狀態根(State Root) 作用類似於默克爾根,因為它是一個哈希值,依賴於它下面的所有數據塊。如果任何數據塊發生變化,根也會發生變化。
在“狀態根” 下面的數據結構是一個Merkle Patric Trie,它為網絡上的每個以太坊賬戶存儲一個鍵值對結構,其中key 是一個以太坊地址,value 是以太坊賬戶對象。
實際上,key 是以太坊地址的哈希值,value 是RLP 編碼的以太坊賬戶,但是我們現在可以忽略這一點。
以太坊體系結構圖的這一部分正是表示“狀態根” 的Merkel Patricia Trie。
Merkle Patricia Trie 是一個比較複雜的的數據結構,我們不會在這篇文章中深入研究它。如果你對Merkle Patricia Trie 感興趣,推薦閱讀這篇優秀的介紹性文章(https://medium.com/shyft-network/understanding-trie-databases-in-ethereum-9f03d2c3325d)。
接下來,讓我們看一下以太坊賬戶信息是如何映射到地址的。
以太坊賬戶
以太坊賬戶是一個以太坊地址的共識代表。由4 個字段組成:
Nonce:顯示從帳戶發送的交易數量的計數器,這將確保交易只處理一次。在合約帳戶中,這個數字代表該帳戶創建的合約數量。
Balance:賬戶餘額,這個地址擁有的Wei 數量。 Wei 是以太幣的計數單位,每個ETH 有1e+18 Wei。
Code Hash:存儲在合約/賬戶中的字節碼的哈希值。該哈希表示以太坊虛擬機(EVM) 上的帳戶代碼。合約帳戶具有編程的代碼片段,可以執行不同的操作。如果帳戶收到消息調用,則執行此EVM 代碼。與其他帳戶字段不同,不能更改。所有代碼片段都被保存在狀態數據庫的相應哈希下,供後續檢索。此哈希值稱為codeHash。對於外部所有的帳戶,codeHash 字段是空字符串的哈希。
Storage Root:有時被稱為存儲哈希。 Merkle Patricia trie 根節點的256 位哈希已編碼了帳戶的存儲內容(256 位整數值映射),並編碼為Trie,作為來自256 的Keccak 256 位哈希的映射位整數鍵,用於RLP 編碼的256 位整數值。此Trie 對此帳戶存儲內容的哈希進行編碼,默認情況下為空。
如下圖所示:
引介|EVM 深入探討Part 4
我們來看Geth 的代碼,找到相應的文件state_account.go 和定義“以太坊賬戶” 的結構StateAccount。
(代碼地址:https://github.com/ethereum/go-ethereum/blob/master/core/types/state_account.go)
可以看到代碼庫中的變量和概念圖相匹配。接下來,我們來看以太坊賬戶中的“存儲根” 字段。
存儲根(Storage Root)
存儲根很像狀態根,在它下面是另一個Merkle Patricia trie。不同的是這次的key 是存儲插槽,value 是每個插槽中的數據。實際上,在這個過程中,value 為RLP 編碼而key 為哈希值。
下圖是以太坊體系結構圖的這一部分正是代表了存儲根的MRT。
存儲根是一個merkle 根哈希值,如果任何底層數據(合約存儲)發生變化,它將受到影響。合約存儲的任何變化會影響到存儲根,進而影響到狀態根,再進而影響到區塊頭。
文章的後半部分是對Geth 代碼庫的探討。我們將簡要地了解一下合約存儲的初始化,以及當調用SSTORE & SLOAD 操作碼時會發生什麼。這將有助於我們在solidity 代碼和底層存儲操作碼opcode 建立聯繫。
StateDB → stateObject → StateAccount
我們以一個全新的合約為例,一個全新的合約意味著會有一個全新的StateAccount。
在我們開始之前,有3 個結構我們需要了解一下。
StateAccount:StateAccount 是以太坊賬戶的Ethereum 共識表示。
stateObject:stateObject 在交易執行中正在被修改的以太坊賬戶狀態。
StateDB:StateDB 結構是用來存儲Merkle trie 內的所有數據,用於檢索合約和以太坊賬戶的一般查詢接口。
我們通過代碼來看看這三個結構的內在關係:
1. StateDB 結構:可以看到它有一個stateObjects 字段,是地址到stateObject 的映射集(狀態根的Merkle Patricia trie 是以太坊地址到以太坊賬戶的映射,而stateObject 是正在被修改的以太坊賬戶)。
2. stateObject 結構:可以看到它有一個數據字段,屬於StateAccount 類型,是一個代碼實現裡的中間態(記得在文章的早些時候,我們將以太坊賬戶映射到Geth 中的StateAccount)。
3. StateAccount 結構:這個結構代表一個以太坊賬戶,它的Root 字段是我們之前討論到的存儲根。
在這個過程中,一些知識拼圖的碎片開始拼湊起來。有了這些前置知識,我們就可以來了解一下一個新的以太坊賬戶或者說是StateAccount 是如何初始化的。
初始化一個新的以太坊賬戶(StateAccount)
我們需要通過statedb.go 文件和它的StateDB 結構創建一個新的StateAccount。 StateDB 有一個createObject 函數,可以創建一個新的stateObject,並將一個空白的StateAccount 傳給它。這實際上是創建一個空白的以太坊賬戶。
下圖為代碼詳情:
1. StateDB 有一個createObject 函數,它接收一個傳入的以太坊地址並返回一個stateObject(stateObject 代表一個正在被修改的以太坊賬戶。)
2. 這個createObject 函數調用newObject 函數,傳入stateDB、地址和一個空的StateAccount(StateAccount = 以太坊賬戶),返回一個stateObject。
3. 在newObject 函數的返回語句中,我們可以看到有許多與stateObject 相關的字段如:地址、數據、dirtyStorage 等。
4. stateObject 的data 字段映射到函數中的空StateAccount 輸入,注意在第103-111 行是一個nil 值轉變為初始化空值的過程。
5. stateObject 被成功創建並帶著已經初始化完成的StateAccount(也就是data 字段)返回。
現在有一個空的stateAccount 了,接下來要想存儲一些數據,為此我們需要使用SSTORE 操作碼。
SSTORE
SSTORE:將一個(u)int256 寫到內存中。
引介|EVM 深入探討Part 4
它從棧中彈出兩個值,首先是32 字節的key,然後是32 字節的value,並將該值存儲在由key 定義的指定存儲槽中。
下面是SSTORE 操作碼的Geth 代碼流程,讓我們看看它的作用:
1. 我們從定義了所有EVM 操作碼的instruments.go 文件開始。在這個文件中,我們能找到名為opSstore 的函數。
2. 傳入這個函數的參數包含合約上下文,列如棧、內存等。從棧中彈出2 個值,並定義賦值記作loc(location 的縮寫)和val(value 的縮寫)。
3. 然後,將這2 個值和合約地址作為輸入一起傳入到StateDB 的SetState 函數。 SetState 函數使用合約地址來檢查出入的該合約是否存在一個stateObject,如果不存在,它將為其先創建一個。然後,對調用創建後的stateObject 的SetState,傳入StateDB db、key 和value。
4. stateObject 的SetState 函數會對假存儲做一些檢查,以及值是否有變化,然後運行journal 的append 函數。
5. 如果你閱讀了關於journal struct 的代碼註釋,能看到journal 是用來跟踪狀態修改(保存中間變量)的,以便在出現執行異常或請求撤銷的情況下可以恢復這些修改。
6. 在journal 被更新後,調用storageObject 的setState 函數,傳入key 和value。更新storageObjects.dirtyStorage。
現在們已經更新了stateObject 的dirtyStorage。這意味這什麼呢?
讓我們從定義dirtyStorage 的代碼開始。
(dirtyStorage → Storage → Hash → 32-byte)
1. dirtyStorage 在stateObject 結構裡定義的,被描述為“在當前事務執行中被修改的存儲項”。
2. 與dirtyStorage 相對應的存儲類型是common.Hash 到common.Hash 的映射。
3. Hash type 只是一個長度為HashLength 的字節數組。
4. HashLength 是一個常數,值為32。
一個32 字節的key 映射到一個32 字節的value,這正是我們在上一篇文章中上講到的合約存儲的概念。可能已經註意到stateObject 中的pendingStorage 和originStorage 就在dirtyStorage 字段的上方。它們都是有相關的,在最終確定寫入dirtyStorage 過程中,dirtyStorage 會被複製到pendingStorage,而pendingStorage 又在trie 被更新時被複製到originStorage。在trie 被更新後,StateAccount 的存儲根也將在StateDB 的”Commit” 過程中被更新。這將把新的狀態寫入底層的內存trie 數據庫中。
現在來到最後一部分,SLOAD。
SLOAD
SLOAD:從存儲中讀取(u)int256。
引介|EVM 深入探討Part 4
從棧中彈出1 個值,即32 字節的key,代表存儲槽,並返回存儲在那裡的32 字節的value。
下面是SLOAD 操作碼的Geth 代碼流程,讓我們看一下它的作用:
1. 我們再次從instructions.go 文件開始,可以找到opSload 函數。我們從棧的頂部獲取SLOAD 的位置(存儲槽),也就是臨時變量loc。
2. 調用StateDB 的GetState 函數,輸入合約地址和存儲位置。 GetState 獲得與該合約地址相關的stateObject。如果stateObject 不為空,它就會在該stateObject 上調用GetState。
3. 在stateObject 的GetState 函數會對fakeStorage 進行檢查,然後再對dirtyStorage 進行檢查。
4. 如果dirtyStorage 存在,返回dirtyStorage 映射中關鍵位置的值(dirtyStorage 代表合約的最新狀態,這就是為什麼要首先返回它)。
5. 否則就將調用GetCommitedState 函數,在storage tire 中查找該值,並再次檢查fakeStorage。
6. 如果pendingStorage 存在,則返回pendingStorage 映射中key 位置的值。
7. 如果上述方法都沒有返回,就檢索originStorage 並返回值。
你會發現函數最先返回dirtyStorage,然後是pendingStorage,最後是originStorage。這是非常合理的,因為在執行過程中,dirtyStorage 是最新的存儲映射,其次是pending,最後才是originStorage。一筆交易可以多次操作改變同一個插槽的數據,所以我們必須確保我們獲取的是最新的值。
讓我們想像一下在同一筆交易中,在SLOAD 之前在同一個插槽發生了SSTORE 操作。那麼在這種情況下dirtyStorage 將在SSTORE 中被更新,在SLOAD 中被返回。
我們現在對SSTORE 和SLOAD 在Geth 層面的實現有一定的了解。它們如何與狀態和存儲對象進行交互,以及更新插槽與更廣泛的以太坊“世界狀態” 的關係。
本篇文章強度挺大,能堅持讀完已經很不錯了,這也正是我們探索加密世界的樂趣所在。下一篇文章讓我們一起來學習探討操作碼CALL & DELEGATECALL。
展開全文打開碳鏈價值APP 查看更多精彩資訊