我們分析了Hello World是如何編譯的,即使一個非常簡單的程序,也需要依賴C標準庫和系統庫,連結其實就是把其他第三方庫和自己原始碼生成的二進位目標文件融合在一起的過程。經過連結之後,那些第三方庫中定義的函數就能被調用執行了。早期的一些作業系統一般使用靜態連結的方式,現在基本上都在使用動態連結的方式。
雖然靜態連結和動態連結都能生成可執行文件,但兩者的代價差異很大。下面這張圖可以很形象地演示了動態連結和靜態連結的區別:
動態連結 v.s 靜態連結
左側的人就像是一個動態連結的可執行文件,右側的海象是一個靜態連結的可執行文件。比起人,海象臃腫得多,那是因為靜態連結在連結的時候,就把所依賴的第三方庫函數都打包到了一起,導致最終的可執行文件非常大。而動態連結在連結的時候並不將那些庫文件直接拿過來,而是在運行時,發現用到某些庫中的某些函數時,再從這些第三方庫中讀取自己所需的方法。
我們把編譯後但是還未連結的二進位機器碼文件稱為目標文件(Object File),那些第三方庫是其他人編譯打包好的目標文件,這些庫裡面包含了一些函數,我們可以直接調用而不用自己動手寫一遍。在編譯構建自己的可執行文件時,使用靜態連結的方式,其實就是將所需的靜態庫與目標文件打包到一起。最終的可執行文件除了有自己的程序外,還包含了這些第三方的靜態庫,可執行文件比較臃腫。相比而言,動態連結不將所有的第三方庫都打包到最終的可執行文件上,而是只記錄用到了哪些動態連結庫,在運行時才將那些第三方庫裝載(Load)進來。裝載是指將磁碟上的程序和數據加載到內存上。例如下圖中的Program 1,系統首先加載Program 1,發現它依賴libx.so後才去加載libx.so。
靜態連結(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()函數相對於文件頭的偏移量是確定的,所以說它的地址在編譯連結後就是確定的。
相比之下,動態連結主要有以下好處:
當然,共享庫也有缺點:
在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。
so文件後面往往跟著很多數字,這表示了不同的版本。so文件命名規則被稱為SONAME:
libname.so.x.y.z
lib是前綴,這是一個約定俗成的規則。x為主版本號(Major Version),y為次版本號(Minor Version),z為發布版本號(Release Version)。
但是我們剛剛看到的.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權限的路徑下,再將其添加到環境變量中。
動態連結庫的查找先後順序為:
比如,我們把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編譯連結時,有兩個參數需要注意,一個是-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