優化呼叫慣例以實現記憶體安全:深入探討 Fil-C
系統程式設計中的記憶體安全通常伴隨著沉重的效能成本。對於行為具敵對性的程式——例如將函式指標轉換為錯誤的簽章或誤用 va_list——確保安全性通常需要詳盡的執行期檢查。Fil-C 是由 Filip Pizlo 開發的一個專案,旨在透過實作一種呼叫慣例來解決此問題,該慣例能在不犧牲常見情況下效率的前提下,透過 panic 或賦予其安全行為來捕捉型別違規。
為了實現這一點,Fil-C 對函式呼叫採用分層方法:一種作為備援方案的通用、預設安全的慣例,以及一系列激進的優化,允許編譯器在能夠證明呼叫是安全的情況下繞過檢查。
通用呼叫慣例:安全基準線
在進行優化之前,Fil-C 定義了一種通用呼叫慣例,無論函式如何被呼叫,都能保證安全性。該過程非常嚴謹:
- 解析 (Resolution):直接呼叫會被降級為「取得器呼叫 (getter calls)」,將符號名稱解析為 flight pointers(capability pointers 與整數值的元組)。
- 驗證 (Verification):系統會驗證 capability 是否不為 null,是否特別是函式 capability,且指標的整數值是否與 capability 的可呼叫值相符。
- 緩衝 (Buffering):參數會對齊到 8 位元組,並分配兩個執行緒本地的呼叫慣例 (CC) 緩衝區——一個用於酬載 (payload),一個用於 capabilities。
- 傳遞 (Transfer):控制權轉移至被呼叫者,被呼叫者會在堆積上分配
byref參數,並將參數從 CC 緩衝區複製到本地資料流中。 - 回傳 (Return):回傳過程與參數傳遞相仿,使用 CC 緩衝區將結果傳回呼叫者。
雖然穩健,但此過程效率低下。它完全避開了暫存器,需要不斷存取執行緒本地緩衝區,且每次呼叫都需要多層間接層。
透過算術簽章編碼進行暫存器優化
為了消除 CC 緩衝區的開銷,Fil-C 引入了基於暫存器的呼叫慣例。這裡的核心創新是使用算術編碼 (arithmetic encoding) 將函式簽章表示為 64 位元整數。
算術編碼如何運作
Fil-C 將簽章(最多 16 個參數與 2 個回傳值)編碼為單個 int64。透過為型別分配數值(例如,int = 0,double = 2,pointer = 7),它建立了一個簽章的完美雜湊 (perfect hash)。例如,簽章 char* (*)(int, char*, double) 會被編碼為 60125。
快速路徑與 Thunks
Fil-C 中的每個函式物件都包含一個 signature 欄位與兩個進入點:一個 fast_entrypoint(原生暫存器基於)與一個 generic_entrypoint(基於緩衝區)。
當進行呼叫時,呼叫者會檢查被呼叫者的簽章是否與預期的編碼相符。如果相符,呼叫者會直接跳轉至 fast_entrypoint,並透過暫存器傳遞參數。如果簽章不同,系統會採用一對 thunks:
- Caller Entrypoint Thunk:將基於暫存器的呼叫轉換為通用的基於緩衝區的慣例。
- Callee Entrypoint Thunk:將通用的基於緩衝區的呼叫轉換為基於暫存器的呼叫。
這些 thunks 是在 LLVM IR 中作為 linkonce_odr 生成的,確保連結器在不同模組之間只保留一份副本。這種機制允許 Fil-C 在維持安全性的同時(透過通用路徑),在 PizBench9019 基準測試中實現了 >1% 的加速。
消除直接呼叫者解析
即使使用暫存器傳遞,直接呼叫仍需要 getter call 與 capability 檢查。Fil-C 透過利用 ELF 符號修飾 (mangling) 進一步優化此點。
簽章修飾的實作方式
編譯器不再呼叫 getter,而是為實作本身匯出一個包含簽章的 ELF 符號(例如 pizlonatedFI60125_foo)。如果呼叫者與被呼叫者對簽章達成共識,呼叫就會變成直接跳轉至實作,完全繞過 getter、capability 檢查與簽章檢查。
使用弱符號處理邊緣情況
此優化在 ELF 載入與 C++ inline 函式方面引入了複雜性。為了防止 thunk 自己呼叫自己的無限迴圈,Fil-C 對呼叫點 thunks 使用隱藏可見度 (hidden visibility),並使用特定的命名慣例(pizlonatedFIP 與 pizlonatedFI)來區分實作與別名 (alias)。
對於 C++ inline 函式——這些函式通常在 COMDAT 群組中是弱定義——連結器可能會丟棄呼叫者試圖引用的實作。Fil-C 透過以下方式解決此問題:
- 修改 LLVM 以承認在本地定義的 COMDAT 符號可能為 NULL。
- 發出對這些符號直接呼叫的 NULL 檢查。
這確保了如果一個函式被 COMDAT 解析時丟棄,錯誤會在連結時被捕捉,而不是導致執行期崩潰。
效能增益摘要
透過從通用的基於緩衝區方法轉向直接呼叫的基於暫存器方法,Fil-C 移除了函式呼叫常見情況下幾乎所有的開銷。轉換過程如下:
| 特徵 | 通用慣例 | 優化後的慣例 | | :--- | :--- | \n| 參數傳遞 | 執行緒本地緩衝區 | CPU 暫存器 | | 解析 | Getter call | 直接跳轉至修飾後的符號 | | 安全檢查 | 完整的 capability 與大小檢查 | 單一簽章匹配或 NULL 檢查 | | 回傳值 | 基於緩衝區 | CPU 暫存器 |
結合起來,這些優化提供了顯著的效能增益,同時維持了嚴格的記憶體安全保證,證明了高層級的安全性並不一定需要高層級的效能損失。