本篇文章主要介紹 Geth 代碼庫,帶大家一起了解以太坊的 “世界狀態”。
作者: Flush,慢霧安全團隊
原用標題:引介:EVM 深入探討 Part 4
導語
這是 “EVM 深入探討” 系列的第四部分。在第 3 部分中,我們了解了合約存儲的相關知識,這期我們將探討單個合約的存儲如何融入以太坊鏈更廣泛的 “世界狀態”。我們將了解以太坊鏈的架構,數據結構,以及 “Go Ethereum”(Geth)客戶端的內部結構。
我們將從以太坊區塊中包含的數據開始,並倒退到一個特定合約的存儲。最後,我們追溯到 Geth 中的 SSTORE 和 SLOAD 操作碼的實現。
本篇文章將介紹 Geth 代碼庫,了解以太坊的 “世界狀態”,以此加深對 EVM 的整體理解。
以太坊架構
我們將從下面的圖片開始,不要被圖中復雜的結構框架給嚇到,在本文結束時,我們會對此有一個全面的認識。這代表了以太坊的架構和以太坊鏈中包含的數據。
接下來我們對圖中內容逐塊分析。首先我們把重點放在第 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” 結構體表示一個區塊頭。
可以看到,代碼庫中所述的值與概念圖中的相匹配。而我們的目標是從區塊頭開始一路尋找到到單個合約的存儲區。要做到這一點,我們需要關注塊頭的 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 對此帳戶存儲內容的哈希進行編碼,默認情況下為空。
如下圖所示:
我們來看 Geth 的代碼,找到相應的文件 state_account.go 和定義 “以太坊賬戶” 的結構 StateAccount。
可以看到代碼庫中的變量和概念圖相匹配。接下來,我們來看以太坊賬戶中的 “存儲根” 字段。
存儲根 (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 寫到內存中。
它從棧中彈出兩個值,首先是 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 的代碼開始。
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。
從棧中彈出 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。
往期回顧
免責聲明:作為區塊鏈信息平台,本站所發布文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。文章內的信息僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。