from:http://blog.ideawand.com/2020/02/15/how-does-linux-shared-library-versioning-works/
作為一名經常在Linux下從事開發的工程師來說,應該很多人都遇到過找不到so庫的問題,特別是在一些涉及到C/C++依賴的項目中。Python這類解釋性語言也會調用一些C/C++編譯出來的so庫來彌補自身性能的不足。為了讓大家能夠面對例如下面這種錯誤時不再手足無措,我整理了這篇文章。
error while loading shared libraries: libxxx.so.2: cannot open shared object file: No such file or directory
Linux下so的版本機制介紹
如果大家在自己的linux系統上執行 ls -l /usr/lib64 這條命令,則會看到很多具有下列特征的軟連接,其中x、y、z為數字, 那么這些軟連接和他們后面的數字有什么用途呢?
libfoo.so -> libfoo.so.x
libfoo.so.x -> libfoo.so.x.y.z
libbar.so.x -> libbar.so.x.y
這里的x,y,z分別代表的是這個so的主版本號(MAJOR),次版本號(MINOR),以及發行版本號(RELEASE),對於這三個數字各自的含義,以及什么時候會進行增長,不同的文獻上有不同的解釋,不同的組織遵循的規定可能也有細微的差別,但有一個可以肯定的事情是:主版本號(MAJOR)不同的兩個so庫,所暴露出的API接口是不兼容的。而對於次版本號,和發行版本號,則有着不同定義,其中一種定義是:次要版本號表示API接口的定義發生了改變(比如參數的含義發生了變化),但是保持向前兼容;而發行版本號則是函數內部的一些功能調整、優化、BUG修復,不涉及API接口定義的修改。
幾個so庫有關名字的介紹
在開始這一節之前,我們先來做一個小的測試,屏幕不要往下滑動太多啊,要不你就提前看到答案了:)
問題:有如下幾個so庫的名字,你認為對於一個程序的運行,那個名字是最重要的呢?
libfoo.so
libfoo.so.1
libfoo.so.1.1
libfoo.so.1.1.1
從直覺上,我猜你選擇了libfoo.so這個不帶任何數字后綴的答案,但實際上,這個不帶任何數字后綴的文件名可能是用處最少的一個文件,真正在一個程序的運行過程中起到定位到某一個so功能的,實際上是帶有一個數字后綴的libfoo.so.1這種形式的文件名。為了證明我說的有道理,我們可以在linux下執行ldd命令來查看一個可執行文件到底都依賴了哪些so庫,例如我們可以執行ldd /bin/bash來查看一下bash這個可執行文件運行時依賴的so庫,在我的PC上輸出結果如下:
$ ldd /bin/bash
linux-vdso.so.1 => (0x00007ffffd0cc000)
libtinfo.so.5 => /lib64/libtinfo.so.5 (0x00007fa2b3a49000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007fa2b3845000)
libc.so.6 => /lib64/libc.so.6 (0x00007fa2b3482000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2b3c73000)
可以看到,這里顯示的都是形如libfoo.so.x這樣帶有一個數字后綴的文件
好了,下面我們來介紹在so查找過程中的幾個名字:SONAME、real name、linker name,其中SONAME是業界通用名稱,而real name和linker name這兩個叫法是我從參考文獻[1]中借鑒的。
SONAME 是一組具有兼容API的SO庫所共有的名字,其命名特征是lib+<庫名>+.so.+<數字>這種形式的
real name 是真實具有SO庫可執行代碼的那個文件,之所以叫real是相對於SONAME和linker name而言的,因為另外兩種名字一般都是一個軟連接,這些軟連接最終指向的文件都是具有real name命名形式的文件。real name的命名格式中,可能有2個數字尾綴,也可能有3個數字尾綴,但這不重要。你只要記住,真實的那個,不是以軟連接形式存在的,就是一個real name。例如下面的兩個文件,libdns.so.100.1.1有1.9M大小,而libdns.so.100是一個軟連接,所以libdns.so.100.1.1這個真實的文件的文件名就是real name
lrwxrwxrwx. 1 root root 17 Feb 7 2018 libdns.so.100 -> libdns.so.100.1.1
-rwxr-xr-x. 1 root root 1.9M Aug 4 2017 libdns.so.100.1.1
linker name 這個名字只是給編譯工具鏈中的連接器使用的名字,和程序運行並沒有什么關系,僅僅在鏈接得到可執行文件的過程中才會用到。它的命名特征是以lib開頭,以.so結尾,不帶任何數字后綴的格式
SONAME的作用
假設在你的Linux系統中有3個不同版本的bar共享庫,他們在磁盤上保存的文件名如下:
/usr/lib64/libbar.so.1.3
/usr/lib64/libbar.so.1.5
/usr/lib64/libbar.so.2.1
假設以上三個文件,都是真實的so庫文件,而不是軟連接,也就是說,上面列出的名字都是real name。
根據我們之前對版本號的定義,我們可以知道:
libbar.so.1.3和libbar.so.1.5之間是互相兼容的
libbar.so.2.1和上述兩個庫之間互相不兼容
我們再假設你有兩個不同的程序A和B,其中A程序依賴libbar.so.1.5這個庫文件,而B程序依賴libbar.so.2.1這個庫文件。但實際上,在A和B兩個程序中,並沒有寫明自己所依賴的是libbar.so.1.5和libbar.so.2.1,真正保存在A和B中的是兩個庫的SONAME,也即libbar.so.1和libbar.so.2。然后,再通過軟鏈接的形式,將libbar.so.1鏈接到libbar.so.1.5,將libbar.so.2鏈接到libbar.so.2.1。
那么引入軟連接的好處是什么呢?假設有一天,libbar.so.2.1庫進行了升級,但API接口仍然保持兼容,升級后的庫文件為libbar.so.2.2,這時候,我們只要將之前的軟連接重新指向升級后的文件,然后重新啟動B程序,B程序就可以使用全新版本的so庫了,我們並不需要去重新編譯鏈接來更新B程序。
總結一下上面的邏輯:
通常SONAME是一個指向real name的軟連接
應用程序中存儲自己所依賴的SO庫的SONAME,也就是僅保證主版本號能匹配就行
通過修改軟連接的指向,可以讓應用程序在互相兼容的SO庫中方便切換使用哪一個
通常情況下,大家使用最新版本即可,除非是為了在特定版本下做一些調試、開發工作
linker name的作用
上一節中我們提到,可執行文件里會存儲精確到主版本號的SONAME,但是在編譯生成可執行文件的過程中,編譯器怎么知道應該用哪個主版本號呢?為了回答這個問題,我們從編譯鏈接的過程來梳理一下。
假設我們使用gcc編譯生成一個依賴foo庫的可執行文件A:
gcc A.c -lfoo -o A
熟悉gcc編譯的讀者們肯定知道,上述的-l標記后跟隨了foo參數,表示我們告訴gcc在編譯的過程中需要用到一個外部的名為foo的庫,但這里有一個問題,我們並沒有說使用哪一個主版本,我們只給出了一個名字。為了解決這個問題,軟鏈接再次發揮作用,具體流程如下:
根據linux下動態鏈接庫的命名規范,gcc會根據-lfoo這個標識拼接出libfoo.so這個文件名,這個文件名就是linker name,然后去嘗試讀取這個文件,並將這個庫鏈接到生成的可執行文件A中。在執行編譯前,我們可以通過軟鏈接的形式,將libfoo.so指向一個具體so庫,也就是指向一個real name,在編譯過程中,gcc會從這個真實的庫中讀取出SONAME並將它寫入到生成的可執行文件A中。例如,若libfoo.so指向libfoo.so.1.5,則生成的可執行文件A使用主版本號為1的SONAME,即libfoo.so.1。
在上述編譯過程完成之后,SONAME已經被寫入可執行文件A中了,因此可以看到linker name僅僅在編譯的過程中,可以起到指定連接那個庫版本的作用,除此之外,再無其他作用。
總結一下上面的邏輯:
通常linker name是一個指向real name的軟連接
通過修改軟連接的指向,可以指定編譯生成的可執行文件使用那個主版本號so庫
編譯器從軟鏈接指向的文件里找到其SONAME,並將SONAME寫入到生成的可執行文件中
通過改變linker name軟連接的指向,可以將不同主版本號的SONAME寫入到生成的可執行文件中
探索可執行程序運行時的so加載過程
上一節我們詳細討論了編譯過程中,編譯器是如何將依賴信息寫入到可執行文件的。在接下來的部分,我們討論當應用被運行時,Linux操作系統是如何讀取並使用這些依賴信息並最終加載依賴的so庫的。
加載so的搜索路徑及干預方式
當在linux系統中啟動一個可執行文件時,首先發揮作用的是程序加載器(program loader),這個加載器也是一個so文件,通常具有ld-linux.so.X這樣的文件名,其中的X是版本號。大家可以回顧一下,在上文中我們用ldd /bin/bash查看了bash所依賴的so庫有哪些,其中就有/lib64/ld-linux-x86-64.so.2這個文件。其實,你可以嘗試用ldd去檢查任何一個可執行文件,你都會看到這個加載器的影子。linux下的elf格式的可執行文件在運行時,首先加載ld-linux.so,再由這個加載器去加載其他的so文件,當其他so文件都已經加載完成之后,我們自己編寫的main函數才會被執行。
加載器會在以下幾個地方進行so庫的搜索,搜索順序為從上至下,如果這些信息不存在,或者在對應的路徑下找不到能夠加載的文件,那么就嘗試下一項,如果所有的都找不到,那就會報出文章開頭展示出的找不到so庫的錯誤信息:
rpath 信息,編譯鏈接時寫入到可執行文件內部的數據
LD_LIBRARY_PATH 環境變量
runpath 信息,編譯鏈接時寫入到可執行文件內部的數據
/etc/ld.so.conf 文件中列出的路徑
/lib、/usr/lib64 等系統默認路徑
我們首先明確一點:絕大多數靠譜的應用程序都不會用到前面3項,僅依靠最后兩項就可以運行,還有一些程序會用到前面3項,但是開發者已經提供好了對應的工具,使得用戶不必去手工配置這些內容。而對於一些自己開發、內部使用、處於調試階段的程序等,由於做的不夠到位,可能導致需要配置前3項才可以讓程序正常運行,而這種情況也往往是在工作中困擾我們最多的情況。
對於上述的rpath和runpath兩項,都是在編譯可執行文件時,由鏈接器寫入到可執行文件中的信息,唯一的區別是這兩項相對於LD_LIBRARY_PATH環境變量的位置,也就是說,rpath中指定的搜索路徑不可以被LD_LIBRARY_PATH環境變量中指定的路徑覆蓋,而runpath中指定的內容卻可以被覆蓋。
rpath和runpath內可以記錄一個絕對路徑,也可以記錄一個相對路徑。其絕對路徑的表達方式和linux操作系統一致,使用一個以/開頭的路徑,就可以表示這是一個絕對路徑。但相對路徑有兩種表達形式,一種是以./開頭,表示相對於當前的工作目錄,另一種是使用$ORIGIN這個特殊的記號來開頭,表示相對於可執行文件所在的位置。
那么,rpath是如何寫入到可執行文件中的呢?以gcc為例,在使用gcc編譯的過程中,可以通過-Wl開關向鏈接器傳遞-rpath參數來指定,例如下面的這一個命令,把$ORIGIN作為rpath寫入到可執行文件中,即表示優先搜索可執行文件所在目錄下有沒有可以加載的so庫, 其中的\$轉義是為了避免shell將其理解為shell環境變量:
gcc -o main main.c -lfoo -L. -Wl,-rpath,"\$ORIGIN"
我們再來結合這個編譯命令,來回顧一下上一節提到的幾個名字:
-lfoo 告訴編譯器,我需要一個叫做foo的庫,於是gcc根據命名規則,拼接出libfoo.so這個linker name,但是去哪里找這個linker name呢?
-L. 告訴gcc,優先在當前工作目錄下去找libfoo.so, 如果不指定這個,則gcc就會默認去/usr/lib64等默認路徑去查找了。
前文說過,linker name通常是一個軟連接,指向一個real name,這種情況在/usr/lib64等路徑下很常見。但假設這里的libfoo.so也是一個我們剛剛編譯生成的so文件,僅僅在開發階段,我們也不關心什么版本管理問題,那么此時,可能軟連接並不存在。這時libfoo.so本身既是linker name也是real name
編譯器根據-L.的指示,在當前工作目錄下找到了libfoo.so,並從中讀取出了SONAME,假設為libfoo.so.1
編譯器將SONAME libfoo.so.1 寫入到生成的目標文件中
接下來,gcc調用鏈接器將目標文件和so庫做鏈接,並生成最終的可執行文件。
由於-Wl,-rpath,"\$ORIGIN"命令的存在,gcc在調用鏈接器的時候,會把-rpath $ORIGIN這個參數傳遞給鏈接器,鏈接器將$ORIGIN作為rpath寫入到最終生成的可執行文件中。
下面運行程序,程序開始運行,首先是加載器ld-linux.so被加載,加載器檢查程序依賴的所有SONAME,發現程序依賴libfoo.so.1,但是去哪找這個so文件呢?
加載器發現可執行文件里有rpath信息,其內容為$ORIGIN,於是在可執行文件所在的目錄下開始尋找所有的so文件,並檢查其中的SONAME和可執行文件中記錄的所依賴的SONAME是否匹配,如果匹配,則成功加載,如果不匹配,則嘗試下一個
注意,這一步其實是有緩存的,從而加速程序的啟動速度。緩存文件是/etc/ld.so.cache,有興趣的同學可以man ld.so來了解詳情
相比於rpath和runpath這兩個被烙印到可執行文件中的配置而言,環境變量LD_LIBRARY_PATH就是一個非常易於修改的配置,因此,通過提供LD_LIBRARY_PATH環境變量,其實是我們解決找不到依賴庫最常用的一個手段。
當然,rpath和runpath也是可以被修改的,有專用的工具如chrpath
例外情況
上述所介紹的搜索順序,在絕大多數場景下都是適用的,但有一個場景不使用,即在使用setuid、setgid、chmod +s等手段,使得一個程序可以以root身份去執行的時候:
LD_LIBRARY_PATH環境變量會被忽略
rpath和runpath中包含$ORIGIN的會被忽略
以上原因是出於安全性考慮的,避免一個特權程序會因為環境變量的改變,或者文件被復制到其他路徑,而加載了被惡意替換的so庫。詳細內容大家可以參考CVE-2010-3847,或者man ld.so。另外需要提示的是,ldd命令在執行時並不會受到這個安全策略的影響,所以,有兩點需要注意:
有可能出現ldd報告顯示依賴的so都可以找到,但實際執行這個文件就是報找不到的情況
ldd在檢測依賴的時候,相當於以一種特殊的方式執行了那個可執行文件,因此存在安全隱患,建議不要對存在風險的可執行文件使用ldd查看其依賴
實際情況下的使用過程
對於絕大多數通過軟件包管理器安裝的程序,apt-get、yum這些工具,都會幫你把需要的so放到系統默認路徑下,而且大部分應用也不需要將rpath烙印到可執行文件中,所以絕大多數情況下,僅使用上述介紹的最后兩個搜索位置就可以找到需要的文件。
有一些奇特的開發者,比如知名的mozilla,傳說他們的應用程序加載so庫的路徑不尋常,但mozilla提供了一個包裝,在啟動瀏覽器之前會幫我們臨時設置好環境變量,所以作為用戶來說,我們感知不到什么。
最有可能出現問題的,就是在我們自己的項目中引入了隔壁項目組開發的so庫,或者從網上直接下載了一些tar包后解壓縮直接使用,這時,如果出現問題,需要排查這幾點:
so庫的版本是否正確
LD_LIBRARY_PATH環境變量設置是否正確
在鏈接時,是否加入了rpath限定只能從特定位置加載so
是否在特權模式下運行
寫在最后
上文中一直在提到SONAME,並且說一個庫,可以有不同的版本,不同的主版本對應不同的SONAME,那么如何修改生成的so文件中的SONAME呢?答案是依然使用-Wl參數,指示鏈接器寫入,具體示例如下:
gcc -shared -Wl,-soname,libfoo.so.233
此外,系統中的ldconfig命令可以幫我們來維護系統中的各種依賴關系的軟連接,有興趣的同學可以自己去深入研究。畢竟手動去創建那些軟連接很容易出錯。
彩蛋:在文章開頭,我們提到過,對於Python這類解釋型語言,我們可以通過開發C擴展的形式,來提升Python的執行效率。那么,除了C擴展之外,還有其他提升Python執行效率的手段嗎?對Python感興趣的同學,可以參考我之前的文章:
PyPy簡要介紹(附Python代碼加速對比實驗)
StackOverflow高贊問題解析:為什么這段代碼在有序數組上執行比亂序數組快?(附Python版實驗)
參考文獻
[1]http://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html
[2]https://amir.rachum.com/blog/2016/09/17/shared-libraries/
[3]https://en.wikipedia.org/wiki/Rpath
[4]https://gitlab.kitware.com/cmake/community/-/wikis/doc/cmake/RPATH-handling