上一篇我們分析了Hello World是如何編譯的,即使一個非常簡單的程序,也需要依賴C標准庫和系統庫,鏈接其實就是把其他第三方庫和自己源代碼生成的二進制目標文件融合在一起的過程。經過鏈接之后,那些第三方庫中定義的函數就能被調用執行了。早期的一些操作系統一般使用靜態鏈接的方式,現在基本上都在使用動態鏈接的方式。
靜態鏈接和動態鏈接
雖然靜態鏈接和動態鏈接都能生成可執行文件,但兩者的代價差異很大。下面這張圖可以很形象地演示了動態鏈接和靜態鏈接的區別:
左側的人就像是一個動態鏈接的可執行文件,右側的海象是一個靜態鏈接的可執行文件。比起人,海象臃腫得多,那是因為靜態鏈接在鏈接的時候,就把所依賴的第三方庫函數都打包到了一起,導致最終的可執行文件非常大。而動態鏈接在鏈接的時候並不將那些庫文件直接拿過來,而是在運行時,發現用到某些庫中的某些函數時,再從這些第三方庫中讀取自己所需的方法。
我們把編譯后但是還未鏈接的二進制機器碼文件稱為目標文件(Object File),那些第三方庫是其他人編譯打包好的目標文件,這些庫里面包含了一些函數,我們可以直接調用而不用自己動手寫一遍。在編譯構建自己的可執行文件時,使用靜態鏈接的方式,其實就是將所需的靜態庫與目標文件打包到一起。最終的可執行文件除了有自己的程序外,還包含了這些第三方的靜態庫,可執行文件比較臃腫。相比而言,動態鏈接不將所有的第三方庫都打包到最終的可執行文件上,而是只記錄用到了哪些動態鏈接庫,在運行時才將那些第三方庫裝載(Load)進來。裝載是指將磁盤上的程序和數據加載到內存上。例如下圖中的Program 1,系統首先加載Program 1,發現它依賴libx.so
后才去加載libx.so
。
所以,靜態鏈接就像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