了解黃皮書的 EVM 規範
黃皮書 (在新分頁開啟)是以太坊的正式規範。除非經過 EIP 流程修改,否則它包含了所有運作方式的精確描述。它是以數學論文的形式撰寫的,其中包含程式設計師可能不熟悉的術語。在本文中,你將學習如何閱讀它,並進而了解其他相關的數學論文。
哪一個版本的黃皮書?
就像以太坊中的幾乎所有事物一樣,黃皮書也會隨著時間演進。為了能夠參考特定版本,我上傳了撰寫本文時的當前版本 (在新分頁開啟)。我使用的章節、頁碼和方程式編號都將參考該版本。建議在閱讀本文件時,在另一個視窗中開啟它。
為什麼是 EVM?
最初的黃皮書是在以太坊開發之初撰寫的。它描述了最初用於保護網路安全的基於工作量證明 (PoW) 的共識機制。然而,以太坊在 2022 年 9 月關閉了工作量證明,並開始使用基於權益證明 (PoS) 的共識機制。本教學將重點介紹黃皮書中定義以太坊虛擬機的部分。EVM 並未因過渡到權益證明而改變(除了 DIFFICULTY 操作碼的傳回值之外)。
9 執行模型
本節(第 14-16 頁)包含了 EVM 的大部分定義。
系統狀態 (system state) 一詞包含了運行系統所需了解的所有資訊。在典型的電腦中,這意味著記憶體、暫存器的內容等。
圖靈機 (Turing machine) (在新分頁開啟) 是一種運算模型。本質上,它是電腦的簡化版本,已被證明具有與一般電腦相同的運算能力(電腦能計算的所有東西,圖靈機也能計算,反之亦然)。這個模型使得證明關於什麼可計算、什麼不可計算的各種定理變得更加容易。
圖靈完備 (Turing-complete) (在新分頁開啟) 一詞指的是一台能夠執行與圖靈機相同計算的電腦。圖靈機可能會陷入無限迴圈,而 EVM 則不會,因為它的燃料 (gas) 會耗盡,所以它只是準圖靈完備 (quasi-Turing-complete) 的。
9.1 基礎知識
本節介紹了 EVM 的基礎知識,以及它與其他運算模型的比較。
堆疊機 (stack machine) (在新分頁開啟)是一種不將中間資料儲存在暫存器,而是儲存在堆疊 (stack) (在新分頁開啟)中的電腦。這是虛擬機的首選架構,因為它易於實作,這意味著出現錯誤和安全漏洞的可能性要小得多。堆疊中的記憶體被劃分為 256 位元的字組 (words)。選擇這種設計是因為它便於進行以太坊的核心密碼學操作,例如 Keccak-256 雜湊運算和橢圓曲線計算。堆疊的最大大小為 1024 個項目(1024 x 256 位元)。執行操作碼時,它們通常會從堆疊中取得參數。有專門用於重新組織堆疊中元素的操作碼,例如 POP(從堆疊頂部移除項目)、DUP_N(複製堆疊中的第 N 個項目)等。
EVM 還有一個稱為記憶體 (memory) 的揮發性空間,用於在執行期間儲存資料。此記憶體被組織成 32 位元組的字組。所有記憶體位置都被初始化為零。如果你執行這段 Yul (在新分頁開啟) 程式碼將一個字組加入記憶體中,它會透過用零填補字組中的空白空間來填滿 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) (在新分頁開啟)將程式碼和資料儲存在同一個記憶體中。基於安全考量,EVM 並未遵循此標準——共用揮發性記憶體會使得更改程式碼成為可能。相反地,程式碼被儲存到儲存空間 (storage) 中。
只有在兩種情況下,程式碼會從記憶體中執行:
- 當一個合約建立另一個合約時(使用
CREATE(在新分頁開啟) 或CREATE2(在新分頁開啟)),合約建構函式的程式碼來自記憶體。 - 在建立任何合約的過程中,建構函式程式碼會執行,然後傳回實際合約的程式碼,這同樣來自記憶體。
異常執行 (exceptional execution) 一詞指的是導致目前合約停止執行的例外狀況。
9.2 費用總覽
本節解釋了燃料費用的計算方式。包含三種成本:
操作碼成本
特定操作碼 (opcode) 的固有成本。要取得此值,請在附錄 H(第 29 頁,方程式 (329) 下方)中找到操作碼的成本群組,並在方程式 (326) 中找到該成本群組。這會給你一個成本函數,在大多數情況下,它使用附錄 G(第 28 頁)中的參數。
例如,操作碼 CALLDATACOPY (在新分頁開啟) 是 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 節(慣例),μ 是機器狀態 (machine state)。機器狀態在第 15 頁的第 9.4.1 節中定義。根據該節,其中一個機器狀態參數是代表堆疊的 s。綜合起來,μs[2] 似乎是堆疊中的第 2 個位置。查看該操作碼 (在新分頁開啟),堆疊中的第 2 個位置是資料的位元組大小。查看 Wcopy 群組中的其他操作碼 CODECOPY (在新分頁開啟) 和 RETURNDATACOPY (在新分頁開啟),它們在相同位置也有資料大小。因此,⌈μs[2]÷32⌉ 是儲存被複製資料所需的 32 位元組字組 (words) 數量。綜合所有資訊,CALLDATACOPY (在新分頁開啟) 的固有成本是 3 單位燃料 (gas),加上每複製一個字組資料 3 單位燃料。
執行成本
執行我們所呼叫之程式碼的成本。
- 在
CREATE(在新分頁開啟) 和CREATE2(在新分頁開啟) 的情況下,為新合約的建構函式。 - 在
CALL(在新分頁開啟)、CALLCODE(在新分頁開啟)、STATICCALL(在新分頁開啟) 或DELEGATECALL(在新分頁開啟) 的情況下,為我們呼叫的合約。
擴充記憶體成本
擴充記憶體的成本(如果需要的話)。
在方程式 326 中,此值寫為 Cmem(μi')-Cmem(μi)。再次查看第 9.4.1 節,我們看到 μi 是記憶體中的字組數量。因此,μi 是操作碼執行前記憶體中的字組數量,而 μi' 是操作碼執行後記憶體中的字組數量。
函數 Cmem 在方程式 328 中定義:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋。⌊x⌋ 是底函數 (floor function),這是一個給定一個值,傳回不大於該值的最大整數的函數。例如,⌊2.5⌋ = ⌊2⌋ = 2。當 a < √512 時,a2 < 512,底函數的結果為零。因此,對於前 22 個字組(704 個位元組),成本會隨著所需記憶體字組的數量呈線性上升。超過該點後,⌊a2 ÷ 512⌋ 為正值。當所需的記憶體夠高時,燃料 (gas) 成本與記憶體數量的平方成正比。
請注意,這些因素只會影響_固有_的燃料成本——它沒有考慮決定終端使用者需要支付多少費用的費用市場或給驗證者的提示——這只是在 EVM 上執行特定操作的原始成本。
9.3 執行環境
執行環境是一個元組 (tuple) I,其中包含不屬於區塊鏈狀態或 EVM 的資訊。
| 參數 | 存取資料的操作碼 | 存取資料的 Solidity 程式碼 |
|---|---|---|
| Ia | ADDRESS (在新分頁開啟) | address(this) |
| Io | ORIGIN (在新分頁開啟) | tx.origin |
| Ip | GASPRICE (在新分頁開啟) | tx.gasprice |
| Id | CALLDATALOAD (在新分頁開啟) 等 | msg.data |
| Is | CALLER (在新分頁開啟) | msg.sender |
| Iv | CALLVALUE (在新分頁開啟) | msg.value |
| Ib | CODECOPY (在新分頁開啟) | address(this).code |
| IH | 區塊頭欄位,例如 NUMBER (在新分頁開啟) 和 DIFFICULTY (在新分頁開啟) | block.number、block.difficulty 等 |
| Ie | 合約間呼叫(包含合約建立)的呼叫堆疊深度 | |
| Iw | EVM 是否被允許改變狀態,或者它是否在靜態執行 |
要理解第 9 節的其餘部分,還需要了解其他幾個參數:
| 參數 | 定義於章節 | 意義 |
|---|---|---|
| σ | 2(第 2 頁,方程式 1) | 區塊鏈的狀態 |
| g | 9.3(第 14 頁) | 剩餘燃料 |
| A | 6.1(第 9 頁) | 累計子狀態(排定在交易結束時發生的變更) |
| o | 9.3(第 14 頁) | 輸出 - 在內部交易(當一個合約呼叫另一個合約時)和呼叫 view 函式(當你只是要求資訊,所以不需要等待交易時)的情況下傳回的結果 |
9.4 執行總覽
現在我們已經具備了所有的基礎知識,終於可以開始探討 EVM 的運作方式了。
方程式 146-151 給出了執行 EVM 的初始條件:
| 符號 | 初始值 | 意義 |
|---|---|---|
| μg | g | 剩餘燃料 |
| μpc | 0 | 程式計數器,下一個要執行指令的地址 |
| μm | (0, 0, ...) | 記憶體,初始化為全零 |
| μi | 0 | 使用的最高記憶體位置 |
| μs | () | 堆疊,初始為空 |
| μo | ∅ | 輸出,為空集合,直到我們停止並帶有傳回資料(RETURN (在新分頁開啟) 或 REVERT (在新分頁開啟))或不帶傳回資料(STOP (在新分頁開啟) 或 SELFDESTRUCT (在新分頁開啟))為止。 |
方程式 152 告訴我們,在執行期間的每個時間點都有四種可能的情況,以及如何處理它們:
Z(σ,μ,A,I)。Z 代表一個測試操作是否建立無效狀態轉換的函數(請參閱異常停止)。如果評估結果為 True,則新狀態與舊狀態相同(除了燃料被消耗之外),因為變更尚未實施。- 如果正在執行的操作碼是
REVERT(在新分頁開啟),則新狀態與舊狀態相同,並會損失一些燃料。 - 如果操作序列已完成(由
RETURN(在新分頁開啟) 表示),則狀態將更新為新狀態。 - 如果我們不在結束條件 1-3 之一,則繼續執行。
9.4.1 機器狀態
本節更詳細地解釋了機器狀態。它指定 w 為目前的操作碼。如果 μpc 小於程式碼長度 ||Ib||,則該位元組 (Ib[μpc]) 就是操作碼。否則,操作碼被定義為 STOP (在新分頁開啟)。
由於這是一台堆疊機 (在新分頁開啟),我們需要追蹤每個操作碼彈出 (δ) 和推入 (α) 的項目數量。
9.4.2 異常停止
本節定義了 Z 函數,它指定了我們何時會發生異常終止。這是一個布林 (Boolean) (在新分頁開啟) 函數,因此它使用 ∨ 表示邏輯或 (logical or) (在新分頁開啟) 以及 ∧ 表示邏輯和 (logical and) (在新分頁開啟)。
如果以下任何條件為真,我們就會發生異常停止:
-
μg < C(σ,μ,A,I) 正如我們在第 9.2 節中看到的,C 是指定燃料成本的函數。沒有足夠的剩餘燃料來支付下一個操作碼。
-
δw=∅ 如果操作碼彈出的項目數量未定義,則操作碼本身也未定義。
-
|| μs || < δw 堆疊下溢 (Stack underflow),堆疊中沒有足夠的項目供目前的操作碼使用。
-
w = JUMP ∧ μs[0]∉D(Ib) 操作碼是
JUMP(在新分頁開啟),且地址不是JUMPDEST(在新分頁開啟)。跳轉_只有_在目的地是JUMPDEST(在新分頁開啟) 時才有效。 -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) 操作碼是
JUMPI(在新分頁開啟),條件為真(非零)因此應該發生跳轉,且地址不是JUMPDEST(在新分頁開啟)。跳轉_只有_在目的地是JUMPDEST(在新分頁開啟) 時才有效。 -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || 操作碼是
RETURNDATACOPY(在新分頁開啟)。在這個操作碼中,堆疊元素 μs[1] 是在傳回資料緩衝區中讀取的偏移量,而堆疊元素 μs[2] 是資料的長度。當你嘗試讀取超出傳回資料緩衝區末端時,就會發生此條件。請注意,對於呼叫資料 (calldata) 或程式碼本身並沒有類似的條件。當你嘗試讀取超出這些緩衝區的末端時,你只會得到零。 -
|| μs || - δw + αw > 1024
堆疊溢位 (Stack overflow)。如果執行操作碼會導致堆疊超過 1024 個項目,則中止。
-
¬Iw ∧ W(w,μ) 我們是否在靜態執行(¬ 是否定 (在新分頁開啟),而當我們被允許改變區塊鏈狀態時 Iw 為真)?如果是這樣,而且我們正在嘗試改變狀態的操作,它就無法發生。
函數 W(w,μ) 在後面的方程式 159 中定義。如果以下條件之一為真,則 W(w,μ) 為真:
-
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} 這些操作碼會改變狀態,無論是透過建立新合約、儲存值,還是銷毀目前的合約。
-
LOG0≤w ∧ w≤LOG4 如果我們被靜態呼叫,我們就無法發出日誌條目。 日誌操作碼都在
LOG0(A0) (在新分頁開啟) 和LOG4(A4) (在新分頁開啟) 之間的範圍內。 日誌操作碼後面的數字指定了日誌條目包含多少個主題 (topics)。 -
w=CALL ∧ μs[2]≠0 當你是靜態時,你可以呼叫另一個合約,但如果你這樣做,你不能轉帳 ETH 給它。
-
-
w = SSTORE ∧ μg ≤ Gcallstipend 除非你有超過 Gcallstipend(在附錄 G 中定義為 2300)的燃料,否則你無法執行
SSTORE(在新分頁開啟)。
9.4.3 跳轉目的地有效性
在這裡,我們正式定義什麼是 JUMPDEST (在新分頁開啟) 操作碼。我們不能只尋找位元組值 0x5B,因為它可能在 PUSH 內部(因此是資料而不是操作碼)。
在方程式 (162) 中,我們定義了一個函數 N(i,w)。第一個參數 i 是操作碼的位置。第二個參數 w 是操作碼本身。如果 w∈[PUSH1, PUSH32],這意味著操作碼是一個 PUSH(方括號定義了包含端點的範圍)。在這種情況下,下一個操作碼位於 i+2+(w−PUSH1)。對於 PUSH1 (在新分頁開啟),我們需要前進兩個位元組(PUSH 本身和一個位元組的值),對於 PUSH2 (在新分頁開啟),我們需要前進三個位元組,因為它是一個兩個位元組的值,依此類推。所有其他 EVM 操作碼都只有一個位元組長,所以在所有其他情況下 N(i,w)=i+1。
這個函數在方程式 (161) 中用於定義 DJ(c,i),它是程式碼 c 中從操作碼位置 i 開始的所有有效跳轉目的地的集合 (set) (在新分頁開啟)。這個函數是遞迴定義的。如果 i≥||c||,這意味著我們處於或超過了程式碼的末端。我們不會再找到任何跳轉目的地,所以只需傳回空集合。
在所有其他情況下,我們透過前往下一個操作碼並取得從它開始的集合來查看程式碼的其餘部分。c[i] 是目前的操作碼,所以 N(i,c[i]) 是下一個操作碼的位置。因此,DJ(c,N(i,c[i])) 是從下一個操作碼開始的有效跳轉目的地集合。如果目前的操作碼不是 JUMPDEST,只需傳回該集合。如果是 JUMPDEST,則將其包含在結果集合中並傳回。
9.4.4 正常停止
停止函數 H 可以傳回三種類型的值。
- 如果我們不在停止操作碼中,則傳回 ∅,即空集合。按照慣例,此值被解釋為布林值 false。
- 如果我們有一個不產生輸出的停止操作碼(
STOP(在新分頁開啟) 或SELFDESTRUCT(在新分頁開啟)),則傳回一個大小為零位元組的序列作為傳回值。請注意,這與空集合非常不同。這個值意味著 EVM 確實停止了,只是沒有傳回資料可讀取。 - 如果我們有一個確實產生輸出的停止操作碼(
RETURN(在新分頁開啟) 或REVERT(在新分頁開啟)),則傳回由該操作碼指定的位元組序列。這個序列取自記憶體,堆疊頂部的值 (μs[0]) 是第一個位元組,其後的值 (μs[1]) 是長度。
H.2 指令集
在我們進入 EVM 的最後一個小節 9.5 之前,讓我們先來看看指令本身。它們在從第 30 頁開始的附錄 H.2 中定義。任何未指定為隨該特定操作碼改變的內容,都預期保持不變。會改變的變數會以 <something>′ 來指定。
例如,讓我們來看看 ADD (在新分頁開啟) 操作碼。
| 值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | 加法運算。 |
| μ′s[0] ≡ μs[0] + μs[1] |
δ 是我們從堆疊中彈出的值數量。在這種情況下是兩個,因為我們正在將最上面的兩個值相加。
α 是我們推回的值數量。在這種情況下是一個,即總和。
所以新的堆疊頂部 (μ′s[0]) 是舊的堆疊頂部 (μs[0]) 和它下方舊值 (μs[1]) 的總和。
與其用一份「讓人眼花撩亂的清單」來瀏覽所有操作碼,本文只解釋那些引入新概念的操作碼。
| 值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x20 | KECCAK256 | 2 | 1 | 計算 Keccak-256 雜湊。 |
| μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
| μ′i ≡ M(μi,μs[0],μs[1]) |
這是第一個存取記憶體的操作碼(在這種情況下,是唯讀的)。然而,它可能會擴展超出記憶體目前的限制,所以我們需要更新 μi。我們使用第 30 頁方程式 330 中定義的 M 函數來執行此操作。
| 值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | 取得給定帳戶的餘額。 |
| ... |
我們需要尋找其餘額的地址是 μs[0] mod 2160。堆疊的頂部是地址,但因為地址只有 160 位元,我們計算該值的模除 (modulo) (在新分頁開啟) 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 是交易先前存取過的地址清單,因此存取這些地址應該更便宜,如第 9 頁的第 6.1 節所定義。你可以在 EIP-2929 (在新分頁開啟) 中閱讀更多關於此主題的資訊。
| 值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | 複製第 16 個堆疊項目。 |
| μ′s[0] ≡ μs[15] |
請注意,要使用任何堆疊項目,我們需要將其彈出,這意味著我們還需要彈出它上面的所有堆疊項目。在 DUP<n> (在新分頁開啟) 和 SWAP<n> (在新分頁開啟) 的情況下,這意味著必須彈出然後推入多達十六個值。
9.5 執行週期
現在我們已經擁有了所有的部分,終於可以理解 EVM 的執行週期是如何記錄的了。
方程式 (164) 說明,給定狀態:
- σ(全域區塊鏈狀態)
- μ(EVM 狀態)
- A(子狀態,交易結束時要發生的變更)
- I(執行環境)
新狀態為 (σ', μ', A', I')。
方程式 (165)-(167) 定義了堆疊以及由於操作碼 (μs) 引起的堆疊變化。方程式 (168) 是燃料 (μg) 的變化。方程式 (169) 是程式計數器 (μpc) 的變化。最後,方程式 (170)-(173) 指定其他參數保持不變,除非被操作碼明確改變。
至此,EVM 已被完整定義。
結論
數學符號是精確的,這使得黃皮書能夠指定以太坊的每一個細節。然而,它確實有一些缺點:
- 它只能被人類理解,這意味著合規性測試 (compliance tests) (在新分頁開啟)必須手動撰寫。
- 程式設計師了解電腦程式碼。 他們可能了解也可能不了解數學符號。
也許因為這些原因,較新的共識層規範 (在新分頁開啟)是用 Python 撰寫的。雖然有用 Python 撰寫的執行層規範 (在新分頁開啟),但它們並不完整。除非整份黃皮書也被翻譯成 Python 或類似的語言,否則黃皮書將繼續發揮作用,而能夠閱讀它將會很有幫助。