Linux的so檔案到底是幹嘛的?淺析Linux的動態連結庫

HelloWorld背後的故事:在Linux上編譯C語言程式 我們分析了Hello World是如何編譯的,即使一個非常簡單的程式,也需要依賴C標準庫和系統庫,

連結

其實就是把其他第三方庫和自己原始碼生成的二進位制目標檔案融合在一起的過程。經過連結之後,那些第三方庫中定義的函式就能被呼叫執行了。早期的一些作業系統一般使用靜態連結的方式,現在基本上都在使用動態連結的方式。

靜態連結和動態連結

雖然靜態連結和動態連結都能生成可執行檔案,但兩者的代價差異很大。下面這張圖可以很形象地演示了動態連結和靜態連結的區別:

Linux的so檔案到底是幹嘛的?淺析Linux的動態連結庫

動態連結 v。s 靜態連結

左側的人就像是一個動態連結的可執行檔案,右側的海象是一個靜態連結的可執行檔案。比起人,海象臃腫得多,那是因為靜態連結在連結的時候,就把所依賴的第三方庫函式都打包到了一起,導致最終的可執行檔案非常大。而動態連結在連結的時候並不將那些庫檔案直接拿過來,而是在執行時,發現用到某些庫中的某些函式時,再從這些第三方庫中讀取自己所需的方法。

我們把編譯後但是還未連結的二進位制機器碼檔案稱為目標檔案(Object File),那些第三方庫是其他人編譯打包好的目標檔案,這些庫裡面包含了一些函式,我們可以直接呼叫而不用自己動手寫一遍。在編譯構建自己的可執行檔案時,使用靜態連結的方式,其實就是將所需的靜態庫與目標檔案打包到一起。最終的可執行檔案除了有自己的程式外,還包含了這些第三方的靜態庫,可執行檔案比較臃腫。相比而言,動態連結不將所有的第三方庫都打包到最終的可執行檔案上,而是隻記錄用到了哪些動態連結庫,在執行時才將那些第三方庫裝載(Load)進來。裝載是指將磁碟上的程式和資料載入到記憶體上。例如下圖中的Program 1,系統首先載入Program 1,發現它依賴libx。so後才去載入libx。so。

Linux的so檔案到底是幹嘛的?淺析Linux的動態連結庫

靜態連結(Static Link)和動態連結(Dynamic Link)

所以,靜態連結就像GIF圖中的海象,把所需的東西都帶在了身上。動態連結只把精簡後的內容帶在自己身上,需要什麼,執行的時候再去拿。

不同作業系統的動態連結庫檔案格式稍有不同,Linux稱之為共享目標檔案(Shared Object),檔案字尾為。so,Windows的動態連結庫(Dynamic Link Library)檔案字尾為。dll。

地址無關

無論何種作業系統上,使用動態連結生成的目標檔案中凡是涉及第三方庫的函式呼叫都是

地址無關

的。假如我們自己編寫的程式名為Program 1,Program 1中呼叫了C標準庫的printf(),在生成的目標檔案中,不會立即確定printf()的具體地址,而是在執行時去裝載這個函式,在裝載階段確定printf()的地址。這裡提到的地址指的是程序在記憶體上的虛擬地址。動態連結庫的函式地址在編譯時是不確定的,在裝載時,裝載器根據當前地址空間情況,動態地分配一塊虛擬地址空間。

而靜態連結庫其實是在編譯時就確定了庫函式地址。比如,我們使用了printf()函式,printf()函式對應有一個目標檔案printf。o,靜態連結時,會把printf。o連結打包到可執行檔案中。在可執行檔案中,printf()函式相對於檔案頭的偏移量是確定的,所以說它的地址在編譯連結後就是確定的。

動態連結的優缺點

相比之下,動態連結主要有以下好處:

多個可執行檔案可以共享使用系統中的共享庫。每個可執行檔案都更小,佔用的磁碟空間也相對比較小。而靜態連結把所依賴的庫打包進可執行檔案,假如printf()被其他程式使用了上千次,就要被打包到上千個可執行檔案中,這樣會佔用了大量磁碟空間。

共享庫的之間隔離決定了共享庫可以進行小版本的程式碼升級,重新編譯並部署到作業系統上,並不影響它被可執行檔案呼叫。靜態連結庫的任何函式有了改動,除了靜態連結庫本身需要重新編譯構建,依賴這個函式的所有可執行檔案都需要重新編譯構建一遍。

當然,共享庫也有缺點:

如果將一份目標檔案移植到一個新的作業系統上,而新的作業系統缺少相應的共享庫,程式將無法執行,必須在作業系統上安裝好相應的庫才行。

共享庫必須按照一定的開發和升級規則升級,不能突然重構所有的介面,且新庫檔案直接覆蓋老庫檔案,否則程式將無法執行。

ldd命令檢視動態連結庫依賴

在Linux上,動態連結庫有預設的部署位置,很多重要的庫放在了系統的/lib和/usr/lib兩個路徑下。一些常用的Linux命令非常依賴/lib和/usr/lib64下面的各個庫,比如:scp、rm、cp、mv等Linux下常用的命令非常依賴/lib和/usr/lib64下的各個庫。不小心刪除了這些路徑,可能導致系統的很多命令和工具都無法繼續使用。

我們可以用ldd命令檢視某個可執行檔案依賴了哪些動態連結庫。

# on Ubuntu 16。04 x86_64$ ldd /bin/ls linux-vdso。so。1 => (0x00007ffcd3dd9000) libselinux。so。1 => /lib/x86_64-linux-gnu/libselinux。so。1 (0x00007f4547151000) libc。so。6 => /lib/x86_64-linux-gnu/libc。so。6 (0x00007f4546d87000) libpcre。so。3 => /lib/x86_64-linux-gnu/libpcre。so。3 (0x00007f4546b17000) libdl。so。2 => /lib/x86_64-linux-gnu/libdl。so。2 (0x00007f4546913000) /lib64/ld-linux-x86-64。so。2 (0x00007f4547373000) libpthread。so。0 => /lib/x86_64-linux-gnu/libpthread。so。0 (0x00007f45466f6000)複製程式碼

可以看到,我們經常使用的ls命令依賴了不少庫,包括了C語言標準庫libc。so。

如果某個Linux的程式報錯提示缺少某個庫,可以用ldd命令可以用來檢查這個程式依賴了哪些庫,是否能在磁碟某個路徑下找到。so檔案。如果找不到,需要使用環境變數LD_LIBRARY_PATH來調整,下文將介紹環境變數LD_LIBRARY_PATH。

SONAME檔案命名規則

so檔案後面往往跟著很多數字,這表示了不同的版本。so檔案命名規則被稱為SONAME:

libname。so。x。y。z

lib是字首,這是一個約定俗成的規則。x為主版本號(Major Version),y為次版本號(Minor Version),z為釋出版本號(Release Version)。

Major Version表示重大升級,不同Major Version之間的庫是不相容的。Major Version升級後,或者依賴舊Major Version的程式需要更新程式碼,重新編譯,才可以在新的Major Version上執行;或者作業系統保留舊Major Version,使得老程式依然能執行。

Minor Version表示增量更新,一般是增加了一些新介面,原來的介面不變。所以,在Major Version相同的情況下,Minor Version從高到低是相容的。

Release Version表示庫的一些bug修復,效能改進等,不新增任何新的介面,不改變原來的介面。

但是我們剛剛看到的。so只有一個Major Version,因為這是一個軟連線,libname。so。x軟連線到了libname。so。x。y。z檔案上。

$ ls -l /lib/x86_64-linux-gnu/libpcre。so。3/lib/x86_64-linux-gnu/libpcre。so。3 -> libpcre。so。3。13。2

因為不同的Major Version之間不相容,而Minor Version和Release Version都是向下相容的,軟連線會指向Major Version相同,Minor Version和Release Version最高的。so檔案上。

動態連結庫查詢過程

剛才提到,Linux的動態連結庫絕大多數都在/lib和/usr/lib下,作業系統也會預設去這兩個路徑下搜尋動態連結庫。另外,/etc/ld。so。conf檔案裡可以配置路徑,/etc/ld。so。conf檔案會告訴作業系統去哪些路徑下搜尋動態連結庫。這些位置的動態連結庫很多,如果連結器每次都去這些路徑遍歷一遍,非常耗時,Linux提供了ldconfig工具,這個工具會對這些路徑的動態連結庫按照SONAME規則建立軟連線,同時也會生成一個快取Cache到/etc/ld。so。cache檔案裡,連結器根據快取可以更快地查詢到各個。so檔案。每次在/lib和/usr/lib這些路徑下安裝了新的庫,或者更改了/etc/ld。so。conf檔案,都需要呼叫ldconfig命令來做一次更新,重新生成軟連線和Cache。但是/etc/ld。so。conf檔案和ldconfig命令最好使用root賬戶操作。非root使用者可以在某個路徑下安裝庫檔案,並將這個路徑新增到/etc/ld。so。conf檔案下,再由root使用者呼叫一下ldconfig。

對於非root使用者,另一種方法是使用LD_LIBRARY_PATH環境變數。LD_LIBRARY_PATH存放著若干路徑。連結器會去這些路徑下查詢庫。非root可以將某個庫安裝在了一個非root許可權的路徑下,再將其新增到環境變數中。

動態連結庫的查詢先後順序為:

LD_LIBRARY_PATH環境變數中的路徑

/etc/ld。so。cache快取檔案

/usr/lib和/lib

比如,我們把CUDA安裝到/opt下面,我們可以使用下面的命令將CUDA新增到環境變數裡。

export LD_LIBRARY_PATH=/opt/cuda/cuda-toolkit/lib64:$LD_LIBRARY_PATH

如果在執行某個具體程式前先執行上面的命令,那麼這個程式將使用這個路徑下的CUDA;如果將這行新增到了。bashrc檔案,那麼該使用者一登入就會執行這行命令,因此該使用者的所有程式也都將使用這個路徑下的CUDA。當同一個動態連結庫有多個不同版本的。so檔案時,可以將它們安裝到不同的路徑下面,然後使用LD_LIBRARY_PATH環境變數來控制使用哪個庫。這種比較適合在多人共享的伺服器上使用不同版本的庫,比如CUDA這種版本變化較快,且深度學習程式又高度依賴的庫。

除了LD_LIBRARY_PATH環境變數外,還有一個LD_PRELOAD環境變數。LD_PRELOAD的查詢順序比LD_LIBRARY_PATH還要優先。LD_PRELOAD裡是具體的目標檔案列表(A list of shared objects);LD_LIBRARY_PATH是目錄列表(A list of directories)。

GCC編譯選項

使用GCC編譯連結時,有兩個引數需要注意,一個是-l(小寫的L),一個是-L(大寫的L)。我們前面曾提到,Linux有個約定速成的規則,假如庫名是name,那麼動態連結庫檔名就是libname。so。在使用GCC編譯連結時,-lname來告訴GCC使用哪個庫。連結時,GCC的連結器ld就會前往LD_LIBRARY_PATH環境變數、/etc/ld。so。cache快取檔案和/usr/lib和/lib目錄下去查詢libname。so。我們也可以用-L/path/to/library的方式,讓連結器ld去/path/to/library路徑下去找庫檔案。

如果動態連結庫檔案在/path/to/library,庫名叫name,編譯連結的方式如下:

$ gcc -L/path/to/library -lname myfile。c