了解黃皮書的 EVM 規範
黃皮書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 有專門的操作碼來管理 calldata。 calldatasize 會傳回資料的大小。 calldataload 將資料載入堆疊。 calldatacopy 將資料複製到記憶體。
標準的馮紐曼架構opens in a new tab將程式碼和資料儲存在同一個記憶體中。 出於安全考量,EVM 並不遵循此標準——共享揮發性記憶體可能會導致程式碼被更改。 因此,程式碼被儲存到儲存空間中。
只有兩種情況下,程式碼會從記憶體中執行:
- 當一個合約建立另一個合約時(使用
CREATEopens in a new tab 或CREATE2opens in a new tab),合約建構子的程式碼來自記憶體。 - 在建立_任何_合約期間,建構子程式碼會執行,然後從記憶體中傳回合約的實際程式碼。
術語「異常執行」指的是導致目前合約執行停止的例外情況。
9.2 費用概覽
本節說明 Gas 費用的計算方式。 有三種成本:
操作碼成本
特定操作碼的內在成本。 要取得此值,請在附錄 H(第 28 頁,方程式 (327) 下方)中找到操作碼的成本群組,然後在方程式 (324) 中找到該成本群組。 這樣你就會得到一個成本函數,在大多數情況下,它會使用附錄 G(第 27 頁)中的參數。
例如,操作碼 CALLDATACOPYopens in a new tab 是 Wcopy 群組的成員。 該群組的操作碼成本是 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 tab 和 RETURNDATACOPYopens in a new tab,它們的資料大小也在相同的位置。 所以 ⌈μs[2]÷32⌉ 是儲存被複製的資料所需的 32 位元組字組數。 綜合起來,CALLDATACOPYopens in a new tab 的內在成本是 3 Gas,外加每複製一個字組的資料需要 3 Gas。
執行成本
執行我們所呼叫的程式碼的成本。
- 對於
CREATEopens in a new tab 和CREATE2opens in a new tab,指的是新合約的建構子。 - 對於
CALLopens in a new tab、CALLCODEopens in a new tab、STATICCALLopens in a new tab 或DELEGATECALLopens in a new tab,指的是我們所呼叫的合約。
記憶體擴充成本
擴充記憶體的成本(如有必要)。
在方程式 324 中,此值寫為 Cmem(μi')-Cmem(μi)。 再次查看 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 上執行特定操作的原始成本。
9.3 執行環境
執行環境是一個元組 I,它包含了不屬於區塊鏈狀態或 EVM 的資訊。
| 參數 | 存取資料的操作碼 | 存取資料的 Solidity 程式碼 |
|---|---|---|
| Ia | ADDRESSopens in a new tab | address(this) |
| Io | ORIGINopens in a new tab | tx.origin |
| Ip | GASPRICEopens in a new tab | tx.gasprice |
| Id | CALLDATALOADopens in a new tab 等 | msg.data |
| Is | CALLERopens in a new tab | msg.sender |
| Iv | CALLVALUEopens in a new tab | msg.value |
| Ib | CODECOPYopens in a new tab | address(this).code |
| IH | 區塊標頭欄位,例如 NUMBERopens in a new tab 和 DIFFICULTYopens in a new tab | block.number、block.difficulty 等 |
| Ie | 合約間呼叫(包含合約建立)的呼叫堆疊深度 | |
| Iw | EVM 是否允許更改狀態,或者它是否正在靜態執行 |
要了解第 9 節的其餘部分,還需要了解其他一些參數:
| 參數 | 定義於章節 | 意義 |
|---|---|---|
| σ | 2(第 2 頁,方程式 1) | 區塊鏈的狀態 |
| g | 9.3(第 13 頁) | 剩餘 Gas |
| A | 6.1(第 8 頁) | 累積的子狀態(預定在交易結束時發生的變更) |
| o | 9.3(第 13 頁) | 輸出——在內部交易(一個合約呼叫另一個合約)和呼叫檢視函數(當你只是請求資訊,因此無需等待交易時)的情況下的傳回結果 |
9.4 執行概覽
現在我們已經完成了所有的準備工作,終於可以開始研究 EVM 的運作方式了。
方程式 137-142 提供了執行 EVM 的初始條件:
| 符號 | 初始值 | 意義 |
|---|---|---|
| μg | g | 剩餘 Gas |
| μpc | 0 | 程式計數器,下一個要執行的指令的位址 |
| μm | (0, 0, ...) | 記憶體,初始化為全零 |
| μi | 0 | 已使用的最高記憶體位置 |
| μs | () | 堆疊,初始為空 |
| μo | ∅ | 輸出,在我們停止(無論是帶有傳回資料 (RETURNopens in a new tab 或 REVERTopens in a new tab) 還是不帶有 (STOPopens in a new tab 或 SELFDESTRUCTopens in a new tab))之前,均為空集合。 |
方程式 143 告訴我們在執行期間的每個時間點有四種可能的情況,以及如何處理它們:
Z(σ,μ,A,I)。 Z 代表一個函數,用於測試某個操作是否會產生無效的狀態轉換(請參閱異常停止)。 如果其結果為 True,則新狀態與舊狀態相同(除了消耗掉的 Gas),因為變更尚未實作。- 如果正在執行的操作碼是
REVERTopens in a new tab,則新狀態與舊狀態相同,但會損失一些 Gas。 - 如果操作序列已完成(由
RETURNopens in a new tab 表示),則狀態會更新為新狀態。 - 如果我們不處於 1-3 的結束條件之一,則繼續執行。
9.4.1 機器狀態
本節更詳細地解釋了機器狀態。 它指定 w 是目前的操作碼。 如果 μpc 小於程式碼的長度 ||Ib||,則該位元組 (Ib[μpc]) 就是操作碼。 否則,該操作碼定義為 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 tab 和LOG4(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 tab 或SELFDESTRUCTopens in a new tab),則傳回一個大小為零的位元組序列作為傳回值。 請注意,這與空集合非常不同。 此值表示 EVM 確實停止了,只是沒有傳回資料可供讀取。 - 如果我們有一個產生輸出的停止操作碼(
RETURNopens in a new tab 或REVERTopens in a new tab),則傳回該操作碼指定的位元組序列。 此序列取自記憶體,堆疊頂部的值 (μs[0]) 是第一個位元組,其後的值 (μs[1]) 是長度。
H.2 指令集
在我們進入 EVM 的最後一小節 9.5 之前,讓我們先看看指令本身。 它們在附錄 H.2 中定義,從第 29 頁開始。 任何未被該特定操作碼指定為改變的內容,都應保持不變。 確實會改變的變數用 <something>′ 表示。
例如,讓我們看看 ADDopens in a new tab 操作碼。
| 數值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 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。 我們使用第 29 頁方程式 328 中定義的 M 函數來完成此操作。
| 數值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | 取得指定帳戶的餘額。 |
| ... |
我們需要查詢餘額的位址是 μ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 中閱讀更多關於此主題的內容。
| 數值 | 助記符 | δ | α | 描述 |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | 複製堆疊第 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) 是 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日