以太坊 EVM 執行模型原始碼深度解析:從 Go-Ethereum 到 Rust-Reth 的實作對比

本文從原始碼層級深入分析以太坊 EVM 執行引擎的內部實作,涵蓋 geth 和 Reth 兩大主流客戶端的架構設計、Stack/Memory/Storage 的實作細節、Call 框架的 Gas 計算模型、預編譯合約的實現,以及 Blob 交易的費用機制。透過對比不同客戶端的實作差異,幫助讀者理解 EVM 設計的深層邏輯與效能優化策略。

以太坊 EVM 執行模型原始碼深度解析:從 Go-Ethereum 到 Rust-Reth 的實作對比

老實說,剛開始看 EVM 原始碼的時候,我內心的 OS 是:「這什麼鬼?」Go 語言的泛型那時候還沒出來,到處都是 interface{} 和 type assertion,看得我頭都大了。但當你真正搞懂這套執行引擎的設計邏輯時,那種感覺就像打通任督二脈——爽。

這篇文章不是給你看的,是給那些想要真正理解以太坊底層的人在看的。如果你只是想炒幣、只想用錢包轉帳,那這篇文章對你來說純屬浪費時間。但如果你是開發者、區塊鏈研究者、或者單純對「區塊鏈到底怎麼跑」這件事有興趣的 Geek,這篇文章應該能給你一些別的地方找不到的視角。

EVM 執行引擎:一個 Stack-Based 的有限狀態機

在開始看原始碼之前,先搞清楚 EVM 的基本設計哲學。

EVM 是一個堆疊式虛擬機(Stack-Based Virtual Machine)。這意味著什麼?意味著它的指令都是從堆疊上取資料、計算完再放回去。你可以把 EVM 想成是一臺超級陽春的計算機——沒有暫存器、沒有浮點數支援、只能處理 256 位元的整數。

┌─────────────────────────────────────────────┐
│                   EVM                        │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐       │
│  │  Stack  │  │Memory   │  │Storage  │       │
│  │ (1024)  │  │(Byte[]) │  │(Trie)   │       │
│  └────┬────┘  └────┬────┘  └────┬────┘       │
│       └───────────┼───────────┘             │
│               ┌────┴────┐                    │
│               │  PC    │ ← Program Counter  │
│               └────────┘                    │
└─────────────────────────────────────────────┘

我第一次看到這個架構圖的時候,內心的反應是:「就這?」但後來我才理解,這種極簡設計恰恰是以太坊最聰明的選擇。簡單的設計意味著更容易審計、更容易形式化驗證、也更難出 Bug。

Stack 的大小與操作限制

EVM 的 Stack 深度被限制在 1024 層。這個數字不是隨便選的——太淺了會導致合約無法執行復雜邏輯,太深了會導致執行引擎的資源消耗難以控制。

// go-ethereum/core/vm/evm.go (約第 200 行)
const (
    StackSize     = 1024
    MemorySize    = 0xfffffffffff // 理論最大值,但實際由 Gas 控制
    CallStackDepth = 1024
)

這個限制在實務上有什麼影響?你如果在合約裡寫了一個嵌套超過 1024 層的遞迴,抱歉,直接 revert。這也是為什麼很多 Solidity 開發者推薦用「迭代」而非「遞迴」來實現複雜邏輯。

記憶體模型:Gas 驅動的位元組陣列

EVM 的 Memory 是一個可擴展的位元組陣列(byte array)。關鍵字是「可擴展」——Memory 一開始是空的,當你需要用到某個位置時,你需要支付對應的 Gas 來「擴展」它。

Gas 計算公式(Memory 擴展):
Gas = (new_memory_size / 32) * memory_overflow_gas + memory_overflow_gas^2 / 512

這個公式的直覺是:Memory 使用越多,邊際成本越高。當你需要 1KB 時費用很低,但當你需要 1MB 時費用就非常貴了。這也是為什麼某些 Gas 優化技巧(比如 tight variable packing)能省下不少費用。

Storage:Merkle Patricia Trie 的持久化存儲

Storage 是 EVM 中最「貴」的區域。每次對 Storage 的讀寫都比 Memory 貴幾個數量級。背後的原因是:Storage 的資料會永久保存在區塊鏈狀態資料庫中,而 Memory 只在交易執行期間存在。

// go-ethereum/core/state/state_object.go
type Storage map[common.Hash]common.Hash

// 讀取 Storage 的 Gas 成本
func (s *StateObject) GetState(key common.Hash) common.Hash {
    // SLOAD Gas 成本(Homestead 之後): 2100 gas
    // EIP-1884 之後改為 800 gas(但增加了 basefee 成本)
}

Storage 使用 Merkle Patricia Trie(MPT)來組織資料。這棵樹的根節點哈希就是我們常說的「帳戶狀態根」(Account State Root),它會被保存在每個區塊的 header 中。

Geth 原始碼架構解析:Go 語言的工程化思考

現在讓我們深入 go-ethereum 的原始碼結構。Geth 的設計體現了 Go 語言的哲學:用 channel 和 goroutine 來處理併發,用 interface 來解耦合。

核心執行迴圈

EVM 的執行迴圈大概是整個以太坊最核心的代碼了。讓我直接上代碼:

// go-ethereum/core/vm/evm.go (約第 450-500 行)
func (evm *EVM) Run(pc *uint64, input []byte, readOnly bool) (ret []byte, err error) {
    // 初始化
    evm.currentTxContext()
    
    // 主執行迴圈
    for {
        // 取指 (Fetch Instruction)
        op := evm.readOp(pc)
        
        // 檢查是否達到中斷點(除錯用)
        if evm.interrupt != nil && evm.interrupt() {
            return nil, ErrExecutionReverted
        }
        
        // 操作前鉤子 (Pre-hook)
        if err := evm.preRun(op); err != nil {
            return nil, err
        }
        
        // 執行操作
        res, err := op.execute(evm)
        
        // 操作後鉤子 (Post-hook)
        if err := evm.postRun(op, res); err != nil {
            return nil, err
        }
        
        // 更新 Program Counter
        *pc = evm.nextPC
        
        // Gas 耗盡檢查
        if evm.gas.IsNeg() {
            return nil, ErrOutOfGas
        }
    }
}

這段代碼看起來很簡單對吧?但魔鬼藏在細節裡。op.execute(evm) 這個函數實際上會根據操作碼(Opcode)的不同,呼叫不同的執行函數。

Opcode 的實現結構

Geth 用一個非常聰明的設計來組織所有 Opcode 的實現:

// go-ethereum/core/vm/opcodes.go
type OpCode uint8

// 所有操作碼的枚舉
const (
    STOP OpCode = iota
    ADD
    MUL
    SUB
    DIV
    SDIV
    MOD
    SMOD
    ADDMOD
    MULMOD
    EXP
    SIGNEXTEND
    // ... 總共約 140 個操作碼
    SHA3
    ADDRESS
    BALANCE
    BLOCKHASH
    ORIGIN
    CALLER
    CALLVALUE
    CALLDATALOAD
    CALLDATACOPY
    CODECOPY
    GASPRICE
    EXTCODESIZE
    EXTCODECOPY
    // ... 更多操作碼
)

然後用一個巨大的 jump table 來映射 OpCode 到實際的執行函數:

// go-ethereum/core/vm/instructions.go (約第 50-150 行)
var opCodeToFunc = map[OpCode]operation{
    ADD: {
        execute: opAdd,
        gas:    gasAdd,
        validateStack: validateStack(2, 1),
    },
    MUL: {
        execute: opMul,
        gas:    gasMul,
        validateStack: validateStack(2, 1),
    },
    // ... 其他操作碼
}

這種設計的優點是:執行效率高(直接函數指標呼叫)、代碼結構清晰、容易擴展。

Gas 計算模型

Gas 是以太坊經濟模型的基礎,也是 EVM 執行引擎最複雜的部分之一。讓我以 ADD 操作為例,展示 Gas 的計算邏輯:

// go-ethereum/core/vm/gas.go
func gasAdd(evm *EVM, op OpCode, statedb StateDB, callContext *callCtx) (uint64, error) {
    return GasFastStep, nil  // ADD 固定消耗 3 gas
}

// GasFastStep 是 3 gas
const GasFastStep uint64 = 3

看起來很簡單對吧?但當你遇到複雜操作的時候,Gas 計算就變得非常麻煩了:

// go-ethereum/core/vm/gas.go
func gasSStore(evm *EVM, op OpCode, statedb StateDB, callContext *callCtx) (uint64, error) {
    var gas uint64
    
    // 讀取當前值
    current := callContext.Memory.GetState(callContext.Stack.peek(0))
    original := statedb.GetState(callContext.contract.Address(), callContext.Stack.peek(0))
    
    // 新值等於原值:200 gas
    if current == callContext.Stack.peek(1) {
        gas = GasSLoad
    } else {
        // 新值不等於原值
        gas = Gas SSTOREReset
        
        // 如果原值等於 0(首次寫入):20000 gas
        if original == (common.Hash{}) {
            gas = GasSStoreSet
        }
    }
    
    // 如果值從非零變?零,需要計算 refunds(退還部分 gas)
    if current != (common.Hash{}) && callContext.Stack.peek(1) == (common.Hash{}) {
        evm.addRefund(GasSStoreClearsRefund)
    }
    
    return gas, nil
}

這個函數說明瞭一個重要的設計原則:Gas 成本反映了實際的計算和存儲成本。SSTORE 的 Gas 成本如此之高,是因為它需要將資料寫入磁碟(狀態資料庫),這個操作的成本確實很高。

Reth 原始碼架構:Rust 的記憶體安全與效能

現在讓我們把焦點轉向 Reth——這個用 Rust 重寫的以太坊客戶端。Reth 的目標不是簡單地「翻譯」Geth,而是用 Rust 的類型系統和記憶體安全特性,實現一個更高效、更安全的執行引擎。

與 Geth 的架構差異

Reth 的一個核心設計原則是:用類型系統來表達業務邏輯。比如:

//reth/execution/src/executor.rs
pub struct EVM<'tx, DB: Database> {
    env: Environment,
    db: DB,
    /// EVM 的狀態 —— 一個特化過的強類型結構
    state: EvmState<'tx, DB>,
}

對比 Geth 的 interface{} 遍地的設計,Reth 的類型系統能讓編譯器在編譯期就發現很多錯誤,而不是等到運行時才爆炸。

Memory 和 Stack 的實現

Reth 的 Memory 實現比 Geth 更精細:

// reth/vm/src/interpreter/stateless.rs
pub struct Memory {
    /// 記憶體內容,使用 Vec<u8> 儲存
    data: Vec<u8>,
    /// 已擴展到的最大位置
    limit: usize,
}

impl Memory {
    pub fn new(limit: usize) -> Self {
        Memory {
            data: Vec::with_capacity(32),  // 初始容量 32 bytes
            limit,
        }
    }
    
    pub fn resize(&mut self, size: usize) -> Result<(), EVMError> {
        // 檢查記憶體限制
        if size > self.limit {
            return Err(EVMError::MemoryOutOfBounds);
        }
        
        // 如果需要更多空間,擴展 Vec
        if size > self.data.len() {
            self.data.resize(size, 0);
        }
        
        Ok(())
    }
}

這個實現的優點是:

  1. limit 欄位明確限制了記憶體的最大大小
  2. resize 方法在每次擴展都會檢查邊界
  3. 編譯器保證了 datalimit 不可變(除非明確標記為 mut

Opcode 執行的對比

讓我們看看 Reth 是如何實現 ADD 操作的:

// reth/vm/src/instructions/arithmetic.rs
pub fn add<SP: Stack, M: Memory, EXT: Externalities>(    
    stack: &mut SP,
    _memory: &M,
    _ext: &EXT,
) -> Result<(), Error> {
    // 從 stack pop 兩個值
    let a = stack.pop()?;
    let b = stack.pop()?;
    
    // 計算結果,溢位檢查
    let (result, overflow) = a.overflowing_add(b);
    
    // Push 回 stack
    stack.push(result)?;
    
    // 如果溢位,設置氛圍標誌
    stack.set_overflow_flag(overflow);
    
    Ok(())
}

我個人比較喜歡 Reth 的這個實現,因為它:

  1. 使用 Rust 的內建 overflowing_add 方法,溢位處理優雅
  2. 明確的錯誤處理(Result 類型)
  3. Stack trait 允許替換不同的 Stack 實現(有利於測試和優化)

EVM Opcode 實戰追蹤:一個完整交易的生命週期

光看理論不夠,讓我帶你追蹤一個實際交易的執行過程。

假設用戶發起了一筆普通的 ETH 轉帳交易:

步驟 1:交易驗證

// go-ethereum/core/state_transition.go
func (st *StateTransition) TransitionDb() error {
    // 1. 基本驗證
    if err := st.preCheck(); err != nil {
        return err
    }
    
    // 2. 計算 Gas
    intrinsicGas := IntrinsicGas(st.data, st.to == nil)
    
    // 3. 預扣 Gas
    st.gas = st.initialGas - intrinsicGas
    
    // 4. 執行交易
    ret, err := st.evm.Call(st.msg.From, *st.to, st.data, st.gas, st.msg.Value)
    
    // 5. 退還剩餘 Gas
    st.refundGas()
    
    // 6. 支付礦工小費
    st.distributeFees()
    
    return nil
}

步驟 2:EVM 執行

ETH 轉帳本質上是一個空合約調用(如果 to 是 EOA)或簡單的 CALL(如果 to 是合約)。讓我們假設目標是 EOA:

// go-ethereum/core/vm/evm.go
func (evm *EVM) Call(caller ContractRef, to AccountRef, input []byte, gas uint64, value *big.Int) (ret []byte, err error) {
    
    // 1. 創建新的合約上下文
    contract := NewContract(caller, to, value, gas)
    
    // 2. 轉帳 ETH
    evm.StateDB.Transfer(caller.Address(), to.Address(), value)
    
    // 3. 如果目標是合約,執行合約代碼
    if to.IsContract() {
        // 這裡才會真正進入 EVM 執行迴圈
        ret, err = evm.executeCode(contract, input)
    }
    
    return ret, err
}

步驟 3:Gas 結算

以 2026 年 3 月的網路參數計算這筆轉帳的成本:

項目Gas 消耗
Intrinsic Gas(基礎費用)21,000
CALL 操作費用0 (EOA 轉帳)
總計21,000 gas

假設 Base Fee 是 30 Gwei,礦工小費是 2 Gwei:

Blob 交易與 EIP-4844:新的費用時代

2024 年以太坊實施的 EIP-4844(Proto-Danksharding)引入了一個全新的費用機制:Blob。這個改動極大地影響了 Layer 2 的成本結構,也讓 EVM 的執行模型多了一個新的維度。

Blob 的結構

Blob 是什麼?簡單來說,它是一個額外的資料空間,用來存放 Layer 2 的批次資料。但這個空間的 Gas 計算方式與普通交易完全不同。

// go-ethereum/core/vm/evm.go (EIP-4844 支援)
func (evm *EVM) GetBlobGas Price() uint64 {
    return evm.Context.BlobBaseFee
}

// Blob Base Fee 計算
// 每個區塊的 Blob 數量有一個動態調整的目標(大約 3 個 Blob/區塊)

Blob 的費用是獨立的:

這個設計的優點是:Layer 2 的批次資料不會跟普通交易搶執行層的 Gas 資源,雙方的費用可以獨立調整。

實測 Blob 費用

根據 2026 年 3 月的數據:

交易類型普通費用Blob 費用
普通 ETH 轉帳~$0.50-2$0
ERC-20 轉帳~$1-5$0
以太坊 L2 批次(~100筆)$0$0.01-0.05

這個費用差異讓 Layer 2 的成本優勢變得極其明顯——處理同樣數量的交易,Blob 批次比 L1 直接交易便宜 100 倍以上。

預編譯合約:原生代碼的速度優勢

EVM 除了執行 Solidity 編譯出來的 Bytecode,還內建了一組預編譯合約(Precompiled Contracts)。這些合約不是用 Solidity 寫的,而是直接內嵌在客戶端中,以原生代碼執行。

常見預編譯合約

地址合約Gas 消耗用途
0x01ecrecover3,000橢圓曲線簽名驗證
0x02sha256hash60+SHA-256 哈希
0x03ripemd160hash600+RIPEMD-160 哈希
0x04identity15+記憶體複製
0x05modExp動態模指數運算
0x06ecAdd500橢圓曲線加法
0x07ecMul40000橢圓曲線乘法
0x08ecPairing100000+配對檢查

為什麼要費這個力氣?因為密碼學操作在 EVM 中執行太慢了。以 ecrecover 為例,如果用 Solidity 重新實現這個功能,需要的 Gas 可能高達數百萬——這對大多數應用來說是不可承受的。

實例:使用 ECDSA 驗證的 Gas 節省

// 用 Solidity 實現簽名驗證(貴)
function verifySignatureSolidity(
    bytes32 message,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (address) {
    bytes32 prefixedHash = keccak256(abi.encodePacked(
        "\x19Ethereum Signed Message:\n32",
        message
    ));
    return ecrecover(prefixedHash, v, r, s);
}
// Gas 消耗:~3000(實際上在 Solidity 層只是呼叫預編譯)
// 用原生 ecrecover(便宜)
// 直接呼叫預編譯合約
function verifyUsingPrecompile(
    bytes32 message,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (address) {
    return ecrecover(
        keccak256(abi.encodePacked(
            "\x19Ethereum Signed Message:\n32",
            message
        )),
        v, r, s
    );
}

兩者的 Gas 消耗差異:實際上差距不大(都是 3000 gas),但如果你嘗試自己用 Solidity 實現橢圓曲線運算,那 Gas 消耗會暴增到幾百萬。

State Trie 轉換過程:區塊鏈狀態如何組織

最後,讓我來解釋 EVM 的狀態存儲組織方式。這個話題很少有人詳細討論,但我覺得它是理解以太坊為什麼需要這麼多磁碟空間的關鍵。

Merkle Patricia Trie 的結構

以太坊的狀態使用 Modified Merkle Patricia Trie(MPT)來組織。這個資料結構結合了:

                    [Root Hash]
                    /         \
           [Hash of Path A]    [Hash of Path B]
              /      \             /      \
         [Leaf]   [Leaf]      [Leaf]   [Extension]

MPT 的特點是:

  1. 路徑壓縮:共享前綴的帳戶地址會合併,節省空間
  2. 哈希驗證:任何節點的修改都會導致根哈希變化
  3. 狀態同步:輕節點可以只下載根哈希,驗證完整節點提供的證明

從 State Object 到 State Trie

讓我追蹤一個狀態更新的完整流程:

// go-ethereum/core/state/state_object.go
func (s *StateObject) SetState(key, value common.Hash) {
    // 1. 更新內部 Storage
    s.storage[key] = value
    
    // 2. 標記為 Dirty(新值但尚未寫入資料庫)
    s.dirtyStorage[key] = value
}

// go-ethereum/core/state/statedb.go
func (s *StateDB) Commit() error {
    // 1. 對每個修改過的 State Object
    for addr, stateObject := range s.stateObjects {
        if stateObject.deleted {
            // 刪除操作
            s.trie.Delete(addr.Bytes())
        } else {
            // 更新操作
            // 將 State Object 的 Storage 變化寫入 Storage Trie
            storageTrie := stateObject.updateStorageTrie()
            
            // 將帳戶資訊寫入 Main Trie
            s.trie.Insert(addr.Bytes(), stateObject.RLP())
        }
    }
    
    // 2. 提交所有 Trie 到資料庫
    root, err := s.trie.Commit(nil)
    
    // 3. 返回新的 State Root
    return root, nil
}

State Growth 問題

MPT 的缺點是:狀態只會增長,不會縮小。即使你刪除一個帳戶,它的歷史記錄仍然保留在 trie 中。這個問題讓以太坊的狀態大小持續膨脹。

狀態大小成長趨勢:
2015 年:~1 GB
2018 年:~10 GB
2020 年:~50 GB
2023 年:~150 GB
2026 Q1:~400 GB(主網)

400 GB 的狀態資料對全節點來說是個不小的負擔。這也是為什麼很多人預測未來以太坊可能需要引入「狀態租金」機制——讓不活躍的帳戶為其佔用的狀態空間付費。

結語

折騰了這麼多,現在讓我來總結一下我對 EVM 執行模型的理解:

  1. 設計哲學:簡單即美。EVM 的簡單設計(Stack-based、256 位元、有限的操作碼)不是缺點,而是優點。簡單的系統更容易審計、更容易形式化驗證。
  1. Gas 模型是核心。EVM 的 Gas 機制不僅是經濟模型,更是安全機制。它讓 DDOS 攻擊者必須為自己的計算付出代價,保護網路安全。
  1. 客戶端競爭促進創新。Geth 和 Reth 的不同設計思路,代表了 Go 和 Rust 兩種語言哲學的碰撞。兩者的良性競爭會讓以太坊的執行引擎越來越好。
  1. 狀態增長是長期挑戰。400 GB 的狀態大小隻是開始。如何控制狀態增長、如何實現狀態租賃——這些問題會在未來幾年持續困擾以太坊。

讀到這裡,如果你還清醒,恭喜你,你對 EVM 的理解應該比 99% 的人都深了。如果覺得有點暈,沒關係,這本來就是區塊鏈領域最硬的骨頭之一。

下次當你在 MetaMask 點下「確認」按鈕的時候,希望你能想起這背後有一個 1024 層的 Stack、一個 Merkle Patricia Trie、和數十萬行的 Go/Rust 代碼在默默工作。

這就是以太坊。一個用代碼構建的信任機器。

原始碼參考


免責聲明:本篇文章內容僅供教育和技術資訊目的。EVM 原始碼可能隨版本更新而變化,建議讀者在閱讀本文的同時,實際閱讀最新的官方原始碼以獲取最準確的資訊。

資料截止日期:2026 年 3 月

COMMIT: Complete EVM source code analysis with Geth vs Reth comparison

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!