在鏈上轉 100 個 USDT(ERC-20 代幣)不會新增一條「甲付給乙 100 USDT」的條目。實際發生的是合約裡 balanceOf 映射的取值被改寫:呼叫方減少 100,接收方增加 100;這筆改寫最終落在哪些儲存槽由合約寫法與編譯器決定,協議只約束 balanceOf 回傳什麼。同時,一筆事件日誌(log)被追加進交易收據,日誌是給鏈下索引器消費的副產品,餘額的真相只在儲存的當前值裡。
帳本類比在這裡失效。帳本記錄已經發生的事,讀者累加歷史得出結論;以太坊保存的是一個會被持續覆蓋的「當前世界」,交易只把它推向下一版。要回答「EVM 到底是什麼」,先要把這個「當前世界」定義清楚,再談誰負責改寫它。
比特幣的帳本模型夠用,因為它的狀態結構極其受限
「帳本」對比特幣是準確的描述。一筆交易的輸入引用此前某筆交易的輸出,輸出在被後續輸入花掉之前,保持為未花費交易輸出(unspent transaction output,UTXO)。一個 UTXO 只能被花費一次,錢包顯示的餘額其實是若干 UTXO 的加總。
比特幣並非沒有狀態,UTXO 集合就是狀態。但這個狀態的結構非常受限。它是一堆只能整體建立、整體花費的「硬幣」,沒有通用的鍵值儲存,腳本也不保存跨交易存在的變數。於是「帳本加未花費輸出集合」足以完整描述整個系統:驗證一筆交易只需要檢查被引用的輸出是否存在、條件腳本是否被滿足,不必查詢任何「合約內部變數」。
以太坊要支援合約持有一個可任意讀寫的映射,這個映射就不能再當作實作細節。ERC-20 轉帳是否有效,取決於合約儲存中呼叫者的餘額槽當前是幾。狀態因此從「驗證時順帶維護的集合」升級為協議必須作出承諾的一等物件。
世界狀態:把「此刻的一切」壓成 32 位元組
以太坊黃皮書把世界狀態(world state)定義為一個從地址到帳戶的映射。每個帳戶是一個四元組:
- nonce:該地址已發出的交易數,或該合約帳戶已建立的合約數;
- balance:以 wei 計價的餘額;
- storageRoot:該帳戶儲存內容的 Merkle Patricia Trie 根雜湊;
- codeHash:該帳戶程式碼的 Keccak-256 雜湊,程式碼本身按這個雜湊存放在狀態資料庫裡。
這裡要區分兩樣東西。storageRoot 承諾的是帳戶自己的鍵值空間,合約作者宣告的狀態變數最終都落在這裡;codeHash 指向的是程式,部署之後不會被某筆交易改寫。把「資料」與「程式碼」用兩個欄位分別承諾,是後面討論讀寫集與衝突偵測時反覆要用的前提(同系列的 0.5《三類儲存別搞混:Memory、Storage 與 Transient Storage》會展開這三類儲存各自的歸屬與壽命)。
所有帳戶再組織成一棵全局的 Merkle Patricia Trie,其根雜湊就是 stateRoot,被寫進區塊頭。也就是說,「此刻整個以太坊長什麼樣」可以被壓縮成 32 個位元組,全網只需要對這 32 個位元組達成共識。
鏈上保存的是狀態根,狀態本身留在節點本地
這裡有一層常被含糊處理的事實:黃皮書明確寫道,世界狀態並不儲存在區塊鏈上,它由每個節點各自維護,鏈上只保留 stateRoot 這個承諾。交易與收據會被完整發布,狀態不會。
這個分工決定了很多使用上的邊界。想知道某個帳戶此刻的餘額,只有兩條路:自己重放全部歷史交易算出狀態,或者向某個節點索要一份可以在 stateRoot 下驗證的 Merkle 證明。輕客戶端走的是第二條路,它不需要保存全部狀態,但需要有人提供證明,而且證明只能說明「在某個 stateRoot 之下這個值是什麼」,無法說明這個 stateRoot 是最新的。後者是共識層的職責。
同樣由此可以理解歸檔節點與裁剪節點的差別。歷史狀態持續累積,保存全部歷史狀態的節點磁碟成本持續成長;只保留近期狀態的節點佔用更小、啟動更快,代價是無法回答任意歷史區塊上的狀態查詢。所謂「鏈上資料」在工程上要拆成兩件事:交易與收據的可用性,狀態根的承諾。混在一起談,後面評估資料可用性與狀態膨脹時會失去分辨力。
狀態轉移函數:以太坊規定的是一段函式
黃皮書用一行公式定義交易級語意,記作:
σ_{t+1} ≡ Υ(σ_t, T)
其中 σ 是世界狀態,T 是一筆交易,Υ 是狀態轉移函數。換成更容易讀的寫法就是 Y(S, T) → S'。黃皮書緊接著強調,有效狀態變更只能透過交易發生,而無效的狀態變更遠多於有效變更,例如憑空減少某帳戶餘額而不在其他地方等量增加。
讀懂這層意思,可以換一個說法:以太坊協議真正規定的內容,是「給定當前狀態和一筆交易,下一版狀態是什麼」;至於「鏈上存了什麼」,那只是這段函式的輸入與輸出。區塊層面同理,黃皮書用 Π 把同一個函式沿交易序列摺疊:
Π(σ, B) ≡ Υ(Υ(σ, T₀), T₁)…
狀態機這個詞到這裡才落地:狀態是 σ,轉移規則是 Υ,區塊是轉移的批次。
帳本模型與狀態機模型的驗證成本不同
把兩個模型放在一起比較,差異會落到驗證方式上,而不只是資料組織方式。
UTXO 交易的輸入是顯式宣告的:每個輸入都寫明引用哪筆交易的哪個輸出。驗證者只需在 UTXO 集合裡查這些具體條目,不必理解任何全局結構。這讓比特幣的交易驗證天然可以局部化,互不相干的交易能被獨立處理。宣告式帳戶模型(例如 Solana 的交易訊息裡預先列出帳戶地址,指令按下標引用)走的是同一條思路。
帳戶模型裡,交易的讀寫集合沒有寫在交易裡。合約可以讀取任意地址的任意儲存槽,讀到了什麼只有執行完才知道。驗證者必須持有一份與執行時相同的狀態視圖,否則結果無從重現。這個差異是後面整條技術路線的起點:讀寫集推斷、樂觀並行控制、衝突偵測,處理的都是「不知道一筆交易會碰什麼」這件事。
以太坊也試過向宣告式靠攏。EIP-2930 引入的存取清單(access list)是新交易類型 0x01 的可選欄位,交易可以預先宣告將存取的地址與儲存槽,把它們加入 accessed_addresses 與 accessed_storage_keys 集合以享受折扣。EIP 正文同時寫明,清單之外的存取仍然允許,只是更貴。讀寫集合因此沒有被強制寫進交易,上面這條結論不因它改變。
EVM 在協議裡的位置:規定「怎麼改狀態」的那份規範
以太坊虛擬機(Ethereum Virtual Machine,EVM)是 Υ 的具體執行規範,內容包括一套指令語意、一套 gas 計量規則、一套異常與終止語意。合約程式碼是一串位元組,只有在被交易或其他合約以訊息呼叫(message call)觸發時才進入執行。
EVM 不等於以太坊協議。共識、資料可用性、結算各自是另一層系統,擴容地圖一文對此有專門拆解。EVM 只回答一件事:面對一段位元組碼和一份輸入,如何確定性地算出狀態變更與回傳值。它也決定這個計算要花多少錢,而 gas 計價規則本身就是共識的一部分,可以被硬分叉修改,EIP-2929 把首次存取帳戶與儲存槽的定價顯著抬高就是例子。
還有一處容易混淆的地方:EVM 的機器模型不只有「堆疊」。每次執行上下文都有堆疊(stack)、記憶體(memory)、程式計數器(program counter,PC)與 gas 計數器,合約的持久變數則透過 SLOAD/SSTORE 落到帳戶儲存裡。堆疊、記憶體與執行迴圈是 0.4 的主題,三類儲存的邊界是 0.5 的主題。
全網重放:為什麼結果必須逐位一致
以太坊沒有中心執行者。每個全節點獨立執行同一批交易,各自算出 stateRoot,再透過共識比較這個值。任何執行細節上的分歧都會直接變成共識失敗,所以規範必須細到位元組。
由此推出幾條硬約束。浮點運算沒有進入指令集,只有基於 256 位字長整數的算術。牆鐘、行程 ID、真實隨機數這類外部輸入不能出現在共識路徑上;合約能讀到的時間戳與區塊號來自區塊頭,是被共識固定的值。
計算還必須可計量且必然終止,否則無法在有限區塊裡安全執行任意程式碼。gas 是把「停機問題」改寫成「花光預算就停」的辦法,它的計量常數也因此屬於共識參數而不是效能調校項。一筆普通轉帳的固有成本是 21000 gas,calldata 裡每個零位元組計 4 gas、非零位元組計 16 gas,這些數字由黃皮書的費用表固定。改動它們與改動區塊大小屬於同一類協議變更,需要硬分叉或驗證者訊號,不能由某個客戶端自行決定。
確定性還有一個容易被忽略的後果:規範不允許存在「未定義行為」。黃皮書寫明 DIV 在除數為 0 時回傳 0,而不拋異常。這個選擇的理由,是讓所有客戶端在同一份非法輸入上得到同一個結果,與語言設計偏好無關。客戶端的單元測試與 execution-spec-tests 規範測試套件,就是用來鎖住這類邊界行為的。多個獨立客戶端(geth、revm、evmone 等)並存,本身也是確定性的校驗手段:一旦某個實作對邊緣情形理解不同,鏈就會分叉,因此分歧必須在測試階段被發現。
確定性與可預測是兩回事
需要區分兩個層次的不確定性。執行層是確定的:同樣的位元組碼、同樣的輸入、同樣的狀態,必然得到同樣的結果。交易能拿到什麼結果卻不只由執行層決定。同一筆交易放在區塊的不同位置,前序狀態不同,結果就可能不同。搶跑與三明治的收益來自排序權,執行層本身仍是確定的。
這個區分在評估平行執行方案時很關鍵。平行改造要守住的是「執行結果與某個串列順序等價」;至於「結果與交易提交時間無關」,這個目標從來就不成立。
EVM 不是凍結的規格
把 EVM 當成固定標準會帶來相容性誤判。規格隨硬分叉演進:PUSH0(EIP-3855,Shanghai)為堆疊機新增一條推入常量 0 的指令;EIP-2929 重新定價了冷熱存取;交易層引入了單筆交易 16,777,216 gas 的上限(EIP-7825,隨 Fusaka 上線);EIP-7935 把客戶端預設的區塊 gas 上限提到 60M。
因此「相容 EVM」這句話必須帶上分叉高度與具體範圍才有意義。位元組碼、預編譯合約、JSON-RPC 與工具鏈各自相容到什麼程度,屬於延伸閱讀裡那篇的範圍。
這套模型在什麼條件下會成為負擔
以狀態為中心的設計買到了通用計算,代價寫在三個地方。
全節點必須長期保存一份隨時可讀的當前狀態,其規模隨帳戶與儲存條目成長,持續抬高硬體門檻。
新節點要自行算出 stateRoot,就必須重放歷史交易或依賴快照同步,驗證成本與歷史長度掛鉤。
由於每筆交易都可能觸碰全局狀態的任意位置,經典實作只能逐筆串列執行,這是後面所有平行執行討論要處理的原始約束。
反過來說,帳本類比的失效也不是絕對的。審計、索引、對帳這類場景仍然按事件流處理資料,因為收據裡的日誌本來就是為鏈下消費設計的。問題只在於不要把這種視角當成協議模型。
資料來源
- Ethereum Yellow Paper(Upsilon、Pi、世界狀態的存放方式、帳戶四元組、DIV 語意、gas 費用表):https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org,Ethereum Virtual Machine (EVM):https://ethereum.org/developers/docs/evm/
- ethereum.org,Understanding the Yellow Paper's EVM Specifications:https://ethereum.org/developers/tutorials/yellow-paper-evm/
- Bitcoin Developer Guide,Transactions(UTXO 只能被花費一次、輸入引用具體輸出):https://developer.bitcoin.org/devguide/transactions.html
- Solana Docs,Transactions(交易訊息中預先列出帳戶地址,指令按下標引用):https://solana.com/docs/core/transactions
- ethereum.org,Nodes and clients(全節點、歸檔節點與狀態裁剪;歸檔節點才保留全部歷史狀態):https://ethereum.org/developers/docs/nodes-and-clients/
- EIP-2929,Gas cost increases for state access opcodes:https://eips.ethereum.org/EIPS/eip-2929
- EIP-2930,Optional access lists(類型 0x01 交易的可選欄位;清單之外的存取仍然允許,只是更貴):https://eips.ethereum.org/EIPS/eip-2930
- EIP-3855,PUSH0 instruction:https://eips.ethereum.org/EIPS/eip-3855
- EIP-7825,Transaction Gas Limit Cap(16,777,216 gas):https://eips.ethereum.org/EIPS/eip-7825
- EIP-7935,Set default gas limit to 60M:https://eips.ethereum.org/EIPS/eip-7935
- Ethereum Foundation,Fusaka Mainnet Announcement:https://blog.ethereum.org/2025/11/06/fusaka-mainnet-announcement
- go-ethereum,core/vm/instructions.go(opDiv 在除數為 0 時寫入 0):https://github.com/ethereum/go-ethereum/blob/master/core/vm/instructions.go
- ethereum/execution-spec-tests(規範一致性測試套件):https://github.com/ethereum/execution-spec-tests
