以太坊 EVM 執行模型深度技術解析:從指令集到狀態轉換的完整旅程

本文深入剖析以太坊虛擬機(EVM)的執行模型,涵蓋帳戶模型、執行環境、Stack/Memory/Storage 三層儲存架構、Opcode 與 Gas 計算、區塊級執行機制、以及子呼叫與訊息傳遞等核心概念。提供詳細的技術解析與實際案例,幫助開發者掌握 EVM 的底層運作原理,寫出更高效的智能合約。


title: 以太坊 EVM 執行模型深度技術解析:從指令集到狀態轉換的完整旅程

summary: 本文深入剖析以太坊虛擬機(EVM)的執行模型,涵蓋帳戶模型、執行環境、Stack/Memory/Storage 三層儲存架構、Opcode 與 Gas 計算、區塊級執行機制、以及子呼叫與訊息傳遞等核心概念。提供詳細的技術解析與實際案例,幫助開發者掌握 EVM 的底層運作原理,寫出更高效的智能合約。

tags:

difficulty: advanced

date: 2026-03-28

parent: null

status: published

references:

url: https://ethereum.github.io/yellowpaper/paper.pdf

desc: 以太坊黃皮書,EVM 正式規格

url: https://www.evm.codes/

desc: 互動式 EVM Opcode 參考

url: https://github.com/ethereum/go-ethereum

desc: Geth 客戶端原始碼

url: https://github.com/ethereum/evmone

desc: 高效能 EVM 解釋器

url: https://docs.soliditylang.org/

desc: Solidity 官方文檔

disclaimer: 本網站內容僅供教育與資訊目的,不構成任何技術或投資建議。


以太坊 EVM 執行模型深度技術解析:從指令集到狀態轉換的完整旅程

為什麼 EVM 值得你深入了解

說實話,我剛開始寫智能合約的時候,壓根沒想過要去研究 EVM 到底是什麼鬼。Solidity 編譯器會幫我處理一切,我只需要寫好邏輯就行了,對吧?

結果等到我想優化 Gas、想理解某個奇怪的 bug、或者想搞清楚某筆交易為什麼貴成那樣子的時候,我發現自己根本離不開 EVM 的知識。於是我花了大概三個月的時間死磕 Yellow Paper,看 Geth 原始碼,在 etherplay 上面折騰各種 opcode。過程很痛苦,但收穫超大——現在我看 DeFi 合約的眼光完全不一樣了,很多以前覺得神的操作,其實就是對 EVM 執行模型的巧妙利用。

所以這篇文章,我想把這個學習過程中最重要的東西濃縮給你。不走學院派路線,我們直接從實務角度切入,搞清楚 EVM 到底是怎麼把一行 Solidity code 變成區塊鏈上可執行狀態轉換的。

帳戶模型:EOA 與合約的恩怨情仇

EVM 的世界裡只有兩種帳戶:外部擁有帳戶(EOA)和合約帳戶(Contract Account)。EOA 由私鑰控制,沒有代碼;合約帳戶由程式碼控制,不能自己發起交易。

這個設計看起來很簡單,但裡面的門道很多。2016 年的 DAO 攻擊之後,很多人開始討論要不要廢除 EOA,讓所有帳戶都變成合約。Vitalik 當時也很支持這個想法,但後來不了了之了。原因是廢除 EOA 會讓系統變得非常複雜——你需要一個「合約」來觸發第一筆交易,那個「合約」的私鑰由誰控制?

現在想想,保留 EOA 的決定其實挺聰明的。它讓整個系統的設計保持簡潔,而且 EIP-7702 的出現,實際上在某種程度上實現了「EOA 臨時變合約」的功能,算是兩個世界的橋梁。

EOA 和合約帳戶的差異在很多地方都會造成困擾。比如你在寫合約的時候,可能會遇到「這筆交易是從 EOA 來的還是從另一個合約來的」的問題。msg.sender 在兩種情況下都會給你發送者的位址,但你可以透過 solidity 的 msg.sender.code.length > 0 判斷這是不是一個合約。

function isContract(address _addr) public view returns (bool) {
    uint256 codeLength;
    assembly {
        codeLength := extcodesize(_addr)
    }
    return codeLength > 0;
}

這個技巧在很多場景下都很有用。比如你在設計一個函數,不希望被其他合約在同一個交易中調用(防止 reentrancy 攻擊),就可以加上這個檢查。

三層儲存架構:Stack、Memory、Storage 的三角戀

我個人認為,理解 EVM 的儲存架構是整個 EVM 學習旅程中最關鍵的部分。EVM 有三種存放資料的地方:Stack、Memory、Storage,它們有著完全不同的特性和效能表現。

Stack(堆疊):最速、最便宜、但容量有限

Stack 是 EVM 用來存放臨時運算資料的地方,最大深度是 1024 個元素,每個元素是 256 位元(32 bytes)。大多數 opcode 操作都在 stack 上進行,比如加法、減法、比較、跳轉等等。

Stack 的特點是快到不行——所有的 stack 操作都只需要消耗 2 到 5 gas。但問題是容量太小,而且只能操作最上面的元素。你想把 stack 中間的某個值拿出來用?不好意思,先把上面的東西挪開。

看個具體例子,以下是一個簡單的 addition 的 bytecode 操作序列:

PUSH1 0x03    // 把 3 推到 stack
PUSH1 0x05    // 把 5 推到 stack
ADD           // 彈出兩個值相加,結果 push 回去

這段程式碼執行完之後,stack 最上面會是 8。整個過程在一個區塊內可能被執行數十億次,所以 EVM 把 stack 操作優化到了極致。

Stack 在 Gas 消耗上是最划算的,這也是為什麼很多高效合約會刻意利用 stack 來做資料暫存,而不是動用到 memory。不過這也讓我見過一些很變態的優化——有人透過精心設計 stack 操作,把一個原本需要 10 萬 gas 的合約降到只剩 3 萬 gas。佩服佩服。

Memory(記憶體):可變大小但代價不菲

Memory 是 EVM 的工作空間,用來存放執行過程中的暫時資料。它是「位元組組(byte array)」的形式,可以動態擴展,但代價很重——每次擴展都需要支付 Gas。

Memory 的定價模型很有趣:讀取和寫入的 Gas 消耗會隨著你存取的位址變高而增加。具體來說:

這個定價模型的目的是防止對 Memory 的大規模滥用。攻擊者可能透過部署一個消耗大量 Memory 的合約,拖慢整個網路的執行速度。所以 Memory 越用越多,Gas 單價越貴——這是一種防 DoS 機制。

Memory 在 Solidity 中的使用方式很直觀。當你宣告一個 uint256 x 作為函數參數,或者在函數內部創建一個臨時變數,這些資料都存在 Memory 裡。Solidity 9 以後,所有的函數參數和回傳值都默認使用 Memory,但老版本可能要自己加 memory 關鍵字。

function demoMemory(uint256[] calldata arr) public pure returns (uint256) {
    // arr 在 calldata 中,不可修改
    uint256 sum = 0;
    for (uint256 i = 0; i < arr.length; i++) {
        sum += arr[i];  // sum 在 stack 上
    }
    return sum;
}

Storage(儲存):最貴、但最持久

Storage 是 EVM 中最昂貴的資料存放地方,所有持久化的狀態都存在這裡。每一次對 Storage 的讀寫,消耗的 Gas 都是天文數字——SLOAD 最低 100 gas(熱門 slot)到 2100 gas(冷門 slot),SSTORE 更誇張,寫入一個新 slot 要 20000 gas,修改已有 slot 要 5000 gas,刪除(設為零)則補貼 15000 gas。

Storage 的設計是「稀疏的 key-value store」。每一個帳戶都有自己的 Storage Trie,所有 32 bytes 的 key 都對應到一個 32 bytes 的 value。Trie 的結構讓 Ethereum 可以高效地驗證和同步狀態,但代價是每一次 Storage 操作都要付出巨大的代價。

我強烈建議每個Solidity 開發者都要把 Storage 的代價刻在腦子裡。以下是我個人的粗略估算:

操作Gas 消耗大約美元成本(@ $2000 ETH)
SLOAD (warm)100$0.0001
SLOAD (cold)2100$0.002
SSTORE (new)20000$0.02
SSTORE (update)5000$0.005
SSTORE (delete)-15000 (refund)-$0.015

所以當你在一個循環裡面做 Storage 寫入的時候,Gas 消耗會直接爆表。我見過有人在一個會被多次呼叫的函數裡,每次都寫入 Storage,結果用戶需要支付幾百美元的 Gas 才能完成一筆交易。這簡直是在搶劫用戶的錢包。

Storage 的另一個特性是它是永久性的。任何寫入 Storage 的資料,都會成為區塊鏈狀態的一部分,直到被明確刪除(set to zero)。這也是為什麼區塊鏈的狀態會不斷膨脹——截至 2024 年底,以太坊的狀態已經超過 100GB 了。

三者的取捨:實務經驗

我自己的經驗法則是:能用 Stack 解決的問題,就不要碰 Memory;能用 Memory 的,就不要動 Storage。

這個原則在很多地方都適用。比如,當你要返回一個動態大小的陣列時,不要每次都往 Storage 陣列 append,而是先在 Memory 裡面計算好,最後一次性返回:

// 不推薦:每次循環都寫 Storage
function badApproach(uint256 n) public returns (uint256[] storage) {
    results.push(n * 2);  // 每次都觸發 SSTORE
}

// 推薦:先在 Memory 計算,最後才寫 Storage
function goodApproach(uint256 n) public view returns (uint256[] memory) {
    uint256[] memory temp = new uint256[](n);
    for (uint256 i = 0; i < n; i++) {
        temp[i] = i * 2;  // Memory 操作,不消耗太多 Gas
    }
    return temp;
}

但要注意的一點是,這個「推薦做法」在某些場景下反而是錯誤的。如果你要呼叫另一個會修改 Storage 狀態的合約函數,你必須傳遞 Storage 指標,不能用 Memory。這也是為什麼 Solidity 有 storage 關鍵字——它允許你明確指定資料的儲存位置。

Opcode 與 Gas 計算:EVM 的經濟模型

如果要說 EVM 最獨特的設計,我會投 Gas 機制一票。這個機制讓 EVM 的執行變成了一個有成本的運算——每一個操作都需要消耗對應的 Gas,而 Gas 需要用 ETH 購買。這種設計解決了著名的「停止問題」:在傳統圖靈機模型中,你沒辦法防止一個程式永遠執行;但在 EVM 裡,即使你的程式進入無限循環,它也會在 Gas 燒完的時候自動停止。

基礎 Gas 消耗

EVM 的 opcode 有幾種不同的 Gas 消耗模式:

  1. 固定消耗:大多數 opcode 的 Gas 消耗是固定的,比如 ADD = 3 gas, MUL = 5 gas, SWAP1 = 3 gas 等等。這些都是最基本的計算,成本極低。
  1. 動態消耗:部分 opcode 的 Gas 消耗是動態的,根據操作的規模而變化。比如 KECCAK256 的基礎消耗是 30 gas,但每個 word(32 bytes)的額外消耗是 6 gas。如果你要計算一個很大的資料的哈希,Gas 會直線上升。
  1. Memory 擴展消耗:如前所述,Memory 的讀寫會觸發擴展成本。這部分在 EIP-2028 以後已經大幅降低了,因為當時把 CALL 函數的 Gas 消耗從 700 降到了 2300——背後的邏輯是,隨著網路升級,我們可以假設更便宜的 Gas 對整個生態系更有利。

EIP-1559 與 Gas 市場

2021 年的倫敦升級引入的 EIP-1559 徹底改變了 Gas 費用的市場機制。在那之前,Gas 價格是由用戶自行競標的,高峰期的時候 Gas price 可能飆升到幾百 gwei。EIP-1559 之後,基礎費用(Base Fee)會根據網路使用率自動調整,而用戶只需要支付「優先費用 + 基礎費用」,不用擔心自己出的價格太高。

這個改動對 EVM 執行模型的影響比較間接,但有兩個值得注意的地方:

  1. Gas 退款的上限:EIP-1559 同時引入了 Gas 退款的上限,規定每筆交易的總退款不能超過耗費 Gas 的 20%。這是因為之前有人透過大量的 SSTORE 退款來實現「免費交易」,這個漏洞被補上了。
  1. Blob 交易:EIP-4844(Proto-Danksharding)在 2024 年引入了 blob 攜帶交易類型,這種交易會產生額外的 data availability 成本,與普通的 EVM 執行成本分開計算。Layer 2 的排序器現在主要透過 blob 交易來提交數據,大幅降低了成本。

實用 Gas 優化技巧

基於我對 EVM Opcode 和 Gas 模型的理解,以下是幾個經過驗證的優化技巧:

減少 Storage 操作次數:把需要多次讀取的 Storage 變數先拉到 Memory:

// 不推薦
function bad(uint256 a, uint256 b) public {
    for (uint256 i = 0; i < 100; i++) {
        data[i] = data[i] + a * b;  // data[i] 每次都要 SLOAD
    }
}

// 推薦
function good(uint256 a, uint256 b) public {
    uint256 temp = a * b;  // 只計算一次
    for (uint256 i = 0; i < 100; i++) {
        uint256 current = data[i];  // SLOAD
        data[i] = current + temp;   // SSTORE
    }
}

使用 Short String 或 Bytes:Solidity 的 stringbytes 底層是一樣的,但 bytes 可以讓你明確指定長度。如果你確定你的資料不會超過 31 bytes,用 bytes31 會比 bytes32 省很多 Memory expansion cost。

批量操作替代循環:如果你的合約邏輯允許,把多個小操作合併成一個大操作:

// 不推薦:每個代幣都要一次 SLOAD + SSTORE
function batchUpdateBad(uint256[] calldata values) external {
    for (uint256 i = 0; i < values.length; i++) {
        balances[msg.sender] += values[i];  // 每次迴圈都有兩個 Storage 操作
    }
}

// 推薦:最後一次性寫入
function batchUpdateGood(uint256[] calldata values) external {
    uint256 total = 0;
    for (uint256 i = 0; i < values.length; i++) {
        total += values[i];  // 全部在 Memory/Sstack 計算
    }
    balances[msg.sender] += total;  // 只觸發一次 Storage 寫入
}

區塊級執行機制:區塊到底怎麼跑的

了解了儲存架構和 Gas 模型之後,現在讓我們把視角拉高,看看 EVM 在區塊級別是怎麼執行的。

區塊結構與執行上下文

以太坊的每個區塊都包含一個交易列表和一個區塊頭(block header)。當礦工或驗證者組裝一個區塊的時候,會依序執行區塊中的每一筆交易,更新世界狀態。

EVM 的執行環境在每筆交易開始時是這樣設定的:

這些環境變數在合約執行過程中是「只讀」的。Solidity 封裝了這些值,讓你可以透過 block.timestampmsg.sender 這樣的形式存取。

Transaction Execution Flow

一筆交易進入 EVM 後,會經歷以下流程:

  1. 初始驗證:檢查交易的 nonce、簽名、Gas limit、費用是否足夠
  2. 預執行:計算交易需要消耗的費用並從發送者扣款
  3. 合約部署或呼叫:如果是合約創建,執行初始化代碼直到返回;如果是一般呼叫,執行目標合約函數
  4. 子呼叫:如果被呼叫的合約內部呼叫了其他合約,會創建新的執行框架(call frame)
  5. 狀態更新:如果執行成功,應用所有Storage 變更;如果執行失敗,回滾所有變更(除了費用扣款)
  6. Gas 退款:退還剩餘的 Gas 給交易發送者

整個過程中,如果任何一步失敗,都會觸發 revert。EVM 會回滾所有尚未「持久化」的變更——注意這裡的關鍵字是「尚未」。在 revert 之前已經寫入 Storage 的資料會被完全抹除,彷彿那筆交易從未發生過。

這就是以太坊區塊鏈的確定性保證——每一筆交易執行後的狀態都是可以直接計算和驗證的。沒有任何「半成功」的交易,所有的失敗都是原子性的。

Call Frame 與訊息傳遞

當合約呼叫另一個合約的函數時,會創建一個新的 call frame。這個 frame 有自己的 Stack 和 Memory,和呼叫者的環境完全隔離。

Call frame 的建立和返回有幾種不同的方式:

我特別想聊聊 DELEGATECALL,因為它在代理合約模式中至關重要。代理合約把所有的 Storage 和 logic 分離——代理合約只負責轉發呼叫,真正的業務邏輯放在一個獨立的 Implementation 合約裡。升級的時候,只需要把 Implementation 位址更新一下就行了。

這種模式的代碼大概長這樣:

// 代理合約
contract Proxy {
    address public implementation;
    
    fallback() external payable {
        assembly {
            let ptr := mload(0x40)
            calldatacopy(ptr, 0, calldatasize())
            let result := delegatecall(
                gas(),
                sload(implementation.slot),
                ptr,
                calldatasize(),
                0,
                0
            )
            returndatacopy(ptr, 0, returndatasize())
            switch result
            case 0 { revert(ptr, returndatasize()) }
            default { return(ptr, returndatasize()) }
        }
    }
}

EVM 在處理這個 fallback 函數的時候,會用 DELEGATECALL 的方式執行 Implementation 合約的代碼,但 Storage 還是寫入 Proxy 合約的 slot。這就是為什麼升級之後,Storage 資料不會消失的原因。

重入攻擊的 Bytecode 層面解析:DAO 事件的技術根源

說到 EVM 攻擊,不得不提重入攻擊(Reentrancy Attack)——這是區塊鏈世界最經典的安全漏洞,也是 2016 年 The DAO 事件的核心原因。很多人只知道「不要Fallback 被外部呼叫」,但對底層到底發生了什麼一知半解。讓我帶你從 bytecode 層面徹底搞懂這個漏洞。

傳統重入 vs 跨函數重入 vs 跨合約重入

重入攻擊有三种形態,威力由弱到強:

1. 單一函數重入(Simple Reentrancy)

攻擊合約在同一個函數被呼叫時反覆呼叫受害合約。

// 受害合約(有漏洞)
contract Victim {
    mapping(address => uint256) public balances;
    
    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount);
        
        // 先轉帳
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
        
        // 後更新餘額 —— 這就是問題所在!
        balances[msg.sender] -= amount;
    }
}

// 攻擊合約
contract Attacker {
    Victim victim;
    
    fallback() external payable {
        if (address(victim).balance >= msg.value) {
            victim.withdraw(msg.value);  // 重入!
        }
    }
    
    function attack() external payable {
        victim.deposit{value: msg.value}();
        victim.withdraw(msg.value);
    }
}

2. 跨函數重入(Cross-function Reentrancy)

利用合約不同函數之間的狀態不一致:

// 受害合約
contract CrossFunction {
    mapping(address => uint256) balances;
    mapping(address => bool) claimed;
    
    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;  // 先扣餘額
        balances[to] += amount;
    }
    
    function claim(address recipient) external {
        if (!claimed[recipient]) {
            recipient.call{value: balances[recipient]}("");
            claimed[recipient] = true;  // 後標記
        }
    }
}

3. 跨合約重入(Cross-contract Reentrancy)

利用 Aave、Uniswap 等 DeFi 協議之間的組合性:

// 攻擊者在同一筆交易中:
// 1. 從 Compound 借出 ETH
// 2. 存入 Aave 作為抵押
// 3. 借出更多 ETH
// 4. 重複直到觸發清算

重入攻擊的 Gas 成本分析

從 Gas 消耗的角度,重入攻擊的成本其實比想像中高:

假設攻擊者有 1 ETH,要進行 N 次重入呼叫:

Gas 消耗 = N × (CALL gas + 轉帳 Gas + 回調函數 Gas)
         = N × (700 + 9000 + 20000) ≈ N × 30,000 gas

以 2026 年平均 Gas 價格 30 gwei 計算:

所以重入攻擊不是無限制的,你的 Gas limit 和攻擊利潤決定了最大重入深度。

防止重入的 Opcode 級別技巧

// 方法一:Checks-Effects-Interactions 模式(最推薦)
function withdrawGood(uint256 amount) external {
    // 1. Checks
    require(balances[msg.sender] >= amount);
    
    // 2. Effects —— 先更新狀態!
    balances[msg.sender] -= amount;
    
    // 3. Interactions —— 最後才與外部合約互動
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

// 方法二:使用 ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureContract is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        // 這個 modifier 會在函數執行前後檢查狀態
        // ...
    }
}

// 方法三:顯式檢查 msg.sender 是否為合約
function isEOA(address account) public view returns (bool) {
    return account.code.length == 0;
}

Solidity 0.8+ 編譯器甚至會在某些情況下自動插入重入檢查——這是 EVM 安全標準提升的表現。


從 Solidity 到 Bytecode:編譯過程大公開

說了這麼多 EVM 的執行細節,最後我們來聊聊 Solidity 程式碼是怎麼變成 EVM 可以執行的 bytecode 的。

這個過程分為幾個步驟:

  1. 解析與語法樹:Solidity 編譯器(solc)首先把你的程式碼解析成一個抽象語法樹(AST),檢查語法錯誤並建立語義模型。
  1. 中間表示(IR):solc 會先把 Solidity 翻譯成一種中間表示(Yul 或某種內部 IR),這個步驟會做一些初步的優化。
  1. 目標代碼生成:IR 會被編譯成 EVM bytecode。不同的編譯器後端(legacy、ir-optimized、via-ir)會產生不同風格的 bytecode。

如果你用 solc --opcodes 輸出 opcode,或者用 solc --bin-runtime 輸出 runtime bytecode,就能看到 Solidity 的原始魔力。以下是一個簡單合約的 bytecode:

// 原始 Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Counter {
    uint256 public count;
    
    function increment() external {
        count += 1;
    }
}

編譯後的 runtime bytecode 大概長這樣(簡化版):

PUSH1 0x80    // 初始化 Memory 指標
PUSH1 0x40    // 初始化 Free Memory Pointer
CALLVALUE     // 檢查是否附帶 ETH
DUP1          // 
...

// increment() 函數
CALLDATASIZE  // 讀取 call data 大小
PUSH1 0x04    // 
...           // 函數選擇器匹配
SLOAD         // 讀取 count
PUSH1 0x01    // 
ADD           // 加 1
SSTORE        // 寫回 Storage
STOP          // 停止執行

你可以用 evm.codes 這個網站實際操作各種 opcode,看看它們對 Stack、Memory、Storage 的影響。這個網站是我學習 EVM 過程中用得最多的工具之一。

結語:EVM 是一座城堡,需要深入了解它的每一塊磚

寫到這裡,我已經涵蓋了 EVM 執行模型的核心概念——帳戶模型、三層儲存架構、Opcode 與 Gas 計算、區塊級執行、以及編譯過程。但說實話,這只是冰山一角。EVM 的世界還有很多值得探索的角落,比如:

如果你看完這篇文章之後,對 EVM 產生了興趣,我的建議是:不要只看文件,去讀原始碼。Geth 的 vm package 是目前最完整的 EVM 實現參考。每一個 opcode 的行為、每一次狀態轉換的邏輯,都寫得清清楚楚。

我個人最喜歡的學習路徑是這樣的:先用 Solidity 寫一個合約,感受「做這件事很貴」;再去查對應的 opcode 消耗,理解「為什麼很貴」;最後看 Geth 原始碼,看看「這個 opcode 是怎麼被執行的」。走完這個流程,你對 EVM 的理解就會完全不同。

祝你在 EVM 的世界裡玩得開心。有問題隨時來聊。


參考資源

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

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

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