跳至主要内容

了解黃皮書的 EVM 規範

evm
中等
qbzzt
2022年5月15日
27 分鐘閱讀

黃皮書opens in a new tab 是以太坊的正式規範。 除非經過 EIP 流程 修訂,否則其中包含一切運作方式的精確說明。 這份文件以數學論文的形式撰寫,其中包含的術語可能是程式設計師不熟悉的。 在本文中,你將學會如何閱讀黃皮書,並進而了解如何閱讀其他相關的數學論文。

哪一份黃皮書?

就像以太坊中幾乎所有事物一樣,黃皮書也會隨著時間演進。 為了能夠參照特定版本,我上傳了撰寫本文時的當前版本。 我使用的章節、頁碼和方程式編號都將參照該版本。 在閱讀本文件的同時,建議在另一個視窗中開啟它。

為何選擇 EVM?

最初的黃皮書是在以太坊開發之初撰寫的。 它描述了最初用於保護網路安全的、基於工作量證明的共識機制。 然而,以太坊在 2022 年 9 月關閉了工作量證明,並開始使用基於權益證明的共識。 本教學將著重於黃皮書中定義以太坊虛擬機的部分。 在轉換到權益證明的過程中,EVM 並未改變(除了 DIFFICULTY 操作碼的傳回值)。

9 執行模型

本節(第 12-14 頁)包含了 EVM 的大部分定義。

術語「系統狀態」包含了執行系統所需了解的一切資訊。 在典型的電腦中,這意味著記憶體、暫存器的內容等。

圖靈機opens in a new tab 是一種計算模型。 本質上,它是一種簡化版的電腦,經證明它具有與普通電腦相同的運算能力(凡是電腦能計算的,圖靈機也能計算,反之亦然)。 這個模型讓我們更容易證明關於哪些是可計算、哪些是不可計算的各種定理。

術語 圖靈完備opens in a new tab 指的是能夠執行與圖靈機相同運算的電腦。 圖靈機會陷入無限迴圈,而 EVM 不會,因為它會耗盡 Gas,所以它只是準圖靈完備的。

9.1 基礎

本節介紹了 EVM 的基礎知識,以及它與其他計算模型的比較。

堆疊機opens in a new tab 是一種將中間資料儲存在 堆疊opens in a new tab 中,而不是暫存器中的電腦。 這是虛擬機的首選架構,因為它易於實作,這意味著發生程式錯誤和安全漏洞的可能性要小得多。 堆疊中的記憶體被劃分為 256 位元字組。 之所以選擇這個大小,是因為它方便以太坊的核心密碼學運算,例如 Keccak-256 哈希和橢圓曲線運算。 堆疊的最大大小是 1024 個項目(1024 x 256 位元)。 執行操作碼時,它們通常從堆疊中取得參數。 有一些專門用於重組堆疊中元素的操作碼,例如 POP(從堆疊頂部移除項目)、DUP_N(複製堆疊中的第 N 個項目)等。

EVM 還有一個稱為記憶體的揮發性空間,用於在執行期間儲存資料。 此記憶體由 32 位元組的字組組成。 所有記憶體位置都初始化為零。 如果你執行這段 Yulopens in a new tab 程式碼向記憶體新增一個字組,它將透過在字組的空白處填充零來填滿 32 個位元組的記憶體,也就是說,它會建立一個字組——位置 0-29 為零,位置 30 為 0x60,位置 31 為 0xA7。

1mstore(0, 0x60A7)

mstore 是 EVM 提供的用於與記憶體互動的三個操作碼之一——它將一個字組載入到記憶體中。 另外兩個是 mstore8,它將單一位元組載入到記憶體中;以及 mload,它將一個字組從記憶體移動到堆疊中。

EVM 還有一個獨立的非揮發性儲存模型,作為系統狀態的一部分進行維護——此記憶體由字組陣列組成(與堆疊中可按字組定址的位元組陣列不同)。 此儲存空間是合約存放持久性資料的地方——一個合約只能與自己的儲存空間互動。 儲存空間以鍵值對應的方式組織。

雖然黃皮書的這一節沒有提到,但了解還有第四種類型的記憶體也很有用。 Calldata 是可按位元組定址的唯讀記憶體,用於儲存與交易的 data 參數一起傳遞的值。 EVM 有專門的操作碼來管理 calldatacalldatasize 會傳回資料的大小。 calldataload 將資料載入堆疊。 calldatacopy 將資料複製到記憶體。

標準的馮紐曼架構opens in a new tab將程式碼和資料儲存在同一個記憶體中。 出於安全考量,EVM 並不遵循此標準——共享揮發性記憶體可能會導致程式碼被更改。 因此,程式碼被儲存到儲存空間中。

只有兩種情況下,程式碼會從記憶體中執行:

  • 當一個合約建立另一個合約時(使用 CREATEopens in a new tabCREATE2opens in a new tab),合約建構子的程式碼來自記憶體。
  • 在建立_任何_合約期間,建構子程式碼會執行,然後從記憶體中傳回合約的實際程式碼。

術語「異常執行」指的是導致目前合約執行停止的例外情況。

9.2 費用概覽

本節說明 Gas 費用的計算方式。 有三種成本:

操作碼成本

特定操作碼的內在成本。 要取得此值,請在附錄 H(第 28 頁,方程式 (327) 下方)中找到操作碼的成本群組,然後在方程式 (324) 中找到該成本群組。 這樣你就會得到一個成本函數,在大多數情況下,它會使用附錄 G(第 27 頁)中的參數。

例如,操作碼 CALLDATACOPYopens in a new tabWcopy 群組的成員。 該群組的操作碼成本是 Gverylow+Gcopy×⌈μs[2]÷32⌉。 查看附錄 G,我們看到兩個常數都是 3,所以得到 3+3×⌈μs[2]÷32⌉

我們還需要解讀運算式 ⌈μs[2]÷32⌉。 最外層的部分 ⌈ <value> ⌉ 是無條件進位函數,這個函數會傳回一個不小於給定值的最小整數。 例如,⌈2.5⌉ = ⌈3⌉ = 3。 內層部分是 μs[2]÷32。 查看第 3 頁的第 3 節(慣例),μ 是機器狀態。 機器狀態在第 13 頁的 9.4.1 節中定義。 根據該節,其中一個機器狀態參數是代表堆疊的 s。 綜合起來,μs[2] 似乎是堆疊中的位置 #2。 查看該操作碼opens in a new tab,堆疊中的位置 #2 是資料的大小(以位元組為單位)。 查看 Wcopy 群組中的其他操作碼,CODECOPYopens in a new tabRETURNDATACOPYopens in a new tab,它們的資料大小也在相同的位置。 所以 ⌈μs[2]÷32⌉ 是儲存被複製的資料所需的 32 位元組字組數。 綜合起來,CALLDATACOPYopens in a new tab 的內在成本是 3 Gas,外加每複製一個字組的資料需要 3 Gas。

執行成本

執行我們所呼叫的程式碼的成本。

記憶體擴充成本

擴充記憶體的成本(如有必要)。

在方程式 324 中,此值寫為 Cmemi')-Cmemi)。 再次查看 9.4.1 節,我們看到 μi 是記憶體中的字組數。 所以 μi 是操作碼執行前記憶體中的字組數,而 μi' 則是操作碼執行後記憶體中的字組數。

函數 Cmem 在方程式 326 中定義:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋⌊x⌋ 是無條件捨去函數,這個函數會傳回一個不大於給定值的最大整數。 例如,⌊2.5⌋ = ⌊2⌋ = 2。a < √512 時,a2 < 512,無條件捨去函數的結果為零。 所以對於前 22 個字組(704 位元組),成本與所需的記憶體字組數呈線性增長。 超過該點後,⌊a2 ÷ 512⌋ 為正數。 當所需的記憶體足夠大時,Gas 成本與記憶體數量的平方成正比。

注意:這些因素只影響_內在的_ Gas 成本——它沒有考慮到費用市場或給驗證者的提示,這些因素決定了終端使用者需要支付多少費用——這裡只是在 EVM 上執行特定操作的原始成本。

進一步了解 Gas

9.3 執行環境

執行環境是一個元組 I,它包含了不屬於區塊鏈狀態或 EVM 的資訊。

參數存取資料的操作碼存取資料的 Solidity 程式碼
IaADDRESSopens in a new tabaddress(this)
IoORIGINopens in a new tabtx.origin
IpGASPRICEopens in a new tabtx.gasprice
IdCALLDATALOADopens in a new tabmsg.data
IsCALLERopens in a new tabmsg.sender
IvCALLVALUEopens in a new tabmsg.value
IbCODECOPYopens in a new tabaddress(this).code
IH區塊標頭欄位,例如 NUMBERopens in a new tabDIFFICULTYopens in a new tabblock.numberblock.difficulty
Ie合約間呼叫(包含合約建立)的呼叫堆疊深度
IwEVM 是否允許更改狀態,或者它是否正在靜態執行

要了解第 9 節的其餘部分,還需要了解其他一些參數:

參數定義於章節意義
σ2(第 2 頁,方程式 1)區塊鏈的狀態
g9.3(第 13 頁)剩餘 Gas
A6.1(第 8 頁)累積的子狀態(預定在交易結束時發生的變更)
o9.3(第 13 頁)輸出——在內部交易(一個合約呼叫另一個合約)和呼叫檢視函數(當你只是請求資訊,因此無需等待交易時)的情況下的傳回結果

9.4 執行概覽

現在我們已經完成了所有的準備工作,終於可以開始研究 EVM 的運作方式了。

方程式 137-142 提供了執行 EVM 的初始條件:

符號初始值意義
μgg剩餘 Gas
μpc0程式計數器,下一個要執行的指令的位址
μm(0, 0, ...)記憶體,初始化為全零
μi0已使用的最高記憶體位置
μs()堆疊,初始為空
μo輸出,在我們停止(無論是帶有傳回資料 (RETURNopens in a new tabREVERTopens in a new tab) 還是不帶有 (STOPopens in a new tabSELFDESTRUCTopens in a new tab))之前,均為空集合。

方程式 143 告訴我們在執行期間的每個時間點有四種可能的情況,以及如何處理它們:

  1. Z(σ,μ,A,I)。 Z 代表一個函數,用於測試某個操作是否會產生無效的狀態轉換(請參閱異常停止)。 如果其結果為 True,則新狀態與舊狀態相同(除了消耗掉的 Gas),因為變更尚未實作。
  2. 如果正在執行的操作碼是 REVERTopens in a new tab,則新狀態與舊狀態相同,但會損失一些 Gas。
  3. 如果操作序列已完成(由 RETURNopens in a new tab 表示),則狀態會更新為新狀態。
  4. 如果我們不處於 1-3 的結束條件之一,則繼續執行。

9.4.1 機器狀態

本節更詳細地解釋了機器狀態。 它指定 w 是目前的操作碼。 如果 μpc 小於程式碼的長度 ||Ib||,則該位元組 (Ibpc]) 就是操作碼。 否則,該操作碼定義為 STOPopens in a new tab

由於這是一台堆疊機opens in a new tab,我們需要追蹤每個操作碼彈出 (δ) 和推入 (α) 的項目數量。

9.4.2 異常停止

本節定義了 Z 函數,它指定了何時會發生異常終止。 這是一個布林opens in a new tab函數,因此它使用 表示邏輯或opens in a new tab以及 表示邏輯與opens in a new tab

如果以下任一條件為真,就會發生異常停止:

  • μg < C(σ,μ,A,I) 如我們在 9.2 節所見,C 是指定 Gas 成本的函數。 剩餘的 Gas 不足以支付下一個操作碼的費用。

  • δw=∅ 如果一個操作碼彈出的項目數是未定義的,那麼該操作碼本身就是未定義的。

  • || μs || < δw 堆疊下溢,堆疊中沒有足夠的項目供目前的操作碼使用。

  • w = JUMP ∧ μs[0]∉D(Ib) 操作碼是 JUMPopens in a new tab,且位址不是 JUMPDESTopens in a new tab。 只有當目標是 JUMPDESTopens in a new tab 時,跳轉才_有效_。

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作碼是 JUMPIopens in a new tab,條件為真(非零),所以應該發生跳轉,但位址不是 JUMPDESTopens in a new tab。 只有當目標是 JUMPDESTopens in a new tab 時,跳轉才_有效_。

  • w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || 操作碼是 RETURNDATACOPYopens in a new tab。 在這個操作碼中,堆疊元素 μs[1] 是從傳回資料緩衝區中讀取的偏移量,堆疊元素 μs[2] 是資料的長度。 當你嘗試讀取超出傳回資料緩衝區的末端時,會發生此情況。 請注意,對於 calldata 或程式碼本身,沒有類似的條件。 當你嘗試讀取超出這些緩衝區的末端時,你只會得到零。

  • || μs || - δw + αw > 1024

    堆疊溢位。 如果執行操作碼將導致堆疊超過 1024 個項目,則中止。

  • ¬Iw ∧ W(w,μ) 我們是否正在靜態執行(¬ 是否定opens in a new tab,當我們被允許更改區塊鏈狀態時 Iw 為真)? 如果是,而我們正在嘗試一個改變狀態的操作,那是不會發生的。

    函數 W(w,μ) 在後面的方程式 150 中定義。 如果以下任一條件為真,則 W(w,μ) 為真:

    • w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} 這些操作碼會改變狀態,無論是透過建立新合約、儲存值,還是銷毀目前合約。

    • LOG0≤w ∧ w≤LOG4 如果我們是靜態呼叫,則無法發出日誌條目。 日誌操作碼都在 LOG0 (A0)opens in a new tabLOG4 (A4)opens in a new tab 之間的範圍內。 日誌操作碼後面的數字指定了日誌條目包含多少個主題。

    • w=CALL ∧ μs[2]≠0 當你處於靜態模式時可以呼叫另一個合約,但這樣做時不能向其轉移 ETH。

  • w = SSTORE ∧ μg ≤ Gcallstipend 除非你的 Gas 超過 Gcallstipend(在附錄 G 中定義為 2300),否則你無法執行 SSTOREopens in a new tab

9.4.3 跳轉目標有效性

在這裡,我們正式定義什麼是 JUMPDESTopens in a new tab 操作碼。 我們不能只尋找位元組值 0x5B,因為它可能在 PUSH 內部(因此是資料而不是操作碼)。

在方程式 (153) 中,我們定義了一個函數 N(i,w)。 第一個參數 i 是操作碼的位置。 第二個參數 w 是操作碼本身。 如果 w∈[PUSH1, PUSH32],這意味著操作碼是一個 PUSH(方括號定義一個包含端點的範圍)。 在這種情況下,下一個操作碼位於 i+2+(w−PUSH1)。 對於 PUSH1opens in a new tab,我們需要前進兩個位元組(PUSH 本身和一個位元組的值),對於 PUSH2opens in a new tab,我們需要前進三個位元組,因為它是一個兩個位元組的值,以此類推。 所有其他 EVM 操作碼都只有一個位元組長,所以在所有其他情況下 N(i,w)=i+1

這個函數在方程式 (152) 中用於定義 DJ(c,i),它是程式碼 c 中從操作碼位置 i 開始的所有有效跳轉目標的集合opens in a new tab。 這個函數是遞迴定義的。 如果 i≥||c||,這意味著我們在程式碼的末端或之後。 我們不會再找到任何跳轉目標,所以只需傳回空集合。

在所有其他情況下,我們透過轉到下一個操作碼並從中取得集合來查看其餘的程式碼。 c[i] 是目前的操作碼,所以 N(i,c[i]) 是下一個操作碼的位置。 因此,DJ(c,N(i,c[i])) 是從下一個操作碼開始的有效跳轉目標集合。 如果目前的操作碼不是 JUMPDEST,則只需傳回該集合。 如果是 JUMPDEST,則將其包含在結果集合中並傳回。

9.4.4 正常停止

停止函數 H 可以傳回三種類型的值。

  • 如果我們不在停止操作碼中,則傳回 ,即空集合。 按照慣例,此值被解釋為布林值的 false。
  • 如果我們有一個不產生輸出的停止操作碼(STOPopens in a new tabSELFDESTRUCTopens in a new tab),則傳回一個大小為零的位元組序列作為傳回值。 請注意,這與空集合非常不同。 此值表示 EVM 確實停止了,只是沒有傳回資料可供讀取。
  • 如果我們有一個產生輸出的停止操作碼(RETURNopens in a new tabREVERTopens in a new tab),則傳回該操作碼指定的位元組序列。 此序列取自記憶體,堆疊頂部的值 (μs[0]) 是第一個位元組,其後的值 (μs[1]) 是長度。

H.2 指令集

在我們進入 EVM 的最後一小節 9.5 之前,讓我們先看看指令本身。 它們在附錄 H.2 中定義,從第 29 頁開始。 任何未被該特定操作碼指定為改變的內容,都應保持不變。 確實會改變的變數用 <something>′ 表示。

例如,讓我們看看 ADDopens in a new tab 操作碼。

數值助記符δα描述
0x01ADD21加法運算。
μ′s[0] ≡ μs[0] + μs[1]

δ 是我們從堆疊中彈出的值的數量。 在這種情況下是兩個,因為我們正在將頂部的兩個值相加。

α 是我們推回的值的數量。 在這種情況下是一個,即總和。

所以新的堆疊頂部 (μ′s[0]) 是舊的堆疊頂部 (μs[0]) 和它下面的舊值 (μs[1]) 的總和。

本文不會以「令人眼花撩亂的列表」方式逐一介紹所有操作碼,而只會解釋那些引入新概念的操作碼。

數值助記符δα描述
0x20KECCAK25621計算 Keccak-256 哈希。
μ′s[0] ≡ KEC(μms[0] . 。 。 (μs[0] + μs[1] − 1)])
μ′i ≡ M(μis[0],μs[1])

這是第一個存取記憶體(在這種情況下是唯讀)的操作碼。 然而,它可能會擴展到記憶體的目前限制之外,所以我們需要更新 μi 我們使用第 29 頁方程式 328 中定義的 M 函數來完成此操作。

數值助記符δα描述
0x31BALANCE11取得指定帳戶的餘額。
...

我們需要查詢餘額的位址是 μs[0] mod 2160。 堆疊頂部是位址,但由於位址只有 160 位元,我們計算其值opens in a new tab 2160

如果 σ[μs[0] mod 2160] ≠ ∅,這意味著有關於此位址的資訊。 在這種情況下,σ[μs[0] mod 2160]b 是該位址的餘額。 如果 σ[μs[0] mod 2160] = ∅,這意味著此位址尚未初始化,餘額為零。 你可以在第 4 頁的 4.1 節中看到帳戶資訊欄位的列表。

第二個方程式,A'a ≡ Aa ∪ {μs[0] mod 2160},與存取熱儲存(最近存取過且可能被快取的儲存)和冷儲存(未被存取過且可能在較慢的儲存中,檢索成本較高)的成本差異有關。 Aa 是交易先前存取過的位址列表,因此存取成本應該較低,如第 8 頁 6.1 節所定義。 你可以在 EIP-2929opens in a new tab 中閱讀更多關於此主題的內容。

數值助記符δα描述
0x8FDUP161617複製堆疊第 16 項。
μ′s[0] ≡ μs[15]

請注意,要使用任何堆疊項目,我們需要將其彈出,這也意味著我們需要彈出其上方的所有堆疊項目。 在 DUP<n>opens in a new tabSWAP<n>opens in a new tab 的情況下,這意味著需要彈出然後推入多達十六個值。

9.5 執行週期

既然我們已經了解了所有部分,我們終於可以理解 EVM 的執行週期是如何被記錄的。

方程式 (155) 指出,給定狀態:

  • σ(全域區塊鏈狀態)
  • μ(EVM 狀態)
  • A(子狀態,交易結束時發生的變更)
  • I(執行環境)

新狀態是 (σ', μ', A', I')

方程式 (156)-(158) 定義了堆疊以及由於操作碼 (μs) 而產生的變化。 方程式 (159) 是 Gas 的變化 (μg)。 方程式 (160) 是程式計數器的變化 (μpc)。 最後,方程式 (161)-(164) 指出,除非操作碼明確改變,否則其他參數保持不變。

至此,EVM 已被完全定義。

結論

數學符號是精確的,它讓黃皮書能夠詳細說明以太坊的每一個細節。 然而,它確實有一些缺點:

  • 它只能被人類理解,這意味著合規性測試opens in a new tab必須手動編寫。
  • 程式設計師理解電腦程式碼。 他們可能理解也可能不理解數學符號。

也許是出於這些原因,較新的共識層規範opens in a new tab是用 Python 編寫的。 雖然有 Python 版本的執行層規範opens in a new tab,但它們並不完整。 除非且直到整個黃皮書也被翻譯成 Python 或類似的語言,否則黃皮書將繼續使用,能夠閱讀它會很有幫助。

頁面最後更新時間: 2025年10月21日

這個使用教學對你有幫助嗎?