共享程序庫通過版本號來完成對應用程序所使用的程序庫的升級,同時保留了對原有應用程序的兼容。本文將討論此方法的實際內幕,以及在常規 Linux? 系統上的 /usr/lib 中有很多符號鏈接的原因。
共享程序庫是現代 UNIX? 系統中有效利用空間和資源的基礎。SUSE 系統中的 C 程序庫大約有 1.3 MB。為 /usr/bin 中每一個程序(我有 2,569 個)制作副本將占去幾個 G 的空間。
當然這個數字有一些夸張 —— 靜態鏈接程序只合并它們使用的那部分程序庫。盡管如此, printf() 的所有副本所占用的空間數量也會讓系統顯得非常臃腫。
共享程序庫不僅可以節省磁盤空間,而且還可以節省內存。內核可以在內存中保持某個共享程序庫的一個惟一副本,并在多個應用程序間共享這個副本。所以,我們不但可以在磁盤上只有 printf() 的一個副本,而且在內存中也只需要一個副本。這對性能有很大的影響。
在本文中,我們將討論共享程序庫所使用的底層技術,以及在共享程序庫版本號幫助下預防兼容性難題的方法,過去,本機共享程序庫實現也曾遇到過這些難題。首先來看一下共享程序庫的工作原理。
共享程序庫的工作原理
這個概念理解起來非常簡單。擁有一個程序庫;然后共享這個程序庫。但是,當您的程序嘗試調用 printf() 時,也就是說實際操作的時候,具體發生的事情卻稍微有點復雜。
這個過程在靜態鏈接系統中比在動態鏈接系統中更簡單。在靜態鏈接系統中,生成的代碼會持有對某個函數的引用。鏈接器使用加載該函數的真實地址去替換這個引用,以便生成的二進制代碼在適當的位置會有正確的地址。然后,在運行代碼時,只需要跳轉到相應的地址即可。對管理員來說,這是一項簡單的任務,因為它允許您對只在程序中的某個位置上實際引用的那些對象進行鏈接。
但是大部分共享程序庫都是動態鏈接的。這具有一些更深層次的意義。其中一方面是,您不能事先預計某個函數在調用時的確切地址!(以及靜態鏈接的共享程序庫模式,比如 BSD/OS 中的,但是它們不在本文討論范圍之內。)
動態鏈接器可以為每個被鏈接的函數做相當多的工作,所以大部分鏈接器都是不積極的。只有在函數被調用時,它們才實際做一些工作。C 程序庫中有一千多個外部可見的符號,有大約三千多個本地符號,因此這種方法可以節省非常多的時間。
實現此奇妙功能的是一個稱為 過程鏈接表(Procedure Linkage Table)(PLT)的數據塊,它是程序中的一個表,列出了程序所調用的每一個函數。當程序開始運行時,PLT 包含每個函數的代碼,以便查詢運行期鏈接器,從而獲得已加載某個函數的地址。然后它會在表中填入這個條目并跳轉到那個已加載函數。當每個函數被調用時,它的 PLT 中的條目就會被簡化為一個到那個已加載函數的直接跳轉。
不過,重要的是,要注意到還有一個間接的額外層次 —— 可以通過跳轉到某個表來解析每個函數調用。
兼容性不僅是為了關聯
這意味著您最終要鏈接的程序庫最好與調用它的代碼相兼容。使用靜態鏈接的可執行文件,可以在某種程度上保證不會發生任何改變。如果使用動態鏈接,就得不到這樣的保證。
當出現新版本的程序庫時會怎樣?特別是新版本改變了某個給定函數的調用次序時,又會怎樣?
版本號可以解決這個問題 —— 共享的程序庫將擁有一個版本號。當一個程序鏈接到某個程序庫時,程序中會存儲一個它計劃支持的版本號。如果更改程序庫,那么版本號就會不匹配,程序也就不會被鏈接到較新版本的程序庫。
不過,動態鏈接的可能優勢之一在于修正缺陷。如果可以修正程序庫中的缺陷,而且不必重新編譯上千個程序,就可以利用這一修正功能,這將是非常令人愉快的。有時,需要鏈接到某個較新的版本。
不幸的是,這會導致在某些情況下,您希望鏈接到較新的版本,而在另外一些情況下,您寧愿堅持使用較老的版本。不過,有一個解決方案 —— 使用兩類版本號:
- 主版本號表明程序庫版本之間的潛在不兼容性。
- 次要版本號表明只是修正了缺陷。
這樣,在大部分情形下,加載具有相同主版本號和更高次要版本號的程序庫是安全的;而加載主版本號更高的程序是不安全的行為。
為了讓用戶(和程序員)不必追蹤程序庫版本號和更新,系統提供了大量的符號鏈接。通常,其模式是:
libexample.so
將是一個指向
libexample.so.N
的鏈接,其中 N 是在系統中可以找到的最高的 主 版本號。
對受支持的每一個主版本號而言,
libexample.so.N
將是一個指向
libexample.so.N.M
的鏈接,其中 M 是最高的 次要 版本號。
這樣,如果為鏈接器指定了 -lexample,那么它會去尋找 libexample.so,這是一個符號鏈接,指向某個指向最新版本的符號鏈接。另一方面,當加載某個現有程序時,它將嘗試去加載 libexample.so.N,其中 N 是它先前鏈接的版本。各得其所!
?
為了進行調試,首先必須知道如何編譯
為了調試使用共享程序庫的問題,對它們如何編譯有更多一些了解會對您有所幫助。
在傳統的靜態程序庫中,生成的代碼通常封裝在一個程序庫文件中(其名稱以 .a 結尾),然后傳遞給鏈接器。在動態程序庫中,程序庫文件的名稱通常以 .so 結尾。文件結構稍有不同。
常規的靜態程序庫的格式是 ar 工具(一個非常簡單的存檔程序,類似于 tar,但是更簡單)所創建的那種格式。相反,共享程序庫通常以更復雜的文件格式存儲。
在現代 Linux 系統中,這一格式通常是 ELF 二進制格式(可執行與可鏈接格式(Executable and Linkable Format))。在 ELF 中,每個文件的組成包括:一個 ELF 頭,隨后是零或者一些段(segments),以及零或者一些區段(sections)。 段 中包含文件的運行時執行所需要的信息,而 區段 中包含用于鏈接和重定位的重要數據。整個文件中的每個字節每次只能由一個區段使用,不過可以存在不被任何區段所包含的孤立字節。通常,在 UNIX 可執行文件中,一個或多個區段會封裝在一個段內。
ELF 格式中包含用于應用程序和程序庫的規范。但程序庫格式要復雜得多,不僅僅是對象模塊的簡單存檔。
鏈接器將所有對符號的引用進行分類,標識出它們是在哪個程序庫中找到的。將靜態程序庫的符號添加到最終的可執行文件中;然后將共享程序庫的符號放入 PLT 中,最后創建對 FLT 的引用。在完成這些任務之后,生成的可執行文件會擁有一個列表,該列表列出了計劃從運行期將加載的程序庫中找出的那些符號。
在運行期間,應用程序將加載動態鏈接器。實際上,動態鏈接器本身使用與共享程序庫相同種類的版本號。例如,在 SUSE Linux 9.1 中, /lib/ld-linux.so.2 文件是一個指向 /lib/ld-linux.so.2.3.3 的符號鏈接。另一方面,尋找 /lib/ld-linux.so.1 的程序不會嘗試使用新的版本。
然后動態鏈接器開始進行所有有趣的工作。它會查明某個程序先前鏈接到了哪些程序庫(以及哪個版本),然后加載它們。加載程序庫的步驟包括:
- 找到程序庫(它可能在系統中若干個目錄中的任意一個目錄中)。
- 將程序庫映射到程序的地址空間。
- 分配程序庫可能需要的由零填充的內存塊。
- 添加程序庫的符號表。
調試這一過程可能會比較困難。您可能會遇到多種問題。例如,如果動態鏈接器不能找到某個給定的程序庫,那么它將停止加載程序。如果它找到了所有需要的程序庫,但卻無法找到某個符號,那么它也可能會因此而停止加載操作(但是可能直到真正嘗試去引用那個符號時才會發生這種情形) —— 這是一種很少見的情況,因為通常如果不存在某個符號,那么在初始化鏈接的時候就會被警告。
?
修改動態鏈接器的搜索路徑
當鏈接某個程序時,在運行期您可以指定另外的搜索路徑。在 gcc 中,其語法是 -Wl,-R/path。如果程序已經被鏈接,那么您也可以設置環境變量 LD_LIBRARY_PATH 來改變這一行為。通常只是在應用程序需要搜索的路徑不是系統級默認路徑的一部分時才需要這樣做,對大部分 Linux 系統來說,這種情況很少見。理論上,Mozilla 用戶可以發布某個使用這個路徑設置所編譯的二進制程序,但是他們更傾向于發布包裝器(wrapper)腳本,在啟動可執行程序之前正確地設置程序庫路徑。
設置程序庫路徑可以為兩個應用程序需要同一程序庫的不兼容版本的這種罕見情況提供一個迂回解決方案??梢允褂冒b器腳本使某一應用程序在使用特殊版本程序庫的目錄中進行搜索。這稱不上是一個完美的解決方案,但是在某些情況下,這是您能采用的最佳方法。
如果出于不得已的原因需要為很多程序添加某個路徑,那么也可以修改系統的默認搜索路徑。通過 /etc/ld.so.conf 控制動態鏈接器,該文件包含默認搜索路徑的列表。對 LD_LIBRARY_PATH 中指定的任何路徑的搜索都要先于 ld.so.conf 中列出的路徑,所以用戶可以覆蓋這些設置。
大部分用戶沒有理由修改系統默認程序庫搜索路徑;通常環境變量更適用于修改搜索路徑,比如連接某個工具包中的程序庫,或者使用某個程序庫的較新版本的測試程序。
使用 ldd
ldd 是調試共享程序庫問題的一個實用工具。其名稱來自 list dynamic dependencies。這個程序會查看某個給定的可執行程序或者共享程序庫,并指出它需要加載哪些共享程序庫以及要使用哪些版本。輸出類似如下:
清單 1. /bin/sh 的依賴
$ ldd /bin/sh linux-gate.so.1 => (0xffffe000) libreadline.so.4 => /lib/libreadline.so.4 (0x40036000) libhistory.so.4 => /lib/libhistory.so.4 (0x40062000) libncurses.so.5 => /lib/libncurses.so.5 (0x40069000) libdl.so.2 => /lib/libdl.so.2 (0x400af000) libc.so.6 => /lib/tls/libc.so.6 (0x400b2000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
?
看到一個“簡單的”的程序使用了這么多個程序庫,可能會有些令人驚訝?;蛟S是 libhistory 需要 libncurses。為了查明真相,我們只需要運行另一個 ldd 命令:
清單 2. libhistory 的依賴
$ ldd /lib/libhistory.so.4 linux-gate.so.1 => (0xffffe000) libncurses.so.5 => /lib/libncurses.so.5 (0x40026000) libc.so.6 => /lib/tls/libc.so.6 (0x4006b000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x80000000)
?
在某些情況下,可能需要為應用程序指定另外的程序庫路徑。例如,對 Mozilla 二進制程序嘗試運行 ldd 所得到輸出的前幾行如下所示:
清單 3. 運行 dll 查找不在搜索路徑中的 程序庫的結果
$ ldd /opt/mozilla/lib/mozilla-binlinux-gate.so.1 => (0xffffe000)libmozjs.so => not foundlibplds4.so => not foundlibplc4.so => not foundlibnspr4.so => not foundlibpthread.so.0 => /lib/tls/libpthread.so.0 (0x40037000)
?
為什么找不到這些程序庫?因為它們不在常見的程序庫搜索路徑中。實際上,它們在 /opt/mozilla/lib 中,所以,解決方案之一是將這個目錄添加到 LD_LIBRARY_PATH 中。
另一個選項是將路徑設置為 .,并在這個目錄下運行 ldd,盡管這樣做更危險 —— 將當前目錄添加到程序庫路徑中與將它添加到可執行程序路徑中一樣有著潛在的危險。
在這種情況下,將這些程序庫所在的目錄添加到系統級搜索路徑中顯然不是一個好辦法。只有 Mozilla 需要這些程序庫。
鏈接 Mozilla
說起 Mozilla,如果您覺得自己從未見過超過幾行的程序庫,那么在某種程度上,Mozilla 是一個更為典型的大型應用程序。現在您可以明白為什么 Mozilla 的啟動需要那么長時間了吧!
清單 4. mozilla-bin 的依賴性
linux-gate.so.1 => (0xffffe000)libmozjs.so => ./libmozjs.so (0x40018000)libplds4.so => ./libplds4.so (0x40099000)libplc4.so => ./libplc4.so (0x4009d000)libnspr4.so => ./libnspr4.so (0x400a2000)libpthread.so.0 => /lib/tls/libpthread.so.0 (0x400f5000)libdl.so.2 => /lib/libdl.so.2 (0x40105000)libgtk-x11-2.0.so.0 => /opt/gnome/lib/libgtk-x11-2.0.so.0 (0x40108000)libgdk-x11-2.0.so.0 => /opt/gnome/lib/libgdk-x11-2.0.so.0 (0x40358000)libatk-1.0.so.0 => /opt/gnome/lib/libatk-1.0.so.0 (0x403c5000)libgdk_pixbuf-2.0.so.0 => /opt/gnome/lib/libgdk_pixbuf-2.0.so.0 (0x403df000)libpangoxft-1.0.so.0 => /opt/gnome/lib/libpangoxft-1.0.so.0 (0x403f1000)libpangox-1.0.so.0 => /opt/gnome/lib/libpangox-1.0.so.0 (0x40412000)libpango-1.0.so.0 => /opt/gnome/lib/libpango-1.0.so.0 (0x4041f000)libgobject-2.0.so.0 => /opt/gnome/lib/libgobject-2.0.so.0 (0x40451000)libgmodule-2.0.so.0 => /opt/gnome/lib/libgmodule-2.0.so.0 (0x40487000)libglib-2.0.so.0 => /opt/gnome/lib/libglib-2.0.so.0 (0x4048b000)libm.so.6 => /lib/tls/libm.so.6 (0x404f7000)libstdc++.so.5 => /usr/lib/libstdc++.so.5 (0x40519000)libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x405d5000)libc.so.6 => /lib/tls/libc.so.6 (0x405dd000)/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)libX11.so.6 => /usr/X11R6/lib/libX11.so.6 (0x406f3000)libXrandr.so.2 => /usr/X11R6/lib/libXrandr.so.2 (0x407ef000)libXi.so.6 => /usr/X11R6/lib/libXi.so.6 (0x407f3000)libXext.so.6 => /usr/X11R6/lib/libXext.so.6 (0x407fb000)libXft.so.2 => /usr/X11R6/lib/libXft.so.2 (0x4080a000)libXrender.so.1 => /usr/X11R6/lib/libXrender.so.1 (0x4081e000)libfontconfig.so.1 => /usr/lib/libfontconfig.so.1 (0x40826000)libfreetype.so.6 => /usr/lib/libfreetype.so.6 (0x40850000)libexpat.so.0 => /usr/lib/libexpat.so.0 (0x408b9000)
?
深入了解共享程序庫
有興趣深入了解 Linux 中的動態鏈接的用戶有很多選擇。GNU 編譯器和鏈接器工具鏈(linker tool chain)文檔都非常好,雖然其內容是以 info 格式存儲的,而且也沒有在標準手冊頁中提及。
ld.so 的手冊頁包含有一個非常詳盡的列表,列出了改變動態鏈接器行為的變量,以及對過去曾經使用的不同版本的動態鏈接器的說明。
大部分 Linux 文檔都假定所有共享程序庫都是動態鏈接的,因為在 Linux 系統上,它們通常是這樣的。實現靜態鏈接的共享程序庫需要做的工作非常多,而且大部分用戶不會因此獲得任何好處,盡管支持這個特性的系統的性能會有顯著改變。
如果您正在使用現成的預先包裝好的系統,那么您可能不會遇到太多的共享程序庫版本 —— 系統可能只附帶它要鏈接的那些共享程序庫版本。另一方面,如果您做過很多次更新和源代碼構建,那么您可能最終得到多個版本的共享程序庫,因為老版本依然會被保留,“以防萬一”。
像平時一樣,如果想了解更多,那么就去親自實踐吧。記住,在某個系統上,幾乎所有程序都會引用一些相同的共享程序庫,所以,如果破壞了系統的某個核心共享程序庫,那么您就得去求助系統恢復工具了。
評論
查看更多