以太坊虛擬機(EVM)深度技術指南:從 opcode 到智能合約執行

本文深入剖析以太坊虛擬機(EVM)的底層運作原理,涵蓋 opcode 完整解析、Gas 機制、三層儲存架構、智能合約執行流程、代理合約模式等核心技術。我們提供完整的 opcode 消耗計算範例和可於瀏覽器運行的 JavaScript 互動式程式碼,幫助開發者深入理解 EVM 的內部機制。新增 EVM Object Format (EOF) 和 Statelessness 未來改進方向的討論。

以太坊虛擬機(EVM)深度技術指南:從 opcode 到智能合約執行

你知道嗎,每次你在 Uniswap 上面Swap一個代幣,背後其實是 EVM 在折騰著一連串複雜的棧操作、記憶體讀寫、還有一大堆 Gas 計算。我當初學 Solidity 的時候,寫了幾行代碼就以為自己懂了,直到某天被一位前輩問「你知道你那段代碼編譯後變成什麼 bytecode 嗎?」瞬間啞口無言。後來硬著頭去研究 EVM,愈挖愈深,愈發現這東西遠比表面上看起来要有意思得多。

這篇文章就來把 EVM 的底層運作原理一次說清楚,順便帶點乾貨程式碼,讓你不只是會「寫合約」,而是真正搞懂「合約是怎麼被執行的」。

EVM 到底是什麼

EVM 全名 Ethereum Virtual Machine,官方說法是「一個隔離的運行環境」。我比較喜歡另一種理解方式:把它想像成一台全球共用的計算機,但這台計算機有個很特別的規矩——每次它幫你做事情,你都得付錢,而且這個價格是動態的。

EVM 存在的主要目的是確保所有以太坊節點在執行同一筆交易時,能產生完全相同的狀態變更。這聽起來很簡單,但要做到確定性(deterministic)其實超級難。同一段程式碼在不同的機器上執行,浮點數運算可能結果不同,時間戳可能不同,甚至作業系統的行為也可能不一致。EVM 的設計就把這些不確定性全部消除——不支援浮點數(只有整數)、時間相關操作只能讀取區塊時間戳、沒有任何系統调用。

EVM 的 bytecode 是一種基於棧的指令集語言,目前約有 140 種 opcode。我們用 Solidity 寫的程式碼,最終都會被編譯成這個 bytecode,然後部署到區塊鏈上。部署的 bytecode 分成兩部分:creation bytecode(只在部署時執行一次,用來建構 runtime bytecode)和 runtime bytecode(每次呼叫合約時執行)。

opcode 完整解析:拆開 bytecode 來看

說到 opcode,這絕對是理解 EVM 的核心。 bytecode 本身對人類來說就是一堆十六進位的數字,但一旦知道怎麼解讀,就會發現每個位元組都有它的意義。

算術運算 opcode

EVM 的整數運算範圍是 256 位元(32 bytes)的無符號整數,所有算術運算都是模 2^256。這有個很實際的問題:Solidity 裡的 uint256 如果做 type(uint256).max + 1,不會拋出溢位錯誤,而是會繞回到 0。當然,Solidity 0.8.x 以後的版本預設會做溢出檢查,但這是編譯器幫你加的 wrapper,底層 EVM 本身沒有這個行為。

來看幾個基本的算術 opcode:

// ADD: 彈出棧頂兩個元素,相加後壓入棧
// PUSH1 01  -> 把值 1 壓入棧
// PUSH1 02  -> 把值 2 壓入棧
// ADD       -> 彈出 1 和 2,相加得到 3,壓回棧

// MUL: 乘法
// PUSH1 03
// PUSH1 04
// MUL       -> 結果 12 (0x0c)

// SUB: 減法(注意:結果為負時會 wrap around)
// PUSH1 01
// PUSH1 03
// SUB       -> 結果 2

// DIV: 整數除法(向零取整)
// PUSH1 02
// PUSH1 09
// DIV       -> 結果 4

// MOD: 取餘數
// PUSH1 03
// PUSH1 0a  // 10
// MOD       -> 結果 1

// EXP: 指數運算(Gas 消耗特別高)
// PUSH1 02
// PUSH1 08  // 2^8
// EXP       -> 結果 256

EXP opcode 的 Gas 消耗隨著指數的位元長度線性增加,具體來說是 GA = 10 + 50 * log256(exponent)。如果你在合約裡做 base ** exponent 且 exponent 很大的話,費用會非常可觀。

比較與邏輯運算

// 以下 Solidity 代碼:
uint256 a = 5;
uint256 b = 10;
bool result = a < b; // true

// 編譯後大致的 opcode 序列:
PUSH1 05        // 加載 a
PUSH1 0a        // 加載 b
LT              // 小於比較,結果 true (= 1) 壓入棧

LT(小於)、GT(大於)、EQ(相等)這些比較的結果就是 1 或 0,會被當作普通的 256 位元整數存在棧上。邏輯運算 ANDORXOR 同樣如此,沒有專門的布林類型,一切都只是數字。

控制流:JUMP 和 JUMPI

這大概是 EVM 裡最容易被理解錯的部分了。傳統程式的控制流是直接寫在代碼裡的,if-else、for 迴圈都是。但 EVM 不一樣——它是「goto-based」的,所有控制流都靠 JUMPJUMPI 這兩個 opcode 實現。

// Solidity
function checkValue(uint256 x) public pure returns (uint256) {
    if (x > 100) {
        return x - 100;
    } else {
        return x;
    }
}

編譯後的 opcode 大致是:

PUSH1 00        // 載入 x
PUSH1 64        // 100
GT              // x > 100? -> bool
PUSH1 1a        // JUMPI 目標:else 分支
JUMPI           // 如果 GT 結果為 1,跳到 1a
// if 分支代碼...
PUSH1 0f        // 目標:return
JUMP
JUMPDEST        // 1a: else 分支入口
// else 代碼...
STOP
JUMPDEST        // 0f: return 目標

這裡有個很重要的細節:JUMPDEST 是一個 marker,沒有它 JUMP 過去就是非法的。EVM 只允許跳到包含 JUMPDEST 的位置,這是為了安全——防止有人跳到合約 bytecode 的中間(特別是 PUSH 指令的中間)執行惡意代碼。

另外,JUMPI 的第二個參數(條件)如果是 0,則不跳轉,繼續執行下一條指令。這意味著區塊鏈上同一個合約,相同的 bytecode,可能因為 input 不同而走完全不同的執行路徑。

Calldata 操作

每筆交易攜帶的 input 資料叫做 calldata。讀取 calldata 的 opcode 有幾個關鍵的:

// function foo(uint256 a, uint256 b) public pure
// 調用 foo(0x1234, 0x5678)

// calldata layout:
// 0x00-0x1f: function selector (4 bytes, right-padded to 32 bytes)
// 0x20-0x3f: first parameter a = 0x1234 (padded to 32 bytes)
// 0x40-0x5f: second parameter b = 0x5678 (padded to 32 bytes)
// CALLDATASIZE: 返回 calldata 的位元組數
// CALLDATALOAD: 從指定位置讀取 32 bytes 到棧
//   - PUSH1 24 -> calldataload 讀取 0x24 位置
// CALLDATACOPY: 複製 calldata 到記憶體
//   - 需要三個參數:memOffset, calldataOffset, length

Solidity 編譯器在處理函數調用時,會先用 CALLDATALOAD 讀取 function selector(前 4 bytes),然後根據 ABI 規範解析參數。如果你函數簽名寫錯了,編譯器根本不知道要解析哪些 bytes,runtime 就會跑出 garbage。

Gas 機制:讓計算不再免費

EVM 的 Gas 機制大概是以太坊最有意思的發明之一了。Vitalik 說過:「如果區塊鏈是個去中心化電腦,那 Gas 就是它的油價。」我覺得這個比喻很貼切——油價會波動,Gas 價格也會,而且每次你「踩油門」(執行操作),都得燒油。

Gas 的基本計算

每個 opcode 都有固定的 Gas 消耗基礎值。基礎 Gas 消耗可以分成幾類:

操作類型opcode基礎 Gas
基本運算ADD, SUB, MUL3
比較/邏輯LT, GT, EQ, AND, OR3
記憶體操作MLOAD, MSTORE3 (另外按記憶體使用量收費)
儲存操作SLOAD2100 (寫入 20000)
儲存操作SSTORE5000 (冷存取 2100)
創建合約CREATE32000
調用其他合約CALL2600 (另有 Gas 傳遞費用)
LOG 事件LOG0-LOG4375 + per topic 375 + per byte 8
部署合約CREATE232000 + 200 * code_length

記憶體的 Gas 計算特別有意思,它不是固定的,而是隨著使用的記憶體大小呈平方增長。公式是:

Gas = 3 * words + floor(words^2 / 512)

其中 words = ceil(byte_length / 32)

這意味著記憶體使用到某個臨界點之後,Gas 消耗會急劇飆升。EIP-2028 把 calldata 的每 byte 費用從 68 Gas 降到 16 Gas(後來又調整到 4 或 12,視具體情況),這個改動直接促成了 Layer 2 和 rollup 技術的經濟可行性。

EIP-1559 之後的 Gas 模型

2021 年倫敦升級引入的 EIP-1559 改變了 Gas 費用的結構。現在每筆交易的費用分兩部分:

  1. Base Fee:由網路根據前一個區塊的空間利用率自動調整
  1. Priority Fee(小費):給驗證者的額外獎勵

Base Fee 這部分會被直接「燒掉」(burn),不再分配給礦工。這是 ETH 變得通縮的關鍵機制之一。想像一下:網路忙碌時,大家搶著交易,base fee 飆高,燒掉的 ETH 量也就暴增。

實際 Gas 計算範例

// 一個簡單的 ERC-20 transfer 函數
function transfer(address to, uint256 amount) public returns (bool) {
    require(balanceOf[msg.sender] >= amount);
    balanceOf[msg.sender] -= amount;
    balanceOf[to] += amount;
    emit Transfer(msg.sender, to, amount);
    return true;
}

執行這段程式碼的 Gas 消耗大致拆解:

一趟 transfer 燒掉 50000 Gas 以上是正常的。如果是第一次和某個地址互動(cold access),SSTORE 的費用會更高。

三層儲存架構:Storage、Memory、Stack

EVM 的儲存系統分成三層,每層的特性、效能和 Gas 消耗都截然不同。

Storage(持久化儲存)

Storage 是合約的「硬碟」,所有持久化的狀態都存在這裡。每個合約都有自己獨立的 storage space,槽(slot)從 0 開始,每個槽 32 bytes,用 256 位元的 key(也就是 uint256)來定址。

Solidity 的狀態變量自動映射到 storage slot,這個映射是有規則的:

contract StorageDemo {
    uint256 public simpleVar;      // slot 0
    uint256 public anotherVar;      // slot 1
    uint256[3] public arrayVar;     // slot 2 (但元素位置是 keccak256(2) + i)
    mapping(address => uint256) public mapVar; // slot 3 (但值位置是 keccak256(key . 3))
}

Solidity 的 mapping 底層是這樣算位置的:slot_of_mapping + keccak256(key || slot_of_mapping)。這就是為什麼區塊鏈瀏覽器能看到某個地址在某個合約的 mapping 裡存了多少代幣,但你沒辦法「列出」整個 mapping 的所有 keys——只能一個個查。

Storage 的讀寫成本極高。冷讀(cold SLOAD)是 2100 Gas,冷寫(非零到非零)是 2900 Gas,從零寫入非零則是 20000 Gas。這些數字是 2022 年 Spurious Dragon 升級後的數值,目的是抑制 storage 滥用。

有個小技巧:如果你要頻繁讀取一個狀態變量,但又不想每次都付 SLOAD 的費用,可以在函數一開始把它讀到 memory 裡,之後用 memory 版本。缺點是 memory 沒那麼「持久」,每次函數呼叫都要重新讀取,但讀 memory 的成本只有 3 Gas。

Memory(臨時記憶體)

Memory 是函數執行過程中的「工作記憶體」,函數執行完就清空了。它是 word-addressable 的 byte array,剛開始空的,每次 MLOAD/MSTORE 使用的時候會自動擴展。擴展的代價是 Gmemory = 3 * words + floor(words^2/512)

Solidity 的 calldata 和 memory 操作有一個特別的地方:memory 的擴展是單向的,只能往前,不能往回。也就是說,你申請了 100 個 words 的 memory,即使後來只用了 50 個,Gas 還是按 100 個算。所以最佳實踐是:儘早搞清楚你需要多少 memory,一次性搞定,別讓它在執行過程中被多次擴展。

// 糟糕的寫法:可能導致 memory 多次擴展
function badExample(uint256[] calldata data) public pure {
    uint256 sum = 0;
    for (uint256 i = 0; i < data.length; i++) {
        // 每次迴圈迭代都可能觸發 memory 擴展
        sum += data[i];
    }
}

// 好的寫法:提前複製到 memory(或直接用 calldata)
function goodExample(uint256[] calldata data) public pure returns (uint256) {
    uint256 sum = 0;
    uint256 len = data.length; // 只讀取一次
    for (uint256 i = 0; i < len; i++) {
        sum += data[i];
    }
}

Stack(棧)

棧是最「便宜」的儲存區域,每個操作幾乎都離不開它。1024 個 slot,每個 slot 256 位元,push/pop 都是 0 Gas(只在計算動態 Gas 時才會被計入)。

EVM 的棧操作有幾個重要的:

如果你在 remix IDE 裡打開 opcode 視圖,會看到棧像疊積木一樣往上堆。有趣的是,太深的棧操作反而會更貴,因為 EVM 在執行時需要追蹤更多上下文。

智能合約執行流程:從交易到狀態變更

一筆交易進來到合約執行完,整個流程大概是這樣的:

  1. 交易驗證:檢查簽名、nonce、餘額是否足夠支付 Gas
  2. 初始化 EVM:創建 EVM 实例,設置 context(區塊資訊、發送者等)
  3. 執行 bytecode:按順序讀取並執行每個 opcode
  4. Gas 結算:執行過程中實時扣除 Gas,最後如果有剩餘就退還
  5. 狀態 Commit:所有 SLOAD/SSTORE 的變更寫入區塊的 state trie

這個流程裡最有趣的是 Gas 的實時扣除機制。如果執行到一半 Gas 耗盡,狀態會完全回滾——彷彿這筆交易從來沒有發生過。這就是為什麼 call revert 的時候,你的交易費用還是得付,因為節點已經幫你跑了一段 bytecode 了。

還有個細節:如果合約 A 調用合約 B(透過 CALL opcode),B 的執行是「嵌套」在 A 的執行裡的。A 可以指定傳給 B 多少 Gas,如果 B 把這些 Gas 用完了,B 會 revert,但 A 不一定會 revert——除非 A 自己的 Gas 也耗盡。這個設計叫做「子調用的 Gas 繼承」。

代理合約模式:讓合約也能「升級」

傳統軟體世界裡,版本更新是家常便飯。但在區塊鏈上,合約一旦部署就「刻在石頭上」了,bytecode 無法修改。那開發者怎麼做升級?

答案就是代理合約模式。

核心思路很簡單:合約分兩部分——一個「代理」(proxy),負責儲存狀態;另一個「實現合約」(implementation),負責存放邏輯。用戶永遠調用 proxy,但 proxy 會把調用轉發給 implementation 執行,執行結果再返回給用戶。因為 proxy 的 bytecode 是固定的,代理合約本身不用升級,升級的只是 implementation。

代理合約的 delegatecall

DELEGATECALL 是代理模式的核心opcode。它的語義是:「用另一個合約的代碼,執行在我的儲存空間裡。」

// 代理合約大致結構
contract Proxy {
    address public implementation;
    
    fallback() external payable {
        (bool success, ) = implementation.delegatecall(
            msg.data
        );
        require(success);
    }
}

這段代碼看起來很短,但背後的意思超級重要:當有人調用 proxy,但 proxy 自己的函數列表找不到對應的 selector 時,會進到 fallback() 函數。然後 fallback 把完整的 msg.data(包含 function selector 和參數)delegatecall 給 implementation 合約。Implementation 合約執行時,所有的 SLOAD/SSTORE 都作用在 proxy 的 storage 裡——所以狀態沒丟,但邏輯卻換成了新的。

這個模式有個巨大的坑:Solidity 的 storage layout 必須完全一致。也就是說,proxy 合約和 implementation 合約的狀態變量順序、類型、排列必須一模一樣。稍有差池,delegatecall 的 storage 讀寫就會錯位,資產可能直接蒸發。著名的 Parity 多簽錢包事件就是這個模式踩坑的經典案例——代理合約指向了一個「銷毀合約」(self-destruct),所有資產就真的被銷毀了。

Transparent Proxy 和 UUPS

後來社群又演化出了幾種更安全的代理模式:

現在 OpenZeppelin 的升級插件默認推薦 UUPS,確實比傳統的 proxy 模式更優雅一些。

EVM Object Format (EOF):對 EVM 的大型重構

說完現有架構,我們來看個未來方向。EVM Object Format(簡稱 EOF)是對 EVM bytecode 格式的一次大幅改造提案,目前正在開發中。

現有 EVM bytecode 的問題在哪裡?主要是兩個:

第一,部署時無法驗證 bytecode 的有效性。目前的 bytecode 可以包含任意資料作為 payload,JUMP 的目標可能指向任何地方。EOF 強制要求 bytecode 必須符合特定的 section 結構,非法 bytecode 在部署時就會被拒絕。

第二,runtime 的 bytecode 是靜態的。合約創建時確定的 bytecode 無法動態加載 code sections。EOF 引入了 code sections 的概念,讓合約可以在 runtime 動態調用其他 sections 的 code。

EOF 格式大致結構:
[0xef] [magic] [version]
[total_size]
[kind(runtime)] [instructions_section] [data_section]
[0x00] ... 更多 sections ...

這個格式對 rollup 特別有好處。ZK Rollup 需要在電路上驗證 EVM 執行過程,如果 bytecode 格式是固定的、well-defined 的,那電路設計就會簡化很多。具體來說,EOF 的 sections 之間是隔離的,靜態分析工具和形式化驗證工具也能更容易地處理 bytecode。

另一個重要的 EOF 改動是禁止 JUMPDEST .dynamic——不再允許從 calldata 動態計算 JUMP 目標。這直接堵死了一類 bytecode 混淆技術,但更重要的是,它讓 bytecode 的驗證變得完全靜態化了。

截至 2026 年第一季度,EOF 還沒有完全啟用,但多個客戶端正在積極實現中。熟悉這個方向,對於想要走在技術前沿的開發者來說很重要。

Statelessness 與未來:EVM 的進化方向

最後聊一下 EVM 的長期演化。

現在每個以太坊全節點都需要存儲完整的狀態(state),這個狀態已經突破了好幾百 GB,而且還在持續增長。Statelessness(無狀態化)提案的目的是:讓驗證區塊不需要存儲完整的狀態,只需要下載見證數據(witness)就能驗證區塊的正確性。

具體怎麼做?引入 Verkle Trie 取代現在的 Merkle Patricia Trie。Verkle Trie 的 witness 大小比 Merkle Patricia Trie 小一個數量級(約 30 bytes per slot vs 400 bytes per slot),這讓無狀態驗證在實踐中變得可行。

對 EVM 來說,Statelessness 的影響是:合約不能再假設能夠高效地訪問任意 storage slot 了。因為 stateless 的節點只持有 witness,如果某筆交易需要的 storage slot 不在 witness 裡,就得多請求一次數據,這個延遲是實實在在的。所以 Statelessness 的設計會倒逼開發者優化 storage 訪問模式,盡量把相關的狀態放在相鄰的 slot 裡(因為 witness 是以 slot 為單位組織的)。

我個人覺得這是件好事。開發者被迫更認真地思考 storage 的佈局,而良好的 storage 佈局對效能的影響本來就很大。現在很多人寫合約根本不 care storage 排布,以後 Statelessness 普及了,這個習慣就非得改不可。

結語

折騰 EVM 的底層細節確實不是件輕鬆的事,但搞懂這些之後,你對智能合約的認知會完全不一樣。你不再只是「會用框架寫代碼」,而是真正理解「這段代碼在區塊鏈上到底發生了什麼」。這種理解能幫你寫出更安全的合約、最佳化 Gas、debug 詭異的行為,甚至參與 EVM 本身的協議設計討論。

EVM 已經十歲了,從一開始的一個實驗性設計,到今天支撐著數千億美元的 DeFi 生態,它證明了自己是個經得起時間考驗的架構。儘管有缺點(比如效能不如本地執行、Gas 模型複雜),但它的確定性保證和龐大的生態系統讓它很難被取代。接下來 EOF、Statelessness、一直到未來的 full danksharding,EVM 的演化才剛剛開始。

如果你想深入研究,推薦直接用 evm.codes 這個工具跑 opcode,把每一條指令的行為親手試一遍,比看一百篇文章都有效。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

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

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