Solidity Optimizer Under the Hood
三句話摘要
深入解析 Solidity 編譯器優化器的運作原理,涵蓋 Legacy 與 Via-IR 兩條編譯管線、各類 Yul 優化 Pass 的機制,以及歷史上出現過的優化器 Bug。 理解 Solidity 優化器的具體機制(Peephole 等價替換、Yul 的 Load Resolver / Dead Code Eliminator / Unused Store Eliminator / Function Specializer),才能寫出對優化器友善的程式碼,並辨識出那些反直覺的邊界條件,避免在安全審計中漏掉優化器層面的潛在風險。 1. Legacy vs Via-IR 管線的根本差異
重點整理
重點- 1
1. Legacy vs Via-IR 管線的根本差異
- 2
Legacy 管線直接將 Solidity 編譯為 EVM assembly 再優化;Via-IR 先轉換為 Yul 中間語言,在 Yul 層做高階優化後才降轉為 EVM assembly,因為 Yul 具有明確的結構化控制流,更易於靜態分析,也讓跨函式的全域優化成為可能。
- 3
2. Peephole 優化器的等價替換邏輯
- 4
Peephole 優化器一次只看 2-3 條 opcode,尋找可用更少或更便宜指令表達相同語意的模式,例如乘以零直接推入結果、`ISZERO x` 替換為 `x`、`PUSH` 後立刻 `POP` 整組刪除,以及 `ADDRESS` + `BALANCE` 合併為單一 `SELFBALANCE`。
- 5
3. Yul 優化器的語意感知能力
- 6
Yul 優化器不只看指令序列,還追蹤 storage 和 memory 在特定時間點的具體值,因此能在編譯期完成像 Keccak256 這類昂貴運算,把 runtime 的雜湊計算替換成一個 `PUSH32` 常數。
- 7
4. 優化器的邊界:演算法複雜度無法被消除
- 8
優化器保證不改變合約語意,只移除或簡化等價邏輯,但它無法把 O(n²) 的迴圈變成常數時間操作——演算法本身的效率仍由開發者負責。
實用技巧與重點
乾貨- 部署成本:每 1 byte 部署合約消耗 200 gas;合約大小硬上限為 24 KB
- optimizer runs 參數:數字越高 → 可能增加 bytecode 大小但降低執行 Gas;數字越低 → bytecode 更小但執行更貴
- Via-IR 優點:自動將區域變數 spill 到 memory,大幅減少 `Stack too deep` 錯誤
- Yul 優化 Pass 清單:Load Resolver、Dead Code Eliminator、Unused Store Eliminator、Function Specializer(可透過自定義優化序列關閉特定 pass)
- Function Specializer 反制技巧:透過一個「混淆函式」`stubborn(x)` 包裝字面常數參數,讓優化器無法識別為字面值,從而阻止特化版本生成,縮減 bytecode
- 歷史 Bug 1(Unused Store Eliminator):含 inline assembly `return`/`stop` 的函式中,第一次 storage 寫入被錯誤判定為「死寫入」並刪除,實際上 assembly `return` 直接結束整個 call,導致後續覆寫永遠不會執行
- 歷史 Bug 2(Unused Store Eliminator):跨 assembly 區塊的記憶體讀寫被誤判斷開連結,第一個 assembly block 的 `mstore` 被刪除,導致第二個 block 讀到未初始化或被 scratch space 汙染的值
- Keccak256 預計算:當 memory[0] 寫入 `0xFF` 後無任何覆寫,優化器直接在編譯期算出雜湊值,bytecode 中只剩 `PUSH32 <digest>`
- 自定義優化序列:編譯器輸入可傳入 `--yul-optimizations` 序列字串覆寫預設 pass 順序
結論
結論“理解 Solidity 優化器的具體機制(Peephole 等價替換、Yul 的 Load Resolver / Dead Code Eliminator / Unused Store Eliminator / Function Specializer),才能寫出對優化器友善的程式碼,並辨識出那些反直覺的邊界條件,避免在安全審計中漏掉優化器層面的潛在風險。”
完整解析
詳細Solidity 的優化器長期以來被開發者當作黑盒子使用——打開 `--optimize` 旗標,設一個很大的 runs 數字,然後期待 Gas 費用下降。這場演講的目標是揭開黑盒蓋子,說明優化器在兩條編譯管線中分別做了什麼事。
Solidity 提供兩種編譯路徑。Legacy 管線的流程相對直接:原始碼 → EVM assembly → 優化器作用於 assembly → bytecode。Via-IR 管線則多了一個中間層:原始碼先被翻譯成 Yul(一種精簡的結構化語言,具備函式、if 和 for,但沒有繼承、modifier 等高階語法糖),優化器在 Yul 層完成高階轉換後,才降轉為 EVM assembly,再執行一次 EVM 層的優化。目前預設仍是 Legacy 管線,但 Solidity 團隊計畫未來將 Via-IR 設為預設。Yul 的結構化控制流和明確的跳躍目標讓靜態分析更容易,也使跨函式的全域優化成為可能,同時它也是 Via-IR 能自動解決 `Stack too deep` 的根本原因。
在 EVM assembly 層,Peephole 優化器以滑動視窗掃描指令序列,尋找可用等價但更廉價指令替換的模式。乘以零直接輸出常數零、`PUSH` 後立刻 `POP` 整組刪除、兩次對調同一對堆疊元素合而為一、`ADDRESS` + `BALANCE` 合併為 `SELFBALANCE`——這些都是典型案例。更值得注意的是,優化器還會追蹤 storage 和 memory 在特定時間點的精確內容:當它確定 memory[0] 存放著 `0xFF` 且在雜湊之前沒有任何覆寫時,整個 Keccak256 運算會在編譯期直接完成,runtime bytecode 只剩一條 `PUSH32`。
Yul 優化器由多個 pass 串接而成,每個 pass 保持語意不變地對程式碼做轉換,且 pass 的執行順序至關重要——前一個 pass 建立的條件往往是後一個 pass 的前提。Load Resolver 偵測 storage/memory 的值是否已在堆疊上,若是則以 `DUP`/`SWAP` 重用而非重新 `SLOAD`,在同一變數多次使用的函式中可省下顯著的 Gas。Dead Code Eliminator 移除永遠不可能執行到的程式碼;值得注意的是,即使 `return` 位於加法之前,若加法不在 `unchecked` 區塊內,溢位檢查仍會保留——因為移除它會改變合約行為。Unused Store Eliminator 刪除對同一位置的早期寫入(若後續被覆寫前從未被讀取),但一旦中間存在外部呼叫,優化器必須保守假設對方可能讀取該 storage,因此不會刪除。Function Specializer 對以字面常數作為引數的呼叫點生成特化函式版本,有時能進一步觸發其他優化,但也可能膨脹 bytecode;開發者可用一個「混淆包裝函式」阻止特化,或在編譯器輸入中移除該 pass。
歷史上優化器確實引入過真實的 Bug。第一個出現在 Unused Store Eliminator:若函式先寫入 storage,再呼叫一個含有 inline assembly `return`(直接結束整個 call 而非僅返回函式)的子函式,接著在正常執行路徑再次寫入同一 slot,優化器會誤判第一次寫入為死寫入並刪除;然而 assembly `return` 路徑根本不會執行到第二次寫入,導致 storage 狀態與預期不符。第二個 Bug 同樣源於 Unused Store Eliminator:跨越兩個 inline assembly 區塊的硬編碼記憶體偏移量讀寫被判定為無關,第一個區塊的 `mstore` 遭刪除,第二個區塊讀到的是未初始化或被暫存空間汙染的值。
演講最後強調:優化器是一組定義明確的轉換規則,而非魔法。它能移除多餘的 opcode、合併等價指令、預計算常數結果,但它無法替代好的演算法設計——將 O(n²) 的迴圈降為常數時間,這件事始終是開發者的責任。
關鍵時刻
Pipeline v2帶時間戳的重點,會在逐字稿層級分析上線後產生。目前請先透過原始影片觀看。
事實查核
Pipeline v2說法查證是下一次管線升級的一部分。KeyFrame 只會顯示它真正能驗證的內容。


