跳至主要內容

了解黃皮書的 EVM 規範

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

黃皮書 (opens in a new tab)是以太坊的正式規範。除非經過 EIP 流程修改,否則它包含了所有運作方式的精確描述。它是以數學論文的形式撰寫的,其中包含程式設計師可能不熟悉的術語。在本文中,你將學習如何閱讀它,並進而了解其他相關的數學論文。

哪一個版本的黃皮書?

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

為什麼是 EVM?

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

9 執行模型

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

系統狀態 (system state) 一詞包含了執行系統所需了解的所有資訊。在典型的電腦中,這意味著記憶體、暫存器的內容等。

圖靈機 (Turing machine) (opens in a new tab)是一種運算模型。本質上,它是電腦的簡化版本,被證明具有與一般電腦相同的執行運算能力(電腦能計算的所有東西,圖靈機都能計算,反之亦然)。這個模型使得證明關於什麼可計算、什麼不可計算的各種定理變得更加容易。

圖靈完備 (Turing-complete) (opens in a new tab)一詞指的是能夠執行與圖靈機相同計算的電腦。圖靈機可能會陷入無限迴圈,而 EVM 則不會,因為它會耗盡燃料 (gas),所以它只是準圖靈完備 (quasi-Turing-complete)。

9.1 基礎知識

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

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

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

mstore(0, 0x60A7)

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

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

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

標準的馮·紐曼架構 (Von Neumann architecture) (opens in a new tab)將程式碼和資料儲存在同一個記憶體中。基於安全考量,EVM 並未遵循此標準——共用揮發性記憶體會使得更改程式碼成為可能。相反地,程式碼被儲存到儲存空間 (storage) 中。

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

  • 當一個合約建立另一個合約時(使用 CREATE (opens in a new tab)CREATE2 (opens in a new tab)),合約建構函式的程式碼來自記憶體。
  • 在建立任何合約的過程中,建構函式程式碼會執行,然後傳回實際合約的程式碼,這同樣來自記憶體。

異常執行 (exceptional execution) 一詞指的是導致目前合約停止執行的例外狀況。

9.2 費用總覽

本節解釋了燃料費用的計算方式。包含三種成本:

操作碼成本

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

例如,操作碼 CALLDATACOPY (opens in a new tab)Wcopy 群組的成員。該群組的操作碼成本為 Gverylow+Gcopy×⌈μs[2]÷32⌉。查看附錄 G,我們可以看到這兩個常數都是 3,這就得出了 3+3×⌈μs[2]÷32⌉

我們還需要解讀表達式 ⌈μs[2]÷32⌉。最外層的部分 ⌈ <value> ⌉ 是天花板函數 (ceiling function),這是一個給定數值後,傳回不小於該數值的最小整數的函數。例如,⌈2.5⌉ = ⌈3⌉ = 3。內層部分是 μs[2]÷32。查看第 3 頁的第 3 節(慣例),μ 是機器狀態。機器狀態在第 13 頁的第 9.4.1 節中定義。根據該節,其中一個機器狀態參數是代表堆疊的 s。綜合起來看,μs[2] 似乎是堆疊中的第 2 號位置。查看該操作碼 (opens in a new tab),堆疊中的第 2 號位置是資料的位元組大小。查看 Wcopy 群組中的其他操作碼 CODECOPY (opens in a new tab)RETURNDATACOPY (opens in a new tab),它們在相同位置也有資料大小。因此,⌈μs[2]÷32⌉ 是儲存被複製資料所需的 32 位元組字組的數量。總結來說,CALLDATACOPY (opens in a new tab) 的固有成本是 3 單位燃料,加上每複製一個字組資料的 3 單位燃料。

執行成本

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

擴充記憶體成本

擴充記憶體的成本(如果需要的話)。

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

函數 Cmem 在方程式 326 中定義:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋⌊x⌋ 是地板函數 (floor function),這是一個給定數值後,傳回不大於該數值的最大整數的函數。例如,⌊2.5⌋ = ⌊2⌋ = 2。當 a < √512 時,a2 < 512,地板函數的結果為零。因此,對於前 22 個字組(704 個位元組),成本會隨著所需記憶體字組的數量呈線性增加。超過這個點之後,⌊a2 ÷ 512⌋ 為正數。當所需的記憶體夠高時,燃料成本會與記憶體數量的平方成正比。

注意,這些因素只會影響固有的燃料成本——它並未考慮決定終端使用者需要支付多少費用的費用市場或給驗證者的提示金 (tips)——這只是在 EVM 上執行特定操作的原始成本。

了解更多關於燃料的資訊

9.3 執行環境

執行環境是一個元組 (tuple) I,其中包含不屬於區塊鏈狀態或 EVM 的資訊。

參數存取資料的操作碼存取資料的 Solidity 程式碼
IaADDRESS (opens in a new tab)address(this)
IoORIGIN (opens in a new tab)tx.origin
IpGASPRICE (opens in a new tab)tx.gasprice
IdCALLDATALOAD (opens in a new tab)msg.data
IsCALLER (opens in a new tab)msg.sender
IvCALLVALUE (opens in a new tab)msg.value
IbCODECOPY (opens in a new tab)address(this).code
IH區塊頭欄位,例如 NUMBER (opens in a new tab)DIFFICULTY (opens in a new tab)block.numberblock.difficulty
Ie合約間呼叫(包含合約建立)的呼叫堆疊深度
IwEVM 是否被允許改變狀態,或者它是否在靜態執行

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

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

9.4 執行總覽

現在我們已經具備了所有的預備知識,終於可以開始探討 EVM 的運作方式了。

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

符號初始值意義
μgg剩餘燃料
μpc0程式計數器,下一個要執行之指令的地址
μm(0, 0, ...)記憶體,全部初始化為零
μi0使用到的最高記憶體位置
μs()堆疊,初始為空
μo輸出,為空集合,直到我們停止並帶有傳回資料(RETURN (opens in a new tab)REVERT (opens in a new tab))或不帶傳回資料(STOP (opens in a new tab)SELFDESTRUCT (opens in a new tab))為止。

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

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

9.4.1 機器狀態

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

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

9.4.2 異常停止

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

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

  • μg < C(σ,μ,A,I) 正如我們在第 9.2 節中所見,C 是指定燃料成本的函數。沒有足夠的剩餘燃料來支付下一個操作碼。

  • δw=∅ 如果操作碼彈出的項目數量未定義,則該操作碼本身也是未定義的。

  • || μs || < δw 堆疊反向溢位 (Stack underflow),堆疊中沒有足夠的項目供目前的操作碼使用。

  • w = JUMP ∧ μs[0]∉D(Ib) 操作碼是 JUMP (opens in a new tab),且地址不是 JUMPDEST (opens in a new tab)。跳躍 (Jumps) 只有在目的地是 JUMPDEST (opens in a new tab) 時才有效。

  • w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作碼是 JUMPI (opens in a new tab),條件為真(非零)因此應該發生跳躍,且地址不是 JUMPDEST (opens in a new tab)。跳躍只有在目的地是 JUMPDEST (opens in a new tab) 時才有效。

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

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

    堆疊溢位 (Stack overflow)。如果執行操作碼會導致堆疊超過 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 tab)LOG4 (A4) (opens in a new tab) 之間的範圍內。 日誌操作碼後面的數字指定了日誌條目包含多少個主題 (topics)。

    • w=CALL ∧ μs[2]≠0 當你處於靜態時,你可以呼叫另一個合約,但如果你這樣做,你無法向它轉帳 ETH。

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

9.4.3 跳躍目的地有效性

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

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

這個函數在方程式 (152) 中用於定義 DJ(c,i),它是程式碼 c 中從操作碼位置 i 開始的所有有效跳躍目的地的集合 (set) (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。
  • 如果我們有一個不產生輸出的停止操作碼(STOP (opens in a new tab)SELFDESTRUCT (opens in a new tab)),則傳回一個大小為零位元組的序列作為傳回值。請注意,這與空集合非常不同。這個值意味著 EVM 確實停止了,只是沒有傳回資料可讀取。
  • 如果我們有一個確實產生輸出的停止操作碼(RETURN (opens in a new tab)REVERT (opens in a new tab)),則傳回由該操作碼指定的位元組序列。這個序列取自記憶體,堆疊頂部的值 (μs[0]) 是第一個位元組,其後的值 (μs[1]) 是長度。

H.2 指令集

在我們進入 EVM 的最後一個小節 9.5 之前,讓我們先來看看指令本身。它們在從第 29 頁開始的附錄 H.2 中定義。任何未指定會隨該特定操作碼改變的事物,都預期會保持不變。會改變的變數則以 <something>′ 來指定。

例如,讓我們來看看 ADD (opens 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 位元,所以我們計算該值對 2160 取模 (modulo) (opens in a new tab)

如果 σ[μ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-2929 (opens in a new tab) 中閱讀更多關於此主題的資訊。

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

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

9.5 執行週期

現在我們已經具備了所有部分,終於可以了解 EVM 的執行週期是如何記錄的。

方程式 (155) 說明,給定以下狀態:

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

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

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

至此,EVM 已被完整定義。

結論

數學符號是精確的,這使得黃皮書能夠指定以太坊的每一個細節。然而,它確實有一些缺點:

也許因為這些原因,較新的共識層規範 (opens in a new tab)是用 Python 撰寫的。雖然有用 Python 撰寫的執行層規範 (opens in a new tab),但它們並不完整。除非整份黃皮書也被翻譯成 Python 或類似的語言,否則黃皮書將繼續發揮作用,而能夠閱讀它將會很有幫助。