EVM Gas 計算深度技術分析:從理論到 Uniswap V4 Hook 與 AAVE V4 真實合約的 Gas 優化實戰
本文深入分析 EVM Gas 計算的根本邏輯,涵蓋坎昆升級後的 Gas 模型變化、Uniswap V4 Hook 合約的常見 Gas 陷阱(如未快取的跨合約呼叫讀取、動態陣列處理)、以及 AAVE V4 風險引擎中的 Gas 優化密技。從真實 DeFi 協議原始碼出發,提供可操作的 Gas 優化技巧,包括 storage packing、反模式修正、以及 EVM opcode 層級的成本分析。
EVM Gas 計算深度技術分析:從理論到 Uniswap V4 Hook 與 AAVE V4 真實合約的 Gas 優化實戰
坦白說,網上關於 Gas 計算的教程,十篇有九篇都在教你「少用 SLOAD」「多用 events」,然後就沒了。這種程度的建議,寫得出一個 Hello World 的 solidity 新手都懂。
但真正能讓你在主網省下幾千美元 Gas 費的,是那些藏在 Uniswap V4 Hook 裡、埋在 AAVE V4 風險引擎中的反模式優雅改寫。
這篇文章,我打算直接把幾個真實 DeFi 協議的合約原始碼拿出來分析,告訴你哪些寫法會把你的 Gas 吃乾抹淨,哪些優化技巧是真金白銀幫你省錢的。數據截止 2026 年 3 月,以太坊坎昆升級後的 EIP-4844 blob 費用模型也會一併討論。
EVM Gas 計算的根本邏輯:先搞清楚你在為什麼付錢
要優化 Gas,你得先搞清楚 EVM 為什麼要收 Gas。這不是為了折騰開發者,而是因為以太坊節點執行你的程式碼時要消耗真實的計算資源和記憶體——這些資源不是免費的。
EVM 的 Gas 機制本质上是一个拍卖市場:你願意為單位計算資源付多少錢,取決於網路當下的擁堵程度。但不管市場價格怎麼波動,每個操作的成本係數是相對固定的,這些係數定義在 EIP-150 和後續的多次調整中。
EVM 操作成本分為三大類:
1. 計算成本(Computational Gas)
- 每個 opcode 的基礎執行費用
- 例如:ADD = 3 gas, MUL = 5 gas
2. 記憶體成本(Memory Gas)
- 按位元組收費,以 WORD(32 bytes)為單位
- 擴展記憶體時收費,縮減不退款
3. 儲存成本(Storage Gas)
- SSTORE:首次寫入 20,000 gas,修改 5,000 gas,刪除 5,000 gas(但退 15,000 gas)
- SLOAD:冷讀取 2,100 gas,熱讀取 100 gas
重點來了:記憶體和儲存是 Gas 消耗的大戶。任何超過基礎計算的複雜操作,最後幾乎都會栽在這兩個地方。
冷知識:坎昆升級後 Gas 模型的變化
2024 年 3 月的坎昆升級(Cancun)把 EIP-4844 帶進了主網。這個升級對 Layer2 用戶影響巨大,因為 blob 交易讓 Rollup 的資料可用性成本直線下降。但對於在主網部署合約的開發者來說,變化也不小:
坎昆升級對 Gas 計算的影響:
1. Blob 資料費用的特殊性
- Blob 資料不是用普通 Gas 計費
- 用的是另一套「blobGasPrice」機制
- 目標:每區塊 3 blob,最大 6 blob
2. Call 成本的調整
- STATICCALL 成本下調(因為預編譯優化)
- 跨合約呼叫更便宜了
3. Blob 交易對 Layer2 的間接影響
- Arbitrum、Base、OP Mainnet 的批次上鍊成本下降 10-50 倍
- 用戶感受到的就是 L2 手續費大幅降低
但別高興太早:Layer2 的手續費降低不代表你在 L2 上部署合約就能為所欲為。L2 的sequencer 仍然要執行你的交易,而且很多 L2 對複雜合約有額外的限制。
Uniswap V4 Hook 合約的 Gas 陷阱:第一手案例分析
Uniswap V4 在 2024 年中旬上線,引入了 Hook 合約這個革命性的概念。簡單來說,Hook 就是一個可以在 swap 生命週期的各個階段插入自訂邏輯的外掛合約。這個設計讓 LP 頭礦、自訂收費、 TWAMM 等高級功能變成了可能。
但代價是:Hook 合約如果寫得不好,會把 Gas 消耗變成一場災難。
案例一:未快取的鉤子狀態讀取
我見過很多新手寫的 Hook 合約是這樣的:
// ❌ 反模式:每次都讀取 storage
contract BadHook {
function beforeSwap(
address sender,
uint256 amount,
bytes calldata data
) external {
// 糟糕:每次 swap 都讀取這個狀態變數
uint256 lastSwapTime = IManager(manager).lastSwapTime();
// 又讀一次
uint256 minInterval = IManager(manager).minInterval();
if (block.timestamp - lastSwapTime < minInterval) {
revert("Swap too frequent");
}
}
}
問題在哪?看 Gas 消耗:
Gas 分析:
未優化版本(每次跨合約呼叫讀取):
- 第一次跨合約呼叫(SLOAD manager):2,100 gas
- 讀取 lastSwapTime(外部呼叫):2,100 gas
- 讀取 minInterval(外部呼叫):2,100 gas
- 總額外開銷:6,300 gas + 跨合約呼叫成本
優化版本(在 swap 前批量讀取):
- 合併為一次 multicall
- 批量讀取改為 local variable 使用
- 額外開銷:降低到 500-800 gas
正確的做法是:
// ✅ 優化版本:批量讀取,快取結果
contract GoodHook {
// 用 memory 變數暫存讀取的數值
struct SwapCache {
uint256 lastSwapTime;
uint256 minInterval;
uint256 fee;
}
function beforeSwap(
address sender,
uint256 amount,
bytes calldata data
) external returns (uint256) {
// 一次性讀取所有需要的狀態
SwapCache memory cache;
// 這裡的 Gas 开销大幅降低
IManager manager = IManager(managerAddr);
cache.lastSwapTime = manager.lastSwapTime();
cache.minInterval = manager.minInterval();
if (block.timestamp - cache.lastSwapTime < cache.minInterval) {
revert("Swap too frequent");
}
return 0; // 不修改 swap 行為
}
}
案例二:Hook 中不必要的動態陣列處理
這是我在真實部署中看到最多的 Gas 殺手:
// ❌ 反模式:在 Hook 中處理動態陣列
contract ExpensiveHook {
mapping(address => uint256[]) public userSwaps;
function beforeSwap(
address sender,
uint256 amount,
bytes calldata data
) external {
// 糟糕:每次 swap 都 push 到動態陣列
// 動態陣列在 memory 和 storage 之间来回拷贝
if (data.length > 0) {
(address[] memory recipients, uint256[] memory amounts) =
abi.decode(data, (address[], uint256[]));
// 這個迴圈超級貴
for (uint256 i = 0; i < recipients.length; i++) {
userSwaps[recipients[i]].push(block.timestamp);
}
}
}
}
Gas 消耗細節分析:
假設 recipients.length = 10
未優化版本的 Gas 消耗:
- 記憶體擴展:每個陣列元素約 3-6 gas
- SSTORE:每個元素 20,000 gas(首次)或 5,000 gas(更新)
- Memory 到 Storage 的拷貝:額外 100-500 gas per item
- 迴圈 overhead:每次迭代約 100-200 gas
總計:一個 10 元素的 swap 額外消耗 50,000-200,000 gas
優化方向:如果真的需要記錄 swap 歷史,考慮用 event log 代替 storage:
// ✅ 替代方案:用 events 代替 storage
contract GoodHookWithEvents {
event SwapRecorded(
address indexed user,
uint256 amount,
uint256 timestamp
);
function beforeSwap(
address sender,
uint256 amount,
bytes calldata data
) external returns (bytes memory) {
// 用 event 記錄,鏈上存儲成本幾乎為零
// 但需要用 The Graph 或其他索引服務來查詢
emit SwapRecorded(sender, amount, block.timestamp);
return "";
}
}
案例三:Hook 與 ReentrancyGuard 的錯誤順序
這個陷阱非常隱蔽,很多自以為寫得安全的合約其實都有這個問題:
// ❌ 反模式:先 unlock 再執行邏輯
contract BadReentrancyPattern {
bool internal locked;
modifier noReentrancy() {
require(!locked, "Reentrancy");
locked = true;
_; // 執行合約邏輯
// 糟糕:這裡已經 unlock 了,但如果 _; revert 了怎麼辦?
locked = false;
}
}
正確的做法是使用 CEI(C Checks Effects Interactions)模式:
// ✅ 正確:嚴格按照 Checks-Effects-Interactions 順序
contract GoodHook {
bool internal locked;
modifier noReentrancy() {
require(!locked, "Reentrancy");
locked = true;
_;
// 重要:unlock 必須在 _; 執行完畢後
locked = false;
}
// Uniswap V4 的 Hook 可以直接返回數值
function beforeSwap(
address sender,
uint256 amount,
bytes calldata data
) external noReentrancy returns (bytes memory) {
// 1. Checks:驗證參數
require(amount > 0, "Zero amount");
// 2. Effects:更新狀態(這裡還在 locked = true 的狀態)
lastSwapBlock = block.number;
// 3. Interactions:執行外部呼叫
// 如果這裡被攻擊者 reenter 回來,locked 仍然是 true
// 攻擊者的呼叫會被 revert
_doExternalCall(amount);
return "";
}
}
AAVE V4 風險引擎的 Gas 優化密技
說完了 Uniswap,再來看看 AAVE V4 的風險引擎。這個模組負責計算用戶的健康因子(Health Factor),決定什麼時候可以借款、借多少、以及什麼時候會被清算。
健康因子的計算是出了名的 Gas 密集型操作,因為它需要遍歷用戶的整個抵押品頭寸、讀取每個資產的最新價格、計算淨值。
反模式一:重複讀取相同的 Reserve Data
// ❌ 反模式:每次都完整讀取 reserve data
function calculateHealthFactorBad(
address user,
address[] calldata assets
) public view returns (uint256) {
uint256 totalCollateralUSD = 0;
uint256 totalDebtUSD = 0;
for (uint256 i = 0; i < assets.length; i++) {
address asset = assets[i];
// 糟糕:每次迴圈都重新讀取這些資料
DataTypes.ReserveData memory reserve =
pool.getReserveData(asset);
// 讀取用戶余額
uint256 userCollateral = IERC20(asset).balanceOf(user);
uint256 userDebt = IDebtToken(
reserve.debtTokenAddress
).balanceOf(user);
// 又讀取價格
uint256 price = oracle.getAssetPrice(asset);
// 計算
totalCollateralUSD += userCollateral * price;
totalDebtUSD += userDebt * price;
}
return totalCollateralUSD * 1e18 / totalDebtUSD;
}
這個函數的問題在於:迴圈中重複讀取了太多次不變的資料。
Gas 消耗分析:
假設 assets.length = 5
未優化版本:
- 每次迴圈調用 pool.getReserveData():~2,100 gas
- 每次讀取用戶余額:~2,100 gas
- 每次讀取 debt token 余額:~2,100 gas
- 每次讀取價格:~500 gas(熱讀取)
- 總計每次迴圈:~6,800 gas
- 整體:~34,000 gas
優化版本(見下文):可降低到 15,000-20,000 gas
正確做法:
// ✅ 優化版本:批量讀取,合理排序讀寫
function calculateHealthFactorGood(
address user,
address[] calldata assets
) public view returns (uint256) {
DataTypes.UserAccountData memory userData =
pool.getUserAccountData(user);
// 直接用 AAVE V4 封裝好的函數
// 這個函數內部已經做了大量優化
return userData.healthFactor;
}
等等,你可能會說:「這不是作弊嗎?」
不完全是。問題在於:很多開發者不知道 AAVE V4 已經把這些計算封裝好了。與其自己重寫,不如直接用協議提供的介面——因為這些介面本身就是 gas-optimized 的,而且有專業團隊維護。
反模式二:不必要的精度處理
// ❌ 反模式:過度使用高精度的除法
function calculateLiquidationBonusBad(
uint256 debtAmount,
uint256 liquidationBonus,
uint256 decimals
) public pure returns (uint256) {
// 糟糕:在 view function 中做大量除法
// EVM 的除法超級貴,每個除法 20-40 gas
uint256 bonus = debtAmount
* liquidationBonus
/ 100
* 10**decimals // 這裡的 10**decimals 在 runtime 計算
/ 1e18;
return bonus;
}
這個問題在高精度的金融計算中特別常見。正確的做法是:
// ✅ 優化:預先計算常量
contract LiquidationCalculator {
// 這些值在部署時就計算好
uint256 public constant PRECISION = 1e18;
uint256 public constant BONUS_SCALE = 1e4; // 10000 = 100%
function calculateLiquidationBonusGood(
uint256 debtAmount,
uint256 liquidationBonus
) public pure returns (uint256) {
// 預先計算比例,避免 runtime 的幂運算
// 例如:liquidationBonus = 10500 表示 105%
// 实际 bonus = debtAmount * 10500 / 10000
return debtAmount * liquidationBonus / BONUS_SCALE;
}
}
Solidity Gas 優化的進階反模式:storage packing
這是一個被嚴重低估的優化點。Solidity 的 storage 是按 32 bytes 為 slot 來組織的,如果你能把多個小於 32 bytes 的變數塞進同一個 slot,不僅能省下 SSTORE 的次數,還能省下 storage 讀寫的 Gas。
// ❌ 反模式:不考慮 storage packing
struct BadConfig {
bool enabled; // 1 byte
uint128 threshold; // 16 bytes
uint128 multiplier; // 16 bytes
uint256 lastUpdate; // 32 bytes
// 問題:bool + 兩個 uint128 = 33 bytes
// 但 Solidity 會把 bool 擴展到 32 bytes
// 導致每個 struct 佔用 3 個 slot
}
contract BadContract {
mapping(address => BadConfig) public configs;
function setConfig(
address user,
bool enabled,
uint128 threshold,
uint128 multiplier
) external {
// 這裡會產生多次 SSTORE
configs[user].enabled = enabled;
configs[user].threshold = threshold;
configs[user].multiplier = multiplier;
configs[user].lastUpdate = block.timestamp;
}
}
Storage 佈局分析:
BadConfig 的 storage 佈局:
Slot 0: [enabled (bool, 32 bytes allocated)]
Slot 1: [threshold (uint128) | multiplier (uint128)]
Slot 2: [lastUpdate (uint256)]
每次更新需要:
- 讀取舊值:SLOAD x3
- 寫入新值:SSTORE x3
- 總計:~20,000 gas for writes + ~6,300 gas for reads
優化版本:
// ✅ 優化版本:手動控制 storage packing
contract GoodContract {
// 明確控制 slot 分配
struct Config {
bool enabled; // 佔用 1 byte,但會和後面的變數共享 slot
uint64 threshold; // 8 bytes -> 和 enabled 共用 Slot 0
uint64 multiplier; // 8 bytes -> 和 enabled 共用 Slot 0
uint64 lastUpdate; // 8 bytes -> 和 enabled 共用 Slot 0
// 實際佈局:[enabled | threshold | multiplier | lastUpdate] = 1 + 8 + 8 + 8 = 25 bytes
// Solidity 會自動優化,讓它們佔用 1 個 slot
}
// 如果需要更精細控制,用位元運算
struct PackedConfig {
uint128 dataA; // 可以存放多個小變數
uint128 dataB; // 用 bit shift 來解析
}
mapping(address => Config) public configs;
// 使用 multicall 模式批量更新
function setConfigBatch(
address[] calldata users,
bool[] calldata enabledValues,
uint64[] calldata thresholds,
uint64[] calldata multipliers
) external {
require(
users.length == enabledValues.length &&
users.length == thresholds.length &&
users.length == multipliers.length,
"Length mismatch"
);
for (uint256 i = 0; i < users.length; i++) {
configs[users[i]].enabled = enabledValues[i];
configs[users[i]].threshold = thresholds[i];
configs[users[i]].multiplier = multipliers[i];
configs[users[i]].lastUpdate = uint64(block.timestamp);
// 實際上這些寫入在同一個 slot,EVM 可能會合併
}
}
}
Uniswap V4 的 Hook 許可清單機制:避免無限迴圈
Uniswap V4 設計了一個「鉤子許可清單」機制,解決了很多人擔心的問題:Hook 合約能不能把整個交易卡死?
答案是:V4 在合約層面加了限制。
// Uniswap V4 Hook 介面
interface IHook {
// 這些函數有嚴格的 Gas 限制
function beforeSwap(
bytes calldata data
) external returns (bytes calldata);
function afterSwap(
bytes calldata data
) external returns (bytes calldata);
}
// Hook 必須在部署時聲明的鉤子函數列表
// V4 的 Hook 合約需要實現這些視圖函數
function getHookPermissions()
external
pure
returns (HookPermissions memory);
這個設計的意義在於:它強制 Hook 開發者思考 Gas 消耗的邊界。如果你的 Hook 邏輯太複雜,你的交易就會因為 Gas 不足而失敗——這是一個市場化的「質量控制」機制。
結語:Gas 優化是一種藝術
說了這麼多,我想表達的核心觀點只有一個:Gas 優化不是靠記規則,而是靠理解 EVM 的運作原理。
當你知道 EVM 為什麼要收 Gas、知道每個操作的真實成本、知道哪些操作會觸發昂貴的記憶體擴展或 storage 讀寫,你寫的合約自然就會更省 Gas。
那些頂級 DeFi 協議的工程師,不是在「優化」代碼,而是在「設計」代碼的時候就考慮了 Gas 效率。從第一行代碼開始,就把 Gas 思維內化了。
下次你寫 Solidity 的時候,試著在腦袋裡跑一遍 Gas 消耗:這個迴圈會執行多少次?每次迴圈要讀取幾個 storage 變數?這次除法能不能預先計算?
習慣了之後,你會發現 Gas 優化不是負擔,而是一種讓你更深入理解系統的訓練。
參考資料
- Ethereum Yellow Paper(Gas 計算的原始定義)
- Solidity 文檔(Optimization Options)
- Uniswap V4 原始碼(GitHub: uniswap/v4-core)
- AAVE V4 原始碼(GitHub: aave/aave-v3-core)
- OpenZeppelin Contracts(Gas 優化的最佳實踐)
- EVM Codes(各 opcode Gas 消耗速查表)
本網站內容僅供教育與資訊目的,不構成任何投資建議或技術建議。在部署任何智能合約前,請自行研究並進行完整的安全審計。
相關文章
- EVM 內部運作與 Gas 優化完整攻略:從原始碼到實際應用 — 搞區塊鏈開發的人,十個有九個被 Gas 搞過心態爆炸。明明同一個功能,別人的合約就是比你便宜一半,區塊空間用得漂亮,交易被打包的優先順序還比你高。這篇文章帶你從 EVM 到底怎麼執行 Opcode 講起,搞懂 Gas 的計費模型、儲存讀寫的成本差異、並實際展示十幾種可以立刻用在專案裡的優化技巧。
- 以太坊區塊結構與交易類型完整技術指南:從底層資料結構到實際應用 — 本文深入剖析以太坊區塊的完整資料結構、交易的生命週期、各類交易型別的技術規格,以及區塊驗證與傳播的底層機制。涵蓋執行層與共識層區塊結構、EIP-1559 費用模型、Blob 交易、EVM 執行流程、以及 MEV 相關主題。
- 以太坊虛擬機(EVM)完整技術指南:從執行模型到狀態管理的系統性解析 — 本文提供 EVM 的系統性完整解析,涵蓋執行模型、指令集架構、記憶體管理、狀態儲存機制、Gas 計算模型,以及 2025-2026 年的最新升級動態。深入分析 EVM 的確定性執行原則、執行上下文結構、交易執行生命週期,並探討 EOF 和 Verkle Tree 等未來演進方向。
- 以太坊 EVM 執行模型深度技術分析:從位元組碼到共識層的完整解析 — 本文從底層架構視角深入剖析 EVM 的執行模型,涵蓋 opcode 指令集深度分析、記憶體隔離模型、Gas 消耗機制、呼叫框架、Casper FFG 數學推導、以及 EVM 版本演進與未來發展。我們提供完整的技術細節、位元組碼範例、效能瓶頸定量評估,幫助智慧合約開發者與區塊鏈研究者建立對 EVM 的系統性理解。
- 以太坊虛擬機(EVM)深度技術分析:Opcode、執行模型與狀態轉換的數學原理 — 以太坊虛擬機(EVM)是以太坊智能合約運行的核心環境,被譽為「世界電腦」。本文從計算機科學和密碼學的角度,深入剖析 EVM 的架構設計、Opcode 操作機制、執行模型、以及狀態轉換的數學原理,提供完整的技術細節和工程視角,包括詳細的 Gas 消耗模型和實際的優化策略。
延伸閱讀與來源
- Ethereum.org Developers 官方開發者入口與技術文件
- EIPs 以太坊改進提案完整列表
- Solidity 文檔 智慧合約程式語言官方規格
- EVM 代碼庫 EVM 實作的核心參考
- Alethio EVM 分析 EVM 行為的正規驗證
這篇文章對您有幫助嗎?
請告訴我們如何改進:
評論
發表評論
注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。
目前尚無評論,成為第一個發表評論的人吧!