深入理解動(dòng)態(tài)鏈接

動(dòng)態(tài)鏈接庫(kù)又叫共享庫(kù)(Shared Library),相信大部分做軟件開(kāi)發(fā)的人都很熟悉。簡(jiǎn)單地說(shuō),庫(kù)是對(duì)一系列程序的封裝,靜態(tài)庫(kù)是會(huì)在鏈接時(shí)與可執(zhí)行程序合并的庫(kù),而動(dòng)態(tài)庫(kù)則在鏈接后仍然與可執(zhí)行文件分離,直到運(yùn)行時(shí)才動(dòng)態(tài)加載。顯然,動(dòng)態(tài)庫(kù)可以共享給多個(gè)可執(zhí)行程序同時(shí)使用,更節(jié)約硬盤和內(nèi)存空間。

不管是Windows開(kāi)發(fā)者,還是Linux開(kāi)發(fā)者,或者是Android、iOS開(kāi)發(fā)者,我們無(wú)時(shí)無(wú)刻都在生產(chǎn)或者使用動(dòng)態(tài)庫(kù),而且很少遇到困難。這得益于一套完整的動(dòng)態(tài)鏈接機(jī)制,該機(jī)制保證鏈接和運(yùn)行時(shí),能夠準(zhǔn)確找到正確的動(dòng)態(tài)庫(kù)。本文就來(lái)探討動(dòng)態(tài)鏈接的內(nèi)部機(jī)制。

一個(gè)簡(jiǎn)單的案例

看看下面這個(gè)簡(jiǎn)單到不能再簡(jiǎn)單的例子。有一個(gè)main.cpp文件,用來(lái)生成可執(zhí)行程序。

// main.cpp
#include "random.h"

int main() {
    return get_random_number();
}

該程序依賴于一個(gè)random庫(kù),庫(kù)的源碼如下:

// random.h
int get_random_number();
// random.cpp
#include "random.h"

int get_random_number(void) {
    return 4;
}

現(xiàn)在,我們用clang++編譯器編譯這個(gè)程序。(clang++與g++類似,但更適合于開(kāi)發(fā),可以sudo apt install clang安裝。)

先編譯random這個(gè)動(dòng)態(tài)鏈接庫(kù):

$ clang++ -o random.o -c random.cpp

其中,-o指定輸出文件的名稱,我們把源文件random.cpp編譯成目標(biāo)文件random.o,-c表示只編譯、不鏈接。然后再把目標(biāo)文件編譯成動(dòng)態(tài)庫(kù):

$ clang++ -shared -o librandom.so random.o

其中,-shared表示生成動(dòng)態(tài)鏈接庫(kù)而不是靜態(tài)庫(kù),-o指定輸出文件名為librandom.so。注意該名稱不是隨便定的,而是遵守了動(dòng)態(tài)鏈接的慣例——所有庫(kù)的命名形式都為lib<name>.so。

接下來(lái),我們編譯可執(zhí)行程序,首先生成目標(biāo)文件main.o

$ clang++ -o main.o -c main.cpp

然后生成可執(zhí)行文件main

$ clang++ -o main main.o
main.o:在函數(shù)‘main’中:
main.cpp:(.text+0x10):對(duì)‘get_random_number()’未定義的引用
clang: error: linker command failed with exit code 1 (use -v to see invocation)

不出意外地出錯(cuò)了,因?yàn)槲覀儧](méi)有鏈接到random庫(kù),所以出現(xiàn)“未定義的引用”。

Tips:這里給個(gè)小提示,在開(kāi)發(fā)C++程序的時(shí)候,只要看到錯(cuò)誤信息“未定義的引用”,一定是某個(gè)庫(kù)忘記鏈接了,如果用的CMake,很有可能是target_link_libraries里面少寫了某個(gè)依賴項(xiàng),或者即便寫了,但是拼寫有誤,像是把${PROTOBUF_LIBRARIES}寫成${Protobuf_LIBRARIES}甚至是寫成${PROTOBUF_LIBS}而出錯(cuò)的情況可是層出不窮。

這次我們指定需要鏈接的庫(kù):

$ clang++ -o main main.o -lrandom
/usr/bin/ld: 找不到 -lrandom
clang: error: linker command failed with exit code 1 (use -v to see invocation)

又出錯(cuò)了。倒也不難想象,雖然我們指定了庫(kù)的名稱,但并沒(méi)有指定庫(kù)的路徑,鏈接器/usr/bin/ld不知道去哪里找random這個(gè)庫(kù)。所以我們得指定搜索路徑。

$ clang++ -o main main.o -lrandom -L.

其中-L后面緊跟著路徑.,表示在當(dāng)前目錄下查找?guī)臁,F(xiàn)在,終于編譯成功了,讓我們運(yùn)行main這個(gè)程序:

$ ./main
./main: error while loading shared libraries: librandom.so: cannot open shared object file: No such file or directory

好了,這又是一個(gè)常見(jiàn)的錯(cuò)誤。當(dāng)我們滿心歡喜準(zhǔn)備見(jiàn)證運(yùn)行結(jié)果的時(shí)候,它卻告訴我們程序根本不能運(yùn)行。這是因?yàn)閯?dòng)態(tài)鏈接的可執(zhí)行程序在運(yùn)行前,需要先加載所需的動(dòng)態(tài)鏈接庫(kù)。雖然我們剛剛用-L.指定了鏈接路徑,但該路徑只對(duì)編譯期生效。為了弄清楚運(yùn)行時(shí)動(dòng)態(tài)鏈接的方式,我們需要更加深入。

ELF文件格式

簡(jiǎn)單來(lái)說(shuō),目前Linux系統(tǒng)上的大部分的可執(zhí)行文件和庫(kù)文件都是ELF格式(Executable Linkable Format)。與此對(duì)應(yīng),Windows系統(tǒng)上的可執(zhí)行文件和庫(kù)文件是PE格式(Portable Executable)。這些格式定義了可執(zhí)行文件的二進(jìn)制結(jié)構(gòu)。通常我們會(huì)認(rèn)為可執(zhí)行文件里面包含的無(wú)非是二進(jìn)制代碼和數(shù)據(jù),但其實(shí)還包含了其它信息,比如架構(gòu)信息、大小端模式、調(diào)試信息、動(dòng)態(tài)鏈接信息等等。這里我們只關(guān)注ELF中與動(dòng)態(tài)鏈接相關(guān)的信息,執(zhí)行以下代碼:

$ readelf -d main
Dynamic section at offset 0xde8 contains 28 entries:
  標(biāo)記        類型                         名稱/值
 0x0000000000000001 (NEEDED)             共享庫(kù):[librandom.so]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libstdc++.so.6]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libm.so.6]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             共享庫(kù):[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是一個(gè)查看ELF格式文件信息的工具,-d表示查看動(dòng)態(tài)鏈接信息。其中,前幾行列出了main依賴的動(dòng)態(tài)鏈接庫(kù),總共有5個(gè)。除了我們編譯時(shí)手動(dòng)指定的librandom之外,還有libstdc++標(biāo)準(zhǔn)C++庫(kù)、libm基礎(chǔ)數(shù)學(xué)庫(kù)、libgcc_sGCC運(yùn)行時(shí)庫(kù)、libc系統(tǒng)調(diào)用庫(kù),這些庫(kù)是編譯器自動(dòng)為每個(gè)可執(zhí)行程序添加的。

從上面的結(jié)果可以看出,可執(zhí)行程序會(huì)記錄每一個(gè)它所需要的動(dòng)態(tài)庫(kù)的名稱,但似乎沒(méi)有記錄這些動(dòng)態(tài)庫(kù)的路徑,至少在這個(gè)例子中沒(méi)有。不過(guò),當(dāng)我們調(diào)用./main時(shí),除了random庫(kù),其它4個(gè)庫(kù)顯然是可以鏈接成功的。我們?nèi)绾尾拍苤肋\(yùn)行時(shí)實(shí)際的鏈接過(guò)程呢?

好在另一個(gè)工具可以預(yù)覽運(yùn)行時(shí)的鏈接信息:

$ 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,其它庫(kù)都鏈接到了正確的位置。可ldd是如何找到這些路徑的呢?這就依賴于動(dòng)態(tài)鏈接機(jī)制中的一項(xiàng)規(guī)定,鏈接器會(huì)按照如下的順序在指定目錄中查找所需的動(dòng)態(tài)鏈接庫(kù):

  1. ELF的rpath中規(guī)定的路徑。
  2. LD_LIBRARY_PATH環(huán)境變量中的路徑。
  3. ELF的runpath中規(guī)定的路徑。
  4. /etc/ld.so.conf文件中列出的路徑。該文件可包含子文件,因此也包括子文件中列出的路徑。
  5. 默認(rèn)的系統(tǒng)路徑/lib/usr/lib。

鏈接器找不到random庫(kù),說(shuō)明它并不在以上這些路徑中。最簡(jiǎn)單的做法,我們可以把random的路徑添加到LD_LIBRARY_PATH環(huán)境變量中。

$ LD_LIBRARY_PATH=.
$ ./main

終于,我們的main正常執(zhí)行了。但這種解決方式還不夠優(yōu)雅,讓我們回想一下平常使用的程序是怎么運(yùn)行的。以O(shè)penCV為例,通常有兩種安裝方式,通過(guò)apt install安裝或編譯安裝。如果是apt install安裝,會(huì)自動(dòng)把庫(kù)安裝到系統(tǒng)目錄/usr/lib下,這種情況鏈接器可以直接找到它們。如果編譯安裝,安裝路徑就由用戶自己指定了,默認(rèn)會(huì)安裝到/usr/local,當(dāng)然也可以安裝到其它任何位置。這種情況,依賴于OpenCV的程序如何能夠找到這些庫(kù)呢?我通常用CMake編譯程序,在CMakeLists.txt中可以指定依賴庫(kù)的路徑,并用find_package找到這些庫(kù)。如此編譯得到的可執(zhí)行程序是可以直接運(yùn)行的,我們對(duì)照上面的動(dòng)態(tài)鏈接庫(kù)查找路徑,顯然不是在2、4、5中找到的,只能是在1或2中找到的?,F(xiàn)在我們就來(lái)看看rpathrunpath到底是什么。

rpath和runpath

與其它搜索路徑不同,rpathrunpath是直接保存在ELF文件中的,可以在編譯可執(zhí)行程序時(shí)設(shè)置該路徑。現(xiàn)在,我們重新編譯main,把rpath加進(jìn)去。

$ clang++ -o main main.o -lrandom -L. -Wl,-rpath,.

其中,-Wl后面跟著逗號(hào)分隔的鏈接器參數(shù)-rpath.,意思是告訴鏈接器參數(shù)-rpath的值是.,也就是當(dāng)前路徑?,F(xiàn)在,不必修改環(huán)境變量,直接調(diào)用./main也可以正常運(yùn)行了。如果我們?cè)俨榭匆幌翬LF中的詳細(xì)信息:

$ readelf -d main
Dynamic section at offset 0xdd8 contains 29 entries:
  標(biāo)記        類型                         名稱/值
 0x0000000000000001 (NEEDED)             共享庫(kù):[librandom.so]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libstdc++.so.6]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libm.so.6]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             共享庫(kù):[libc.so.6]
 0x000000000000000f (RPATH)              Library rpath: [.]
 ...

可以發(fā)現(xiàn)多了一項(xiàng)RPATH,正是我們剛剛設(shè)置的值。rpathrunpath的區(qū)別僅僅是優(yōu)先級(jí)不同,runpath中的路徑可以被外部的環(huán)境變量LD_LIBRARY_PATH覆蓋,而rpath則不會(huì)。

現(xiàn)實(shí)場(chǎng)景

文中的例子很易于理解,但畢竟有些不切實(shí)際,沒(méi)人會(huì)手動(dòng)編譯源文件,也不會(huì)手動(dòng)設(shè)置rpath。對(duì)于Linux上的開(kāi)發(fā)者而言,CMake可以說(shuō)是最常用的構(gòu)建工具。當(dāng)我們寫好CMakeLists.txt后,CMake會(huì)幫我們?cè)O(shè)置好鏈接庫(kù)的名稱,以及rpath等搜索路徑。此外,CMake提供了一些命令用來(lái)手動(dòng)設(shè)置rpath,但我們一般都不需要用,這里就不提了。值得一提的是,CMake會(huì)根據(jù)構(gòu)建類型來(lái)決定是否設(shè)置rpath,在build時(shí),會(huì)添加庫(kù)路徑到rpath,而在install時(shí),則會(huì)把rpath設(shè)置為空。這是因?yàn)?,install之后,認(rèn)為可執(zhí)行文件是可移植的,不必依賴于編譯時(shí)鏈接的特定的庫(kù),庫(kù)的搜索路徑完全由所在系統(tǒng)的默認(rèn)庫(kù)路徑和環(huán)境變量決定。

到這里,本文可以告一段落了。我們了解了動(dòng)態(tài)鏈接的原理和方式,雖然并不十分深入,但至少懂得了編譯器和鏈接器是如何工作的,了解了常見(jiàn)的鏈接錯(cuò)誤出現(xiàn)的原因及其解決方案。

需要特別提出的是,本文并非原創(chuàng),而是參考了一篇博客Shared Libraries: Understanding Dynamic Loading的內(nèi)容,并做了適當(dāng)?shù)暮?jiǎn)化。原文中有更詳細(xì)的內(nèi)容,建議感興趣的同學(xué)去讀一讀。在此對(duì)原作者Amir Rachum表示感謝。

參考資料

Shared Libraries: Understanding Dynamic Loading Amir Rachum
Executable and Linkable Format Wikipedia
深入理解程序構(gòu)造(一) 卡巴拉的樹(shù)
Rpath handling CMake Community Wiki

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 2016年國(guó)慶假期終于把此書過(guò)完,整理筆記和體會(huì)于此。 關(guān)于書名 書名源于俄羅斯的演員斯坦尼斯拉夫斯基創(chuàng)作的《演員...
    李劍飛的簡(jiǎn)書閱讀 7,462評(píng)論 2 65
  • 一、溫故而知新 1. 內(nèi)存不夠怎么辦 內(nèi)存簡(jiǎn)單分配策略的問(wèn)題地址空間不隔離內(nèi)存使用效率低程序運(yùn)行的地址不確定 關(guān)于...
    SeanCST閱讀 8,146評(píng)論 0 27
  • Spring Cloud為開(kāi)發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見(jiàn)模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,711評(píng)論 19 139
  • 生活是個(gè)太過(guò)沉重的話題,說(shuō)不清,道不明,但依然要拖著身體前行。也許你讀出了消極的氣息,但生活就是不斷地挑戰(zhàn)自己的心...
    心存善念王李軍閱讀 611評(píng)論 4 18
  • 皺紙撫未平,筆沁舊書音。 三言兩句半,字字盡誅心。 前有孟氏女,后有卓文君。 如是秦河柳,西湖小妹墳。 宋詞本十色...
    寒菊閱讀 503評(píng)論 0 2

友情鏈接更多精彩內(nèi)容