Ulrich Drepper:how To Write Shared Libraries@2011

以下是根據提供的資料,對主要論點進行的詳細解釋:

本文深入探討了如何編寫高效能且 ABI (應用程式二進位介面) 穩定的共用函式庫(在 ELF 檔案格式中稱為 DSOs 或 Dynamic Shared Objects),指出許多開發者僅以應用程式程式碼的思維來建立共用函式庫,這忽略了在許多平台上產生良好程式碼所需額外技術,以及產生最佳化程式碼所需的更深入知識。作者 Ulrich Drepper 旨在介紹這些必要規則和技術,並闡述如何管理 ABI 的穩定性。

核心論點一:了解 ELF 動態連結的成本與瓶頸

文章首先回顧了 Linux 從 a.out 格式轉向 ELF 的歷史,說明了 a.out 格式共用函式庫的缺陷:需要固定載入位址導致位址空間碎片化和管理困難,以及函式呼叫需要透過固定存根表進行,雖快但不靈活且難以維護。ELF 的優勢在於允許 DSO 在任意位址載入,並由動態連結器 (dynamic linker) 在執行時完成連結和重定位。

然而,這種動態連結過程並非沒有成本。作者詳細剖析了 ELF 載入啟動的步驟:

  1. 核心載入與對映 (Mapping): 核心根據 ELF 程式標頭 (Program Header) 將程式碼、資料等段 (segment) 對映到處理程序的虛擬位址空間。這個階段的效率與檔案大小和段的數量有關。重要的是,可寫入的記憶體頁面不能被多個處理程序共享,而唯讀頁面可以。因此,應盡可能減少可寫入資料。
  2. 動態連結器啟動: 核心將控制權交給動態連結器(一個獨立的 helper 程式)。動態連結器負責載入應用程式依賴的所有 DSO。這個過程涉及讀取檔案、解析依賴關係、對映到記憶體等,其成本隨 DSO 數量增加而增加。
  3. 重定位 (Relocation): 這是動態連結器啟動時通常最昂貴的部分。重定位是調整程式碼和資料,使其反映實際的載入位址。
    • 相對重定位 (Relative Relocations): 指向自身物件內部的位址,僅需加上物件的載入位址即可,成本較低。
    • 基於符號的重定位 (Symbol Relocations): 指向外部符號(函式或變數)的位址。這需要動態連結器在載入的 DSO 集合中查找符號定義。這個查找過程是主要性能瓶頸:
      • 它依賴於 符號雜湊表 (Hash Table) 的效率,糟糕的雜湊函數或過長的鏈 (chain) 會導致多次比較。
      • 它需要比較 符號名稱字串,長名稱和共同前綴多的名稱(如 C++ 經 name mangling 後的名稱)會顯著增加比較時間。
      • 查找範圍 (Lookup Scope) 的大小:動態連結器依序在作用域內搜尋符號,作用域通常包含應用程式及其所有依賴,DSO 越多,作用域越大,查找越慢。
    • 文字重定位 (Text Relocations): 修改程式碼段或唯讀段的重定位。這是應極力避免的,因為它導致包含該程式碼的記憶體頁面不可共享,增加記憶體使用和分頁交換成本,也存在安全隱患。
  4. 建構子執行 (Constructors): 載入並重定位完成後,動態連結器會按正確的順序執行每個 DSO 和應用程式的初始化函式(建構子)。這涉及對 DSO 之間的依賴關係進行拓撲排序,其成本與 DSO 數量有關。

作者總結了影響 ELF 啟動成本的關鍵因素:DSO 數量、符號數量(尤其導出的和未定義的)、符號名稱長度、重定位數量、重定位類型(相對 vs 符號 vs 文字)、程式碼和資料的存放位置(唯讀 vs 可寫入)。重定位,尤其是基於符號的重定位,是主要的開銷來源。

核心論點二:最佳化 DSO 性能的核心在於控制符號導出與可見性

既然符號查找是主要瓶頸,減少需要查找的符號數量和提高查找效率就是關鍵。作者強調,預設情況下,DSO 會導出所有全域可見的符號,這通常 far too large。只有構成 DSO ABI 的符號才應該被導出。未限制符號導出會帶來多重問題:

  1. 使用者誤用內部介面: 開發者無法保證未來版本不改變內部介面,但使用者可能依賴這些介面,導致未來版本不相容。
  2. 阻止編譯器最佳化: 預設導出的符號可以被其他 DSO 插入 (interpose),編譯器無法確定對這些符號的引用是否指向本 DSO 內部,因此必須產生更通用的(通常也更慢、更大)程式碼,例如透過 GOT (Global Offset Table) 和 PLT (Procedure Linkage Table) 間接存取變數或呼叫函式,即使定義就在本 DSO 內部。
  3. 增加動態符號表大小: 導出符號越多,動態符號表和字串表越大,增加載入時間、記憶體使用,並可能惡化雜湊表效率。

文章提供了多種控制符號導出的方法,並分析了它們對程式碼品質的影響:

  1. 使用 static: 這是最簡單、最好的方法。將變數或函式宣告為 static 會限制其作用域到檔案內部,阻止其被導出。這使得編譯器能夠確定對這些符號的引用是本地的,從而產生最佳化的程式碼(例如,直接 PC 相對呼叫,變數直接透過相對位移存取,無需 GOT)。
  2. 控制符號可見性 (Visibility): ELF gABI 定義了符號可見性。STV_DEFAULT 是預設可見性(導出且可插入),STV_HIDDEN 則隱藏符號,使其無法從 DSO 外部訪問。GCC 提供了 -fvisibility 編譯選項(設定預設可見性)和 __attribute__((visibility(...))) 屬性/pragmas(設定個別符號可見性)。使用 hidden 可見性與 static 類似,也能讓編譯器產生針對本地定義的最佳化程式碼,但其作用域是整個 DSO,而非單一檔案。作者建議將源檔案合併,盡可能使用 static,否則使用 hidden 可見性標記不需導出的符號。對於 C++ 類別,GCC 4.0+ 支援直接標記整個類別的可見性,這對於隱藏內部實現細節,特別是 inline 函式和編譯器自動生成的成員,非常有效。
  3. 使用導出映射 (Export Maps) / 版本腳本 (Version Scripts): 這是一種連結器層級的方法 (-Wl,--version-script=mapfile)。可以在映射檔案中明確列出哪些符號要導出 (global:),哪些要本地化 (local:)。雖然可以控制哪些符號出現在動態符號表中,但由於在編譯器生成程式碼之後才應用,它無法像 statichidden 屬性那樣讓編譯器提前產生最佳化程式碼。對於變數,使用導出映射隱藏符號仍會產生 GOT 條目和相對重定位;對於函式,雖然移除了 PLT 條目,但可能會保留不必要的 PIC 暫存器載入指令。儘管如此,導出映射是控制實際導出符號集的必要工具,特別是對於版本控制。
  4. 避免在內部使用導出符號: 如果一個符號必須導出,但在 DSO 內部使用時希望總是使用本地定義並享受最佳化,可以透過建立別名 (__attribute__((alias(...)))) 並隱藏別名的方式實現。內部程式碼使用別名,外部使用原始名稱。這會稍微增加程式碼複雜性,並且違反 ISO C 對函式指標相等性的要求,因此需謹慎使用。
  5. 避免 DF_SYMBOLIC: 這個 ELF 旗標強制 DSO 優先查找自身的符號定義。作者強烈不建議使用,因為它影響所有符號,無法提供細粒度控制,且不能像可見性屬性那樣讓編譯器產生最佳化程式碼,甚至可能導致意料之外的符號查找結果。

核心論點三:維護 ABI 穩定性對長期成功至關重要,應利用符號版本控制

對於被多個專案使用的 DSO,維護其 ABI 穩定性與技術最佳化同等重要。ABI 穩定性意味著現有合法使用 DSO 的應用程式,在 DSO 升級到未來版本後仍能正常執行。這包括:已導出的符號不能消失;已導出的變數大小和結構不能以不相容的方式改變;已導出的函式語義在合法使用範圍內不能改變。

處理不相容變更(例如,修正一個被某些程式依賴的 bug,或更改一個函式的簽名)是 ABI 維護的挑戰。僅僅改變 DSO 檔案名稱(最粗糙的版本控制)會導致系統中存在大量不同版本的 DSO 拷貝,浪費資源,並可能在同一應用程式中混用不同版本時導致問題。

作者強調了 GNU 符號版本控制 (Symbol Versioning) 作為處理不相容變更的優越機制。它允許同一個符號名稱在同一個 DSO 中有多個定義,每個定義對應一個不同的版本名稱。連結應用程式時,連結器會記錄應用程式使用了特定 DSO 中特定版本的符號。在執行時,動態連結器能夠識別應用程式需要的確切版本,並在 DSO 中找到對應的定義。

實現符號版本控制需要:

  1. 在原始碼中使用 .symver 偽操作或特定屬性 (如果編譯器支援): 為同一符號的不同實現定義不同的內部名稱,並使用 .symver 將外部可見的符號名稱與內部名稱及版本名稱關聯起來。使用 @@ 標記預設版本,@ 標記僅供相容性查找的舊版本。
  2. 在版本腳本 (Version Script) 中定義版本層次和符號歸屬: 使用類似導出映射的格式,定義不同的版本區塊,列出每個版本包含哪些符號。新的版本可以繼承舊版本的符號。這樣,可以在不改變 DSO 檔案名稱的情況下,同時提供同一個符號的多個不相容版本。

符號版本控制是處理不相容變更的最佳方法,但需要使用者應用程式在連結時與 DSO 連結(不能依賴未定義符號),否則動態連結器無法確定所需的符號版本。作者建議使用 -Wl,-z,defs 連結選項來強制所有符號在連結時得到定義。

核心論點四:適當管理 DSO 間的依賴關係

DSO 之間的依賴關係(由 DT_NEEDED 條目記錄)管理也很重要。動態連結器需要找到這些依賴檔案。除了標準搜尋路徑 (/lib, /usr/lib, /etc/ld.so.conf, LD_LIBRARY_PATH) 外,DSO 作者可以定義一個「執行路徑」(Run Path),記錄在 DSO 的 DT_RPATHDT_RUNPATH 條目中。DT_RUNPATH 比已廢棄的 DT_RPATH 更優,因為它的優先級低於 LD_LIBRARY_PATH,允許使用者覆蓋。

使用執行路徑需要小心:空路徑 (:) 表示目前工作目錄,可能引入安全或查找問題;絕對路徑使得安裝位置不靈活。更好的做法是使用動態字串標記 (DSTs) 如 $ORIGIN,它在執行時展開為包含 DSO 的目錄的絕對路徑,從而允許相對於 DSO 位置指定依賴路徑(例如 $ORIGIN/../lib)。

最後,作者指出,連結器命令列上列出的 DSO,預設都會被加入到結果的 DT_NEEDED 列表,即使程式碼中並未實際引用其中的符號。這會增加不必要的依賴,擴大符號查找範圍。可以使用 --as-needed 連結選項來告訴連結器只添加實際需要的 DSO 依賴。

總結來說,編寫高效能和穩定的共用函式庫,需要深入理解 ELF 的工作原理和動態連結成本,特別是重定位和符號查找。核心最佳化在於精確控制符號的導出和可見性,使編譯器能夠產生最佳化的程式碼。長期的 ABI 穩定性則依賴於成熟的版本控制機制,其中 GNU 符號版本控制是處理不相容變更的強大工具。同時,也要注意資料結構設計、程式碼生成細節以及 DSO 間的依賴管理。