EVM Opcode 完整指南:從底層理解以太坊虛擬機

提供完整的以太坊虛擬機(EVM)操作碼參考手冊,涵蓋所有 Opcode 的功能、Gas 消耗計算、儲存與記憶體操作、以及Opcode 層級的安全漏洞分析,幫助開發者深入理解智慧合約底層運作。

EVM Opcode 完整指南:從底層理解以太坊虛擬機

概述

以太坊虛擬機(Ethereum Virtual Machine,EVM)是以太坊智慧合約的執行環境,其核心是一個基于堆疊的(stack-based)圖靈完整虛擬機。理解 EVM Opcode(操作碼)不僅是智慧合約優化的基礎,更是識別安全漏洞、診斷交易失敗原因的關鍵技能。本文提供完整的 Opcode 參考指南,涵蓋每個操作碼的功能、Gas 消耗計算、以及實際應用場景。

對於希望深入理解以太坊底層運作的開發者而言,掌握 Opcode 知識能夠顯著提升合約設計能力與安全審計水平。我們將從基礎概念出發,逐步深入到進階主題,包括Gas優化技巧與常見攻擊向量分析。

一、EVM 基礎架構

1.1 執行環境概述

EVM 是一個隔離的執行環境,每次智慧合約調用都在一個全新的虛擬機實例中進行。這種設計確保了確任性(determinism)——相同的輸入必然產生相同的輸出。

核心組件

執行模型

EVM 採用 Von Neumann 架構,程式碼與資料共存於同一記憶體空間。執行流程如下:

  1. 載入位元組碼(Bytecode)到記憶體
  2. 讀取並解碼當前 PC(Program Counter)指向的 Opcode
  3. 從堆疊彈出所需參數
  4. 執行對應操作
  5. 將結果推回堆疊
  6. PC 遞增或跳轉

1.2 位元組碼結構

智慧合約編譯後生成 EVM 位元組碼,其結構如下:

0x6080604052348015610010575b5b610100818163518c6001600160401b039290811682900b816020015b600080fd5b506004361003d557fe6
 |_____| |_________| |______________________________________________________________|
  PUSH1   操作碼        參數(緊跟在 PUSH 操作碼後)

實際範例:Simple Storage 合約

// SimpleStorage.sol
contract SimpleStorage {
    uint256 public storedData;

    function set(uint256 x) public {
        storedData = x;
    }

    function get() public view returns (uint256) {
        return storedData;
    }
}

編譯後的位元組碼:

608060405234801561000f575b5b610100818163518c6001600160401b039290811682900b60205260205260406020a0600067

讓我們逐步分析這段位元組碼:

位址Opcode說明
0x00PUSH1 0x80將 0x80 推入堆疊
0x02PUSH1 0x40將 0x40 推入堆疊
0x04MSTORE將 0x80 存到 memory[0x40:0x60]
0x05PUSH1 0x04載入 jumpdest
......繼續解碼

1.3 堆疊操作模型

EVM 採用大端序(Big-Endian)表示法,最重要的位元組優先存放。堆疊操作遵循 LIFO(Last In, First Out)原則:

堆疊指令示例

// 堆疊狀態追蹤
PUSH1 0x20  // 堆疊: [0x20]
PUSH1 0x10  // 堆疊: [0x20, 0x10]
ADD         // 彈出 0x10, 0x20; 推入 0x30 // 堆疊: [0x30]
PUSH1 0x05  // 堆疊: [0x30, 0x05]
MUL         // 彈出 0x05, 0x30; 推入 0xF0 // 堆疊: [0xF0]

二、完整 Opcode 參考手冊

2.1 停止與運算操作碼(0x00-0x0F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x00STOP--0停止執行,不消耗 Gas
0x01ADDa, ba + b3整數加法,溢位則回捲
0x02MULa, ba × b5整數乘法,溢位則回捲
0x03SUBa, ba - b3整數減法
0x04DIVa, ba / b5整數除法,除以 0 回 0
0x05SDIVa, ba / b5有符號除法
0x06MODa, ba % b5取餘運算
0x07SMODa, ba % b5有符號取餘
0x08ADDMODa, b, n(a + b) % n8先加後取餘
0x09MULMODa, b, n(a × b) % n8先乘後取餘
0x0AEXPa, baᵇ10*指數運算
0x0BSIGNEXTENDb, x符號擴展5擴展位元組長度

EXP 操作碼 Gas 計算

EXP 的動態 Gas 較為複雜:

Gas = 10 + (exponent_byte_size × gas_per_byte)
其中 exponent_byte_size = ceil(log2(exponent) / 8)

範例:

// EXP Gas 計算
2²⁵⁵ // 指數位元組大小 = 32, Gas = 10 + 32 × 50 = 1610
2¹    // 指數位元組大小 = 1,  Gas = 10 + 1 × 50  = 60

2.2 比較與分支操作碼(0x10-0x1F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x10LTa, b1 if a < b else 03無符號小於
0x11GTa, b1 if a > b else 03無符號大於
0x12SLTa, b1 if a < b else 03有符號小於
0x13SGTa, b1 if a > b else 03有符號大於
0x14EQa, b1 if a == b else 03相等比較
0x15ISZEROa1 if a == 0 else 03邏輯非
0x16ANDa, ba & b3位元 AND
0x17ORa, bab3位元 OR
0x18XORa, ba ^ b3位元 XOR
0x19NOTa~a3位元 NOT
0x1ABYTEi, xx[i]3獲取位元組
0x1BSHLshift, valuevalue << shift3左移(Constantinople)
0x1CSHRshift, valuevalue >> shift3右移(邏輯)
0x1DSARshift, valuevalue >> shift3右移(算術)

有符號 vs 無符號比較

// 範例:有符號與無符號的差異
uint256 a = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
uint256 b = 1;

// 無符號比較:a > b(因為 a = 2^256 - 1)
// 有符號比較:a < b(因為 a = -1 in two's complement)

2.3 密碼學操作碼(0x20-0x2F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x20SHA3offset, lengthkeccak256(memory[offset:offset+length])30 + 6 × wordsKeccak-256 雜湊
0x21SHA256offset, lengthsha256(...)60 + 12 × wordsSHA-256(未啟用)
0x22RIPEMD160offset, lengthripemd160(...)600 + 120 × wordsRIPEMD-160(未啟用)
0x23ECRECOVERv, r, s, hash回復公鑰位址或 03000橢圓曲線恢復

SHA3(Keccak-256)Gas 計算

Gas = 30 + 6 × ceil(length / 32)

範例:

// "Hello" 的長度為 5 位元組
Gas = 30 + 6 × ceil(5/32) = 30 + 6 × 1 = 36
// "Hello World" 的長度為 11 位元組
Gas = 30 + 6 × ceil(11/32) = 30 + 6 × 1 = 36

2.4 環境資訊操作碼(0x30-0x3F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x30ADDRESS-合約位址2當前合約位址
0x31BALANCEaddressaddress.balance2600*帳戶餘額
0x32ORIGIN-交易發起者2EOA 位址
0x33CALLER-msg.sender2調用者位址
0x34CALLVALUE-msg.value2交易金額(wei)
0x35CALLDATALOADimsg.data[i:i+32]3載入 calldata
0x36CALLDATASIZE-msg.data.length2calldata 長度
0x37CALLDATACOPYdest, offset, length-3 + 3 × words複製 calldata
0x38CODESIZE-當前合約代碼長度2-
0x39CODECOPYdest, offset, length-3 + 3 × words複製合約代碼
0x3AGASPRICE-tx.gasprice2交易 Gas 價格
0x3BEXTCODESIZEaddress合約代碼長度2600*-
0x3CEXTCODECOPYaddr,dest,o,l-2600* + 3 × words-
0x3DRETURNDATASIZE-上次調用的回傳資料長度2-
0x3ERETURNDATACOPYd,o,l-3 + 3 × words-
0x3FEXTCODEHASHaddresskeccak256(code)2600*-

2.5 區塊資訊操作碼(0x40-0x4F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x40BLOCKHASHblockNumberblock.blockhash(n)20*區塊雜湊
0x41COINBASE-block.coinbase2礦工位址
0x42TIMESTAMP-block.timestamp2區塊時間戳
0x43NUMBER-block.number2區塊編號
0x44DIFFICULTY-block.difficulty2區塊難度
0x45GASLIMIT-block.gaslimit2Gas 上限
0x46CHAINID-chain.id2鏈 ID
0x47SELFBALANCE-address(this).balance5合約餘額(更便宜)

2.6 堆疊、記憶體、儲存操作碼(0x50-0x5F)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0x50POPa-2彈出堆疊頂
0x51MLOADoffsetmemory[offset:offset+32]3 + 3 × words載入記憶體
0x52MSTOREoffset, value-3 + 3 × words儲存記憶體
0x53MSTORE8offset, value-3儲存單一位元組
0x54SLOADkeystorage[key]2100*載入儲存
0x55SSTOREkey, value-2900 / 20000儲存值
0x56JUMPdest-8無條件跳轉
0x57JUMPIdest, cond-10條件跳轉
0x58PC-pc2程式計數器
0x59MSIZE-記憶體大小2已使用記憶體
0x5AGAS-remaining gas2剩餘 Gas
0x5BJUMPDEST--1跳轉目標標記

2.7 調用操作碼(0xF0-0xFF)

Opcode名稱堆疊輸入堆疊輸出Gas說明
0xF0CREATEvalue, offset, lengthnewAddress32000部署新合約
0xF1CALLgas, addr, value, argsOffset, argsLen, retOffset, retLensuccess100* + 動態調用其他合約
0xF2CALLCODEgas, addr, value, argsOffset, argsLen, retOffset, retLensuccess100* + 動態同上下文調用
0xF3RETURNoffset, length-0返回資料
0xF4DELEGATECALLgas, addr, argsOffset, argsLen, retOffset, retLensuccess100* + 動態委託調用
0xFASTATICCALLgas, addr, argsOffset, argsLen, retOffset, retLensuccess100* + 靜態靜態調用
0xFDREVERToffset, length-0回滾交易
0xFEINVALID--0無效指令
0xFFSELFDESTRUCTbeneficiary-30000自毀合約

三、Gas 消耗深度分析

3.1 靜態 vs 動態 Gas

EVM 的 Gas 消耗分為兩大類:

靜態 Gas(固定)

這些操作的 Gas 消耗是固定的,與執行環境無關:

操作類型Gas 消耗
基本算術(ADD, SUB, etc.)3-5
堆疊操作(PUSH, POP, DUP, SWAP)3
邏輯運算(AND, OR, XOR)3
環境查詢(ADDRESS, CALLER)2

動態 Gas(變動)

這些操作的 Gas 消耗取決於執行時的狀態:

總 Gas = 靜態 Gas + (記憶體words × memoryGas) + 額外操作 Gas

3.2 記憶體 Gas 模型

記憶體擴展採用線性定價,遵循以下規則:

記憶體擴展成本

memoryGas = ceil(newSize / 32) - ceil(oldSize / 32)
cost = memoryGas × memoryGas / 512 + (3 × memoryGas)

簡化理解

記憶體範圍額外 Gas
0-0.2 KB(0-64 bytes)~3
0.2-1 KB(64-352 bytes)~24
1-2 KB~90
每增加 1 倍成本增加 ~3 倍

實際範例

// 場景 1:簡單函數
function simple() external {
    uint256 a = 1;  // 記憶體不擴展
}
// Gas ≈ 21000 (交易成本)

// 場景 2:記憶體陣列
function withArray(uint256 n) external {
    uint256[] memory arr = new uint256[](n);  // 擴展記憶體
    arr[0] = 42;
}
// 當 n=10: memoryGas = ceil(320/32) - ceil(0/32) = 10
// memoryCost = 10×10/512 + 3×10 ≈ 30
// 總成本大幅增加

3.3 儲存操作 Gas 詳解

儲存(Storage)是 EVM 中最昂貴的操作。

Gas 計算規則

操作Cold AccessWarm Access
SLOAD2100100
SSTORE (set 0→非0)200002900
SSTORE (非0→非0)29002900
SSTORE (非0→0)50005000

冷存取定義

「Cold Access」指的是在當前交易中首次訪問某個存儲槽。Warm Access 指的是在當前交易中已訪問過的存儲槽。

實務建議

  1. 批量更新:減少 SSTORE 次數
// 不佳做法:多次 SSTORE
function updateMultipleBad(uint256[] calldata values) external {
    for (uint256 i = 0; i < values.length; i++) {
        data[i] = values[i]; // 每次都觸發 SSTORE
    }
}

// 較佳做法:使用 scratch space
function updateMultipleBetter(uint256[] calldata values) external {
    assembly {
        // 在 memory 中組裝資料,一次性寫入
    }
}
  1. 刪除恢復:使用 DELETE 而非設為 0
// DELETE 實際上會退還 Gas
function cleanup() external {
    delete data;  // 退還約 15000 Gas(如果原本非0)
}

3.4 調用操作 Gas 計算

CALL 系列操作的 Gas 消耗最為複雜:

CALL Gas = 100 (cold) / 100 (warm) + 子調用消耗 + 額外

子調用 Gas 轉遞

// 完整 Gas 轉遞語法
success := call(gas, addr, value, argsOffset, argsLen, retOffset, retLen)

// 限制子調用 Gas
success := call(gas / 2, addr, value, argsOffset, argsLen, retOffset, retLen)

DELEGATECALL 風險

// 典型的代理模式漏洞
function proxyCall(address impl, bytes calldata data) external {
    // 攻擊者可能設置極低的 gas 導致目標合約失敗但代理合約成功
    (bool success, ) = impl.delegatecall(data);
    require(success);
}

3.5 Gas 優化實務技巧

1. 短路運算

// 不佳:總是執行兩個條件
if (condition1 && condition2) { ... }

// 較佳:短路運算
if (condition1) {
    if (condition2) { ... }
}

2. 避免不必要狀態變數

// 浪費 Gas
function computeAndStore(uint256 a, uint256 b) external returns (uint256) {
    result = a * b;  // 寫入存儲
    return result;
}

// 較佳:記憶體計算
function computeOnly(uint256 a, uint256 b) external pure returns (uint256) {
    return a * b;  // 記憶體中計算
}

3. 變數打包

// 未優化:兩個存儲槽
struct Unpacked {
    uint128 a;  // 存儲槽 0
    uint128 b;  // 存儲槽 1
}

// 優化後:一個存儲槽
struct Packed {
    uint128 a;
    uint128 b;
}

4. 使用 Custom Error

// 較舊的做法:require 字串
require(owner == msg.sender, "Not authorized"); // 字串儲存在 bytecode 中

// 較新的做法:custom error
error NotAuthorized();
// Error selector: 0x08214c63
require(owner == msg.sender, NotAuthorized.selector);

四、Opcode 層級攻擊向量分析

4.1 整數溢位攻擊

經典案例:The DAO 事件

// 有漏洞的代碼(2016)
function splitDAO(
    DAOInterface dao,
    uint256 _proposalID,
    address _newCurator
) noEther {
    // 計算回報
    uint256 rewardAccount = balanceOf[msg.sender];  // 可能溢位
    // ...
    balanceOf[msg.sender] = 0;  // 沒有檢查
}

防禦方法

// SafeMath 庫
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    require(b <= a);
    return a - b;
}

// Solidity 0.8+ 內建檢查
uint256 result = a - b;  // 自動 revert if b > a

4.2 重入攻擊

攻擊模式

// 有漏洞的合約
mapping(address => uint256) public balances;

function withdraw() external {
    uint256 balance = balances[msg.sender];
    require(balance > 0);

    // 轉帳在狀態更新前
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success);

    balances[msg.sender] = 0;  // 太晚了!
}

// 攻擊合約
function receive() external payable {
    if (address(victim).balance > 0) {
        victim.withdraw();  // 遞迴調用
    }
}

防禦模式

// 檢查-生效-互動模式
function withdraw() external {
    uint256 balance = balances[msg.sender];
    require(balance > 0, "No balance");

    // 1. 狀態更新先於轉帳
    balances[msg.sender] = 0;

    // 2. 轉帳在最後
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success);
}

// 或使用 ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

4.3 操縱存儲槽攻擊

Slot 讀取順序問題

// 假設攻擊者可控制地址
mapping(address => uint256) public userBalances;

function getBalance() external view returns (uint256) {
    address user = msg.sender;
    return userBalances[user];  // 如果 user = 存儲槽位置?
}

// 攻擊:部署合約使 slot 0 映射到 userBalances

防禦

// 明確指定存儲位置
mapping(address => uint256) public userBalances;

// 使用 assembly 避免編譯器優化干擾

4.4 操縱 Block 參數

Timestamp 操控

// 典型漏洞
function distributeRewards() external {
    require(block.timestamp - lastDistribution >= 1 days);
    // 獎勵發放
}

// 攻擊:礦工可以選擇 block.timestamp(在一定範圍內)

防禦

// 使用多個區塊確認
function distributeRewards() external {
    require(block.timestamp - lastDistribution >= 1 days);
    require(blockhash(block.number - 1) != bytes32(0));  // 確認區塊已最終確認
}

五、Debug 工具與實務應用

5.1 使用 evm-dafny 追蹤執行

指令追蹤範例

原始交易:
to: 0xABC...DEF
data: 0xa9059cbb000000000000000000000000[dest]0000000000000000000000000000000000000a

解碼:
0xa9059cbb = transfer(address,uint256)
0x0000...00dest = 目標地址
0x0000...000a = 數量 (10)

5.2 常見交易失敗診斷

Out of Gas

Gas 消耗分析:
CALL opcode: 21000 (base) + 70000 (contract execution) = 91000
可用 Gas: 80000
結果: REVERT - Out of Gas

Stack Underflow

錯誤代碼: 0x11 (JUMP 目標不是 JUMPDEST)
原因: 嘗試跳轉到資料區域

5.3 Gas 優化實戰

範例:優化的 ERC-20 Transfer

// 未優化版本
function transfer(address to, uint256 amount) public returns (bool) {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    balances[to] += amount;
    emit Transfer(msg.sender, to, amount);
    return true;
}

// 優化版本
function transfer(address to, uint256 amount) public returns (bool) {
    assembly {
        // 單一 SLOAD, 單一 SSTORE
        let slot := balances.slot
        let senderSlot := keccak256(abi.encode(msg.sender, slot))
        let balance := sload(senderSlot)

        if lt(balance, amount) {
            mstore(0x00, 0xcdctedb3) // Error: InsufficientBalance
            revert(0x00, 0x04)
        }

        sstore(senderSlot, sub(balance, amount))
    }
    // ... emit event
}

六、Opcode 與合約安全審計清單

6.1 必檢查項目

  1. 重入防護:所有資金相關函數
  2. 整數溢位:所有算術運算
  3. 存取控制:所有權限函數
  4. 輸入驗證:所有外部輸入

6.2 Opcode 級別審計要點

6.3 Gas 極限建議

操作類型建議 Gas 限制
簡單轉帳21000
ERC-20 transfer50000
標準合約調用100000
複雜 DeFi 操作200000+

結論

理解 EVM Opcode 是智慧合約開發的進階技能。透過掌握這些底層操作碼,開發者能夠:

建議讀者在本地測試環境中使用 Remix 或 Hardhat 的除錯功能,逐步追蹤每個 Opcode 的執行,理解堆疊、記憶體、儲存的狀態變化。這種「透視」能力將顯著提升智慧合約開發的專業水平。

延伸閱讀

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

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

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