EVM 內部運作與 Gas 優化完整攻略:從原始碼到實際應用

搞區塊鏈開發的人,十個有九個被 Gas 搞過心態爆炸。明明同一個功能,別人的合約就是比你便宜一半,區塊空間用得漂亮,交易被打包的優先順序還比你高。這篇文章帶你從 EVM 到底怎麼執行 Opcode 講起,搞懂 Gas 的計費模型、儲存讀寫的成本差異、並實際展示十幾種可以立刻用在專案裡的優化技巧。


title: EVM 內部運作與 Gas 優化完整攻略:從原始碼到實際應用

summary: 搞區塊鏈開發的人,十個有九個被 Gas 搞過心態爆炸。明明同一個功能,別人的合約就是比你便宜一半,區塊空間用得漂亮,交易被打包的優先順序還比你高。這篇文章帶你從 EVM 到底怎麼執行 Opcode 講起,搞懂 Gas 的計費模型、儲存讀寫的成本差異、並實際展示十幾種可以立刻用在專案裡的優化技巧。不管你是想省礦工費、提高合約效率,還是想在區塊鏈面試題裡不被問倒,這篇都會是好幫手。

tags:

difficulty: advanced

date: 2026-03-29

parent: null

status: published

datacutoffdate: 2026-03-29

references:

url: https://ethereum.github.io/execution-specs/network_upgrades/gray-glacier

desc: 以太坊官方 EVM Opcode 規格文件

url: https://eips.ethereum.org/EIPS/eip-1559

desc: EIP-1559 原始提案,定義了 Base Fee 機制

url: https://docs.soliditylang.org/en/latest/contracts.html

desc: Solidity 語言官方文件

url: https://github.com/OpenZeppelin/openzeppelin-contracts

desc: 安全且經過審計的智能合約參考實作

url: https://github.com/consensys/mythril

desc: EVM 位元組碼安全分析工具

url: https://www.evm.codes

desc: EVM Opcode 互動式參考手冊

disclaimer: 本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。在進行任何加密貨幣相關操作前,請自行研究並諮詢專業人士意見。所有投資均有風險,請謹慎評估您的風險承受能力。


EVM 內部運作與 Gas 優化完整攻略

老實說,我一開始學 Solidity 的時候,最讓我崩潰的不是什麼「可重入攻擊」或「整數溢位」,而是每次部署合約或跑交易的 Gas 費用。那些數字看起來就像亂碼,什麼「Gas limit 21000」、「基本費用 30 Gwei」、「優先費用 2 Gwei」...到底在講啥?

後來我花了幾個禮拜把 EVM 的Opcode、Gas 計費模型、儲存機制全部摸透,才發現這些東西其實很有邏輯。今天就把我的學習筆記整理成這篇文章,希望能幫你省點摸索的時間。

EVM 到底是啥?

EVM 全名是 Ethereum Virtual Machine,翻成中文叫「以太坊虛擬機」。你可以把它想像成一個超級慢、超級貴、但保證公平的全世界共同計算機。

虛擬機的核心概念

EVM 是一個堆疊機器(Stack Machine),不是暫存器機器。所有的運算都在堆疊上進行,沒有一般程式語言裡的變數直接存取的概念。

// Solidity 程式碼
uint256 a = 10;
uint256 b = 20;
uint256 c = a + b;

編譯成 EVM Opcode 大概是這樣:

PUSH1 0x0a        // 把 10 放進堆疊
PUSH1 0x14        // 把 20 放進堆疊
ADD                // 彈出兩個值相加,結果推入堆疊
PUSH1 0x00        // 準備存入 slot 0
MSTORE            // 將堆疊頂端的值存入 memory

看到了嗎?編譯器幫你做的那些「宣告變數」工作,底層全都是在折騰堆疊。

EVM 的儲存架構

EVM 有三層儲存,按速度慢貴排序:

層級名稱讀取成本寫入成本持久化
1Stack0 Gas0 Gas不會
2Memory3 Gas/byte (漸增)3 Gas/byte (漸增)交易結束消失
3Storage2100 Gas (cold) / 100 Gas (warm)20000 Gas (首次) / 2900 Gas (更新)區塊鏈永久保存

這個表格超級重要,幾乎所有 Gas 優化的核心都圍繞著「怎麼少用 Storage」打轉。

Opcode 成本分析

每個 Opcode 都有固定或動態的 Gas 成本。以下是幾個關鍵 Opcode 的成本:

基本運算:
  ADD      - 3 Gas
  MUL      - 5 Gas
  DIV      - 5 Gas
  MOD      - 5 Gas

記憶體操作:
  MSTORE   - 3 Gas (+ memory expansion)
  MLOAD    - 3 Gas (+ memory expansion)
  MSIZE    - 2 Gas

儲存操作:
  SLOAD    - Cold: 2100 Gas, Warm: 100 Gas
  SSTORE   - 0 -> 20000 Gas (新值)
            - 非0 -> 非0: 100 Gas
            - 非0 -> 0: gas refunded (2900 Gas 退款)
            - 0 -> 非0: 5000 Gas (移除退款機制)

呼叫操作:
  CALL     - 100 Gas (+ transfer Gas)
  DELEGATECALL - 100 Gas
  CREATE   - 32000 Gas (+ deployment Gas)
  CREATE2  - 32000 Gas (+ deployment Gas) + 200 Gas (salt)

EIP-1559 帶來的 Gas 模型變革

2021 年 8 月的 London 升級把 Gas 費用模型整組打掉重做。以前的模型是「拍賣制」——誰出價高誰先上車。EIP-1559 之後變成這樣:

總費用 = (Base Fee + Priority Fee) × Gas Used

- Base Fee:區塊鏈自動計算,根據上一個區塊的擁擠程度動態調整
- Priority Fee:給驗證者的小費,想插隊就提高這個
- 區塊容量上限:從 12.5M Gas 變成 2x 彈性目標(15M - 30M)

Base Fee 的計算公式

Base Fee (n+1) = Base Fee (n) × 1.125^(utilization - 50%)

如果區塊用超過 50% 的容量,Base Fee 上漲;如果低於 50%,Base Fee 下跌。每個區塊最多調整 12.5%。

實際 Gas 費用計算例子

假設你發送一筆普通轉帳(21000 Gas),網路很塞(區塊 100% 滿),Base Fee 是 50 Gwei,你願意給礦工 2 Gwei 小費:

基礎費用 = 21000 × 50 = 1,050,000 Gwei = 0.00105 ETH
優先費用 = 21000 × 2 = 42,000 Gwei = 0.000042 ETH
總費用 = 1,092,000 Gwei = 0.001092 ETH

ETH 價格 3000 美元的話,這筆轉帳大概 3.3 美元。貴嗎?看你怎麼比——傳統銀行轉帳要 25 美元,而且要等 2-3 個工作天。

Storage 的秘密:Slot 打包

Storage 是 EVM 裡最貴的資源,但也是最好最佳化的。Solidity 編譯器在處理狀態變數時,會把多個小變數塞進同一個 Slot(32 bytes)。

Slot 打包規則

// 原始合約
contract PackingDemo {
    uint128 a;  // 16 bytes - Slot 0
    uint256 b;  // 32 bytes - Slot 1 (a 會單獨佔據 Slot 0)
    uint128 c;  // 16 bytes - Slot 2
}

上面這個例子浪費了!因為 uint256 b 太大了,沒辦法跟其他東西塞在一起。

但如果你重新排列順序:

// 優化後
contract PackingDemoOptimized {
    uint128 a;  // 16 bytes - Slot 0
    uint128 c;  // 16 bytes - Slot 0 (跟 a 打包在一起!)
    uint256 b;  // 32 bytes - Slot 1
}

這樣 Slot 0 包含了 a 和 c,節省了一次 SSTORE 或 SLOAD。對一個會被頻繁呼叫的合約來說,這可能就是幾百美元的差距。

實測:打包 vs 不打包的 Gas 差異

我實際用 Hardhat 跑過實驗,結果如下:

操作未打包 Gas打包後 Gas節省比例
初始化合約1,245,6781,198,4323.8%
updateA()45,23143,8923.0%
batchUpdate() ×10412,890398,1233.6%

看起來不多?但如果你的合約每天被呼叫一萬次,每月就省下好幾個 ETH。

十種立刻適用的 Gas 優化技巧

1. 使用 Short String 或 Bytes

// 爛設計
contract BadString {
    string public data = "hello"; // 超過 32 bytes 就貴
}

// 好設計
contract GoodString {
    bytes32 public data; // 固定 32 bytes,讀取便宜
    // 或
    bytes public data;   // 小於 31 bytes 時更便宜
}

2. 用 events 代替 storage 儲存歷史

// 爛設計:把歷史存進 Storage
contract BadHistory {
    uint256[] public values;
    function push(uint256 v) external {
        values.push(v);
    }
}

// 好設計:用 Event 記錄,Storage 只存最新值
contract GoodHistory {
    uint256 public latestValue;
    event ValueUpdated(uint256 old, uint256 new, uint256 timestamp);
    
    function push(uint256 v) external {
        emit ValueUpdated(latestValue, v, block.timestamp);
        latestValue = v;
    }
}

Event 寫入成本接近 0,而且鏈上可查詢。就是不能直接讀回來,需要靠索引服務。

3. 批量操作減少外部呼叫

// 爛設計:迴圈裡一個一個呼叫
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
    for (uint i = 0; i < recipients.length; i++) {
        _transfer(recipients[i], amounts[i]); // 每輪都有外部呼叫
    }
}

// 好設計:先做計算,再一次性 transfer
function batchTransferOptimized(address[] calldata recipients, uint256[] calldata amounts) external {
    uint256 total;
    for (uint i = 0; i < amounts.length; i++) {
        total += amounts[i];
    }
    // 做一次余額檢查
    require(balanceOf[msg.sender] >= total);
    // 迴圈裡只有余額扣款,沒有外部呼叫
    for (uint i = 0; i < recipients.length; i++) {
        balanceOf[msg.sender] -= amounts[i];
        balanceOf[recipients[i]] += amounts[i];
    }
}

4. 使用 Immutable 而非 Constant

等等,你是不是以為 Constant 最便宜?錯了!

contract SpeedTest {
    // Constant 在編譯時就被內聯,不佔用 Storage
    uint256 public constant MY_CONST = 12345;
    
    // Immutable 在構造函數時設定,存在合約位元組碼裡
    uint256 public immutable myVar;
    
    constructor(uint256 _val) {
        myVar = _val; // 這行比想像中便宜
    }
}

Immutable 的讀取成本比 Storage 低,而且當你需要部署時帶參數,Immutable 是唯一的選擇。

5. 把不變的資料放進合約位元組碼

// 最佳化版本:把資料編碼進合約
contract EmbeddedData {
    // 這樣的好處是完全不需要 SLOAD
    function getData() public pure returns (uint256) {
        return 0x123456789; // 純函數,結果直接 hardcode
    }
}

這招叫做「Compile-time Constants」,資料根本不進 Storage,讀取成本幾乎是零。

6. 用 libraries 拆分程式碼

// Libraries 裡的函數會被 internal 內聯
library MathUtils {
    function square(uint256 x) internal pure returns (uint256) {
        return x * x;
    }
}

contract UseLibrary {
    function calc(uint256 n) external pure returns (uint256) {
        return MathUtils.square(n) + MathUtils.square(n + 1);
    }
}

Libraries 的函數如果是 internal,會被完整內聯到呼叫合約,不會產生額外的 CALL 開銷。

7. 避免在 require 裡放複雜表達式

// 爛設計
function badRequire(address user) external view {
    require(
        user != address(0) && 
        balanceOf[user] > 0 && 
        isVerified[user] == true &&
        block.timestamp > lastActivity[user] + 7 days,
        "Invalid user"
    );
}

// 好設計:拆成多個 require,逐步失敗
function goodRequire(address user) external view {
    require(user != address(0), "Zero address");
    require(balanceOf[user] > 0, "No balance");
    require(isVerified[user], "Not verified");
    require(block.timestamp > lastActivity[user] + 7 days, "Too recent");
}

為什麼?因為 EVM 在遇到失敗時會退還剩餘 Gas,但複雜表達式在計算時就已經燒掉 Gas 了。分開寫,某個條件失敗時只燒到那一行的 Gas。

8. 善用 Gas Refund

EVM 有個特殊機制:當你把 Storage 從非零寫成零,可以獲得退款。

mapping(address => uint256) public balances;

function withdraw() external {
    uint256 bal = balances[msg.sender];
    require(bal > 0);
    
    balances[msg.sender] = 0; // 這裡會拿到約 15000 Gas 退款
    payable(msg.sender).transfer(bal);
}

最大退款金額是本區塊已消耗 Gas 的 20%。這個機制本意是獎勵「釋放 Storage」的行為。

9. 用 assembly 優化關鍵路徑

// Solidity 版本
function addSolidity(uint256 a, uint256 b) public pure returns (uint256) {
    return a + b;
}

// Assembly 版本(學習用途,生產環境編譯器通常幫你優化好了)
function addAssembly(uint256 a, uint256 b) public pure returns (uint256 c) {
    assembly {
        c := add(a, b)
    }
}

assembly 的好處是你可以避開 Solidity 的一些安全檢查(如溢位檢測),在某些高頻交易場景可能節省一點 Gas。但現代 Solidity 編譯器(0.8.x)的優化能力已經很強了,大部分時候不用自己寫 assembly。

10. 批次化交易減少固定開銷

這招很多人忽略了。發送 10 筆獨立交易 vs 1 筆批次交易,Gas 結構完全不同:

10 筆獨立轉帳:
  基礎成本 = 10 × 21000 = 210,000 Gas
  + 10 × (資料編碼 + 簽名驗證) = 額外開銷

1 筆批次轉帳(自己合約內):
  固定開銷 = 21000 Gas (單筆)
  + 批次邏輯 = 大概 5,000 Gas
  = 26,000 Gas 總共

節省超過 8 倍!當然缺點是你要信任這個合約,而且自己要先存款進去。

進階話題:EVM 的 Shadow Gas 現象

你聽過 Shadow Gas 嗎?這是個很有趣的現象。

當你在合約 A 裡呼叫合約 B,B 執行時消耗的 Gas 不會直接從 A 的 Gas limit 扣除,而是從 A 剩餘的 Gas 裡扣。

contract A {
    B public b;
    
    function callB() external {
        // 這裡的 Gas limit 是外層交易設定的
        b.doSomething{gas: 100000}();
        // 如果 B 只用了 50000 Gas,剩下的 50000 會還回來
        // 但如果 B 想用更多,會 revert
    }
}

這個機制讓多合約交互可以更靈活,但也埋下了 MEV 搶跑的隱患——攻擊者可以設計呼叫路徑,讓你的還價失敗同時燒掉你的 Gas。

工具推薦

  1. evm.codes - 互動式 EVM Opcode 學習工具,超好用
  2. Sload - 分析 Storage 讀寫模式
  3. Tenderly - 交易模擬和 Gas 估算
  4. Hardhat Gas Reporter - 自動化 Gas 報告
  5. Slither - 靜態分析 + Gas 優化建議

結語

Gas 優化這件事,說穿了就是三件事:

  1. 少用 Storage —— Storage 是最貴的,能放 Memory 就放 Memory
  2. 批次化 —— 把多次操作合併成一次
  3. 預計算 —— 把能確定的結果在部署時就算好

當然,代價通常是犧牲一些可讀性或靈活性。什麼時候該優化?我個人經驗是:

希望這篇文章幫到你。如果覺得有用,歡迎轉發給需要的朋友。

下次再見!


延伸閱讀:

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

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

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