動態(tài)鏈接庫又叫共享庫(Shared Library)芦圾,相信大部分做軟件開發(fā)的人都很熟悉囊扳。簡單地說瓤荔,庫是對一系列程序的封裝挥转,靜態(tài)庫是會在鏈接時與可執(zhí)行程序合并的庫海蔽,而動態(tài)庫則在鏈接后仍然與可執(zhí)行文件分離,直到運行時才動態(tài)加載绑谣。顯然党窜,動態(tài)庫可以共享給多個可執(zhí)行程序同時使用,更節(jié)約硬盤和內存空間借宵。
不管是Windows開發(fā)者幌衣,還是Linux開發(fā)者,或者是Android壤玫、iOS開發(fā)者豁护,我們無時無刻都在生產或者使用動態(tài)庫,而且很少遇到困難欲间。這得益于一套完整的動態(tài)鏈接機制楚里,該機制保證鏈接和運行時,能夠準確找到正確的動態(tài)庫括改。本文就來探討動態(tài)鏈接的內部機制腻豌。
一個簡單的案例
看看下面這個簡單到不能再簡單的例子。有一個main.cpp
文件嘱能,用來生成可執(zhí)行程序。
// main.cpp
#include "random.h"
int main() {
return get_random_number();
}
該程序依賴于一個random
庫虱疏,庫的源碼如下:
// random.h
int get_random_number();
// random.cpp
#include "random.h"
int get_random_number(void) {
return 4;
}
現(xiàn)在惹骂,我們用clang++編譯器編譯這個程序。(clang++與g++類似做瞪,但更適合于開發(fā)对粪,可以sudo apt install clang
安裝。)
先編譯random
這個動態(tài)鏈接庫:
$ clang++ -o random.o -c random.cpp
其中装蓬,-o
指定輸出文件的名稱著拭,我們把源文件random.cpp
編譯成目標文件random.o
,-c
表示只編譯牍帚、不鏈接儡遮。然后再把目標文件編譯成動態(tài)庫:
$ clang++ -shared -o librandom.so random.o
其中,-shared
表示生成動態(tài)鏈接庫而不是靜態(tài)庫暗赶,-o
指定輸出文件名為librandom.so
鄙币。注意該名稱不是隨便定的肃叶,而是遵守了動態(tài)鏈接的慣例——所有庫的命名形式都為lib<name>.so
。
接下來十嘿,我們編譯可執(zhí)行程序因惭,首先生成目標文件main.o
:
$ clang++ -o main.o -c main.cpp
然后生成可執(zhí)行文件main
:
$ clang++ -o main main.o
main.o:在函數‘main’中:
main.cpp:(.text+0x10):對‘get_random_number()’未定義的引用
clang: error: linker command failed with exit code 1 (use -v to see invocation)
不出意外地出錯了,因為我們沒有鏈接到random
庫绩衷,所以出現(xiàn)“未定義的引用”蹦魔。
Tips:這里給個小提示,在開發(fā)C++程序的時候咳燕,只要看到錯誤信息“未定義的引用”版姑,一定是某個庫忘記鏈接了,如果用的CMake迟郎,很有可能是
target_link_libraries
里面少寫了某個依賴項剥险,或者即便寫了,但是拼寫有誤宪肖,像是把${PROTOBUF_LIBRARIES}
寫成${Protobuf_LIBRARIES}
甚至是寫成${PROTOBUF_LIBS}
而出錯的情況可是層出不窮表制。
這次我們指定需要鏈接的庫:
$ clang++ -o main main.o -lrandom
/usr/bin/ld: 找不到 -lrandom
clang: error: linker command failed with exit code 1 (use -v to see invocation)
又出錯了。倒也不難想象控乾,雖然我們指定了庫的名稱么介,但并沒有指定庫的路徑,鏈接器/usr/bin/ld
不知道去哪里找random
這個庫蜕衡。所以我們得指定搜索路徑壤短。
$ clang++ -o main main.o -lrandom -L.
其中-L
后面緊跟著路徑.
,表示在當前目錄下查找?guī)臁慨仿,F(xiàn)在久脯,終于編譯成功了,讓我們運行main
這個程序:
$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory
好了镰吆,這又是一個常見的錯誤帘撰。當我們滿心歡喜準備見證運行結果的時候,它卻告訴我們程序根本不能運行万皿。這是因為動態(tài)鏈接的可執(zhí)行程序在運行前摧找,需要先加載所需的動態(tài)鏈接庫。雖然我們剛剛用-L.
指定了鏈接路徑牢硅,但該路徑只對編譯期生效蹬耘。為了弄清楚運行時動態(tài)鏈接的方式,我們需要更加深入减余。
ELF文件格式
簡單來說综苔,目前Linux系統(tǒng)上的大部分的可執(zhí)行文件和庫文件都是ELF格式(Executable Linkable Format)。與此對應,Windows系統(tǒng)上的可執(zhí)行文件和庫文件是PE格式(Portable Executable)休里。這些格式定義了可執(zhí)行文件的二進制結構蛆挫。通常我們會認為可執(zhí)行文件里面包含的無非是二進制代碼和數據,但其實還包含了其它信息妙黍,比如架構信息悴侵、大小端模式、調試信息拭嫁、動態(tài)鏈接信息等等可免。這里我們只關注ELF中與動態(tài)鏈接相關的信息,執(zhí)行以下代碼:
$ readelf -d main
Dynamic section at offset 0xde8 contains 28 entries:
標記 類型 名稱/值
0x0000000000000001 (NEEDED) 共享庫:[librandom.so]
0x0000000000000001 (NEEDED) 共享庫:[libstdc++.so.6]
0x0000000000000001 (NEEDED) 共享庫:[libm.so.6]
0x0000000000000001 (NEEDED) 共享庫:[libgcc_s.so.1]
0x0000000000000001 (NEEDED) 共享庫:[libc.so.6]
0x000000000000000c (INIT) 0x400568
0x000000000000000d (FINI) 0x400764
0x0000000000000019 (INIT_ARRAY) 0x600dd0
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x600dd8
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x400298
0x0000000000000005 (STRTAB) 0x4003f0
0x0000000000000006 (SYMTAB) 0x4002d0
0x000000000000000a (STRSZ) 241 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x601000
0x0000000000000002 (PLTRELSZ) 48 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x400538
0x0000000000000007 (RELA) 0x400520
0x0000000000000008 (RELASZ) 24 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000006ffffffe (VERNEED) 0x400500
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x4004e2
0x0000000000000000 (NULL) 0x0
readelf
是一個查看ELF格式文件信息的工具做粤,-d
表示查看動態(tài)鏈接信息浇借。其中,前幾行列出了main
依賴的動態(tài)鏈接庫怕品,總共有5個妇垢。除了我們編譯時手動指定的librandom
之外,還有libstdc++
標準C++庫肉康、libm
基礎數學庫闯估、libgcc_s
GCC運行時庫、libc
系統(tǒng)調用庫吼和,這些庫是編譯器自動為每個可執(zhí)行程序添加的涨薪。
從上面的結果可以看出,可執(zhí)行程序會記錄每一個它所需要的動態(tài)庫的名稱炫乓,但似乎沒有記錄這些動態(tài)庫的路徑刚夺,至少在這個例子中沒有。不過末捣,當我們調用./main
時侠姑,除了random
庫兵多,其它4個庫顯然是可以鏈接成功的萧吠。我們如何才能知道運行時實際的鏈接過程呢?
好在另一個工具可以預覽運行時的鏈接信息:
$ ldd main
linux-vdso.so.1 => (0x00007ffcdde26000)
librandom.so => not found
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f74aaf37000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f74aac2e000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f74aaa18000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f74aa64e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f74ab2b9000)
果然衅胀,除了random
卒茬,其它庫都鏈接到了正確的位置】欤可ldd
是如何找到這些路徑的呢圃酵?這就依賴于動態(tài)鏈接機制中的一項規(guī)定,鏈接器會按照如下的順序在指定目錄中查找所需的動態(tài)鏈接庫:
- ELF的
rpath
中規(guī)定的路徑馍管。LD_LIBRARY_PATH
環(huán)境變量中的路徑郭赐。- ELF的
runpath
中規(guī)定的路徑。/etc/ld.so.conf
文件中列出的路徑。該文件可包含子文件捌锭,因此也包括子文件中列出的路徑俘陷。- 默認的系統(tǒng)路徑
/lib
和/usr/lib
。
鏈接器找不到random
庫观谦,說明它并不在以上這些路徑中拉盾。最簡單的做法,我們可以把random
的路徑添加到LD_LIBRARY_PATH
環(huán)境變量中豁状。
$ LD_LIBRARY_PATH=.
$ ./main
終于捉偏,我們的main
正常執(zhí)行了。但這種解決方式還不夠優(yōu)雅泻红,讓我們回想一下平常使用的程序是怎么運行的夭禽。以OpenCV為例,通常有兩種安裝方式谊路,通過apt install
安裝或編譯安裝讹躯。如果是apt install
安裝,會自動把庫安裝到系統(tǒng)目錄/usr/lib
下缠劝,這種情況鏈接器可以直接找到它們潮梯。如果編譯安裝,安裝路徑就由用戶自己指定了剩彬,默認會安裝到/usr/local
酷麦,當然也可以安裝到其它任何位置。這種情況喉恋,依賴于OpenCV的程序如何能夠找到這些庫呢沃饶?我通常用CMake編譯程序,在CMakeLists.txt
中可以指定依賴庫的路徑轻黑,并用find_package
找到這些庫糊肤。如此編譯得到的可執(zhí)行程序是可以直接運行的,我們對照上面的動態(tài)鏈接庫查找路徑氓鄙,顯然不是在2馆揉、4、5中找到的升酣,只能是在1或2中找到的。現(xiàn)在我們就來看看rpath
和runpath
到底是什么态罪。
rpath和runpath
與其它搜索路徑不同噩茄,rpath
和runpath
是直接保存在ELF文件中的,可以在編譯可執(zhí)行程序時設置該路徑≡淦校現(xiàn)在机杜,我們重新編譯main
,把rpath
加進去衅谷。
$ clang++ -o main main.o -lrandom -L. -Wl,-rpath,.
其中椒拗,-Wl
后面跟著逗號分隔的鏈接器參數-rpath
和.
,意思是告訴鏈接器參數-rpath
的值是.
会喝,也就是當前路徑《傅現(xiàn)在,不必修改環(huán)境變量肢执,直接調用./main
也可以正常運行了枉阵。如果我們再查看一下ELF中的詳細信息:
$ readelf -d main
Dynamic section at offset 0xdd8 contains 29 entries:
標記 類型 名稱/值
0x0000000000000001 (NEEDED) 共享庫:[librandom.so]
0x0000000000000001 (NEEDED) 共享庫:[libstdc++.so.6]
0x0000000000000001 (NEEDED) 共享庫:[libm.so.6]
0x0000000000000001 (NEEDED) 共享庫:[libgcc_s.so.1]
0x0000000000000001 (NEEDED) 共享庫:[libc.so.6]
0x000000000000000f (RPATH) Library rpath: [.]
...
可以發(fā)現(xiàn)多了一項RPATH
,正是我們剛剛設置的值预茄。rpath
和runpath
的區(qū)別僅僅是優(yōu)先級不同兴溜,runpath
中的路徑可以被外部的環(huán)境變量LD_LIBRARY_PATH
覆蓋,而rpath
則不會耻陕。
現(xiàn)實場景
文中的例子很易于理解拙徽,但畢竟有些不切實際,沒人會手動編譯源文件诗宣,也不會手動設置rpath
膘怕。對于Linux上的開發(fā)者而言,CMake可以說是最常用的構建工具召庞。當我們寫好CMakeLists.txt
后岛心,CMake會幫我們設置好鏈接庫的名稱,以及rpath
等搜索路徑篮灼。此外忘古,CMake提供了一些命令用來手動設置rpath
,但我們一般都不需要用诅诱,這里就不提了髓堪。值得一提的是,CMake會根據構建類型來決定是否設置rpath
娘荡,在build時干旁,會添加庫路徑到rpath
,而在install時炮沐,則會把rpath
設置為空疤孕。這是因為,install之后央拖,認為可執(zhí)行文件是可移植的,不必依賴于編譯時鏈接的特定的庫,庫的搜索路徑完全由所在系統(tǒng)的默認庫路徑和環(huán)境變量決定鲜戒。
到這里专控,本文可以告一段落了。我們了解了動態(tài)鏈接的原理和方式遏餐,雖然并不十分深入伦腐,但至少懂得了編譯器和鏈接器是如何工作的,了解了常見的鏈接錯誤出現(xiàn)的原因及其解決方案失都。
需要特別提出的是柏蘑,本文并非原創(chuàng),而是參考了一篇博客Shared Libraries: Understanding Dynamic Loading的內容粹庞,并做了適當的簡化咳焚。原文中有更詳細的內容,建議感興趣的同學去讀一讀庞溜。在此對原作者Amir Rachum表示感謝革半。
參考資料
Shared Libraries: Understanding Dynamic Loading Amir Rachum
Executable and Linkable Format Wikipedia
深入理解程序構造(一) 卡巴拉的樹
Rpath handling CMake Community Wiki