1. 動態鏈接器的自舉
我們知道動態鏈接器本身也是一個共享對象,但是事實上它有一些特殊性。對於普通共享對象文件來說,它的重定位工作由動態鏈接器來完成。他也可以依賴其他共享對象,其中的被依賴共享對象由動態鏈接器負責鏈接和裝載。可是對於動態鏈接器來說,它的重定位工作由誰來完成?它是否可以依賴於其他共享對象?
這是一個“雞生蛋,蛋生雞”的問題,為了解決這種無休止的循環,動態鏈接器這個“雞” 必須有些特殊性。首先是,動態鏈接器本身不可以依賴於其他任何共享對象;其次是動態鏈接器本身所需要的全局和靜態變量和重定位工作由它本身完成。對於第一個條件我們可以認為的控制。在編寫動態鏈接器時必須保證不使用任何系統庫,運行庫;對於第二個條件,動態鏈接器必須在啟動時有一段非常精巧的代碼可以完成這項艱巨的工作而同時又不能使用全局和靜態變量。這種具有一定限制條件的啟動代碼往往被稱為自舉(Bootstrap)。
動態鏈接器入口地址即是自舉代碼的入口,當操作系統將進程控制權交給動態鏈接器時,動態鏈接器的自舉代碼即開始運行。自舉代碼首先會找到它自己的GOT。而GOT的第一個入口保存的是“.dynamic”段的偏移地址,由此找到了動態連機器本身的“.dynamic”段。通過“.dynamic”的信息,自舉代碼便可以獲得動態鏈接器本身的重定位表和符號表等,從而得到動態鏈接器本身的重定位入口,先將它們全部重定位。從這一步開始,動態鏈接器代碼中才可以使用自己的全局變量和靜態變量。
實際上在動態鏈接器的自舉代碼中,除了不可以使用全局變量和靜態變量之外,甚至不能調用函數,即動態鏈接器本身的函數也不能調用。這是為什么呢?其實我們在前面分析地址無關代碼時已經提到過,實際上使用PIC模式編譯的共享對象,對於模塊內部的函數調用也是采用跟模塊外部函數調用一樣的方式,即使用 GOT/PLT的方式,所以在 GOT/PLT沒有被重定位之前,自舉代碼不可以使用任何全局變量,也不可以調用函數。下面這段注釋來自於 Glibc26.1源代碼中的 elf/rtld.c
這段注釋寫在白舉代碼的末尾,表示自舉代碼已經執行結束。“ Now life is sane",可以想象動態鏈接器的作者在此時大舒一冂氣,終於完成白舉了,可以自由地調用各種函數並且隨意訪問全局變量了,
2. 裝載共享對象
完成基本自舉以后,動態鏈接器將可執行文件和鏈接器本身的符號表都合並到一個符號表當中,我們可以稱它為全局符號表( Global Symbol Table)。然后鏈接器開始尋找可執文件所依賴的共享對象,我們前面提到過“.dynamic”段中,有一種類型的入口DT_NEEDED,它所指出的是該可執行文件(或共享對象)所依賴的共享對象。由此,鏈接器可以列出可執行文件所需要的所有共享對象,並將這些共享對象的名字放入到一個裝載集合中。然后鏈接器開始從集合里取個所需要的共享對象的名字,找到相應的文件后打開該文件,讀取相應的ELF文件頭和“ .dynamic”段,然后將它相應的代碼段和數據段映射到進程空間中。如果這個ELF共享對象還依賴於其他共享對象,那么將所依賴的共享對象的名字放到裝載集合中。如此循環直到所有依賴的共享對象都被裝載進來為止,當然鏈接器可以有不同的裝載順序,如果我們把依賴關系看作一個圖的話,那么這個裝載過程就是一個圖的遍歷過程,鏈接器可能會使用深度優先或者廣度優先或者其他的順序來遍歷整個圖,這取決於鏈接器,比較常見的算法一般都是廣度優先的。
當一個新的共享對象被裝載進來的時候,它的符號表會被合並到全局符號表中,所以當所有的共享對象都被裝載進來的時候,全局符號表里面將包含進程中的所有動態鏈接所需要的符號。
符號的優先級
在動態鏈接器按照各個模塊之間的依賴關系,對它們進行裝載並且將它們的符號並入到全局符號表時,會不會有這么一種情況發生,那就是有可能不同的模塊定義了同一個符號?讓我們來看看這樣一個例子:共有4個共享對象a1.so,a2.so, b1.so, b2.so,它們的源代碼文件分別為a1.c, a2.c, b1.c 和 b2.c
/*a1.c*/
#include <stdio.h>
void a() {
printf("a1.c\n");
}
/*a2.c*/
#include <stdio.h>
void a() {
printf("a2.c\n");
}
/*b1.c*/
#include <stdio.h>
void a();
void b1() {
a();
}
/*b2.c*/
#include <stdio.h>
void a();
void b2() {
a();
}
可以看到a1.c和a2.c中都定義了名字為a的函數,那么由於b1.c和b2.c都用到了外部函數“a”,但由於源代碼中沒有指定依賴於哪一個共享對象中的函數“a”,所以我們在編譯時指定依賴關系。我們假設b1.so依賴於a1.so,b2.so依賴於a2.so,將b1.so與a1.so進行鏈接,b2.so與a2.so進行鏈接:
$gcc -fPIC -shared a1.c -o a1.so
$gcc -fPIC -shared a2.c -o a2.so
$gcc -fPIC -shared b1.c a1.so -o b1.so
$gcc -fPIC -shared b2.c a2.so -o b2.so
$ldd b1.so
linux-gate.so.1 -> (0xffffe000)
a1.so -> not found
libc.so.6 -> /lib/tls/i686/cmov/libc.so.6 (0xb7e86000)
/lib/ld-linux.so.2 (0x80000000)
$ldd b2.so
linux-gate.so.1 -> (0xffffe000)
a2.so -> not found
libc.so.6 -> /lib/tls/i686/cmov/libc.so.6 (0xb7e17000)
/lib/ld-linux.so.2 (0x80000000)
那么當有程序同時使用b1.c中的函數b1和b2.c中的函數b2會怎么樣呢?比如有程序
main.c
#include <stdio.h>
void b1();
void b2();
int main() {
b1();
b2();
return 0;
}
然后我們將main.c編譯成可執行文件並且運行:
$gcc main.c b1.so b2.so -o main -Xlinker -rpath ./
./main
a1.c
a1.c
很明顯,main依賴於b1.so和b2.so;b1.so依賴於a1.so;b2.so依賴於a2.so,所以當動態鏈接器對main程序進行動態鏈接時,b1.so、b2.so、a1.so和a2.so都會被裝載到進程的地址空間,並且它們中的符號都會被並入到全局符號表,通過查看進程的地址空間信息可以看到:
這4個共享對象的確都被裝載進來了,那a1.so中的函數a和a2.so中的函數a是不是沖突了呢?為什么main的輸出結果是兩個“al.c”呢?也就是說a2.so中的函數a似乎被忽略了。這種一個共享對象里面的全局符號被另一個共享對象的同名全局符號覆蓋的現象又被稱為共享對象全局符號介入(Global symbol interpose)
關於全局符號介入這個問題,實際上Linux下的動態鏈接器是這樣處理的:它定義了一個規則,那就是當一個符號需要被加入全局符號表時,如果相同的符號名已經存在,則后加入的符號被忽略從動態鏈接器的裝載順序可以看到,它是按照廣度優先的順序進行裝載的,首先是main,然后是b1.so、b2.so、a1.so,最后是a2.so。當a2.so中的函數a要被加入全局符號表時,先前裝載a1.so時,al.o中的函數a已經存在於全局符號表,那么a2.so中的函數a只能被忽略。所以整個進程中,所有對於符合“a”的引用都會被解析到a1.so中的函數a,這也是為什么main打印出的結果是兩個“a1.c”而不是理想中的“alc”和“a2.c”。
由於存在這種重名符號被直接忽略的問題,當程序使用大量共享對象時應該非常小心符號的重名問題,如果兩個符號重名又執行不同的功能,那么程序運行時可能會將所有該符號名的引用解析到第-個被加入全局符號表的使用該符號名的符號,從而導致程序莫名其妙的錯誤。
全局符號介入與地址無關代碼
前面介紹地址無關代碼時,對於第一類模塊內部調用或跳轉的處理時,我們簡單地將其當作是相對地址調用/跳轉。但實際上這個問題比想象中要復雜,結合全局符號介入,關於調用方式的分類的解釋會更加清楚。還是拿前面“pic.c”的例子來看,由於可能存在全局符號介入的問題,foo函數對於bar的調用不能夠采用第一類模塊內部調用的方法,因為一旦bar函數由於全局符號介入被其他模塊中的同名函數覆蓋,那么foo如果采用相對地址調用的話,那個相對地址部分就需要重定位,這又與共享對象的地址無關性矛盾。所以對於bar()函數的調用,編譯器只能采用第三種,即當作模塊外部符號處理,bar()函數被覆蓋,動態鏈接器只需要重定位“.got .plt”,不影響共享對象的代碼段
為了提高模塊內部函數調用的效率,有一個辦法是把bar()函數變成編譯單元私有函數,即使用“ statIc”關鍵字定義bar()函數,這種情況下,編譯器要確定bar()函數不被其他模塊覆蓋,就可以使用第一類的方法,即模塊內部調用指令,可以加快函數的調用速度。
3. 重定位與初始化
當上面的步驟完成之后,鏈接器開始重新遍歷可執行的文件和每個共享對象的重定位表,將它們的GOT/PLT的每個需要重定位的位置進行修正。因為此時動態鏈接器已經擁有了進程的全局符號表,所以這個修正過程也顯得比較容易,跟我們前面提到的地址重定位的原理基本相同。在前面介紹動態鏈接的重定位表時,我們已經碰到了幾種重定位類型,每種重定位入口地址的計算方法我們在這里就不再重復介紹了。
重定位完成之后,如果某個共享對象有“.init”段,那么動態鏈接器會執行“.init”段中的代碼,用以實現共享對象特有的初始化過程,比如最常見的,共享對象中的C++ 的全局靜態對象的構造就需要通過“init”來初始化。相應地,共享對象中還可能有“ finit”段,當進程退出時會執行“.finit"段中的代碼,可以用來實現類似C++全局對象析構之類的操作。
如果進程的可執行文件也有“init”段,那么動態鏈接器不會執行它,因為可執行文件中的“init”段和“ finit”段由程序初始化部分代碼負責執行,我們將在后面的“庫”這部分詳細介紹程序初始化部分。
當完成了重定位和初始化之后,所有的准備工作就宣告完成了,所需要的共享對象都已經裝載並且鏈接完成了,這時候動態鏈接器就如釋重負,將進程的控制權轉交給程序的入口並且開始執行。
4. linux動態鏈接器的實現
在前面分析 Linux下程序的裝載時,己經介紹了一個通過 execve()系統調用被裝載到進程的地址空間的程序,以及內核如何處理可執行文件。內核在裝載完ELF可執行文件以后就返回到用戶空間,將控制權交給程序的入口。對於不同鏈接形式的ELF可執行文件,這個程序的入口是有區別的。對於靜態鏈接的可執行文件來說,程序的入口就是ELF文件頭里面的 e_entry指定的入口;對於動態鏈接的可執行文件來說,如果這時候把控制權交給e_entry指定的入口地址,那么肯定是不行的,因為可執行文件所依賴的共享庫還沒有被裝載,也沒有進行動態鏈接。所以對於動態鏈接的可執行文件,內核會分析它的動態鏈接器地址(在“.interp”段),將動態鏈接器映射至進程地址空間,然后把控制權交給動態鏈接器。
Linux動態鏈接器是個很有意思的東西,它本身是一個共享對象,它的路徑是lib/ld-linux.so.2,這實際上是個軟鏈接,它指向lib/ld-x.y.z.so,這個才是真正的動態連接器文件。共享對象其實也是ELF文件,它也有跟可執行文件一樣的EF文件頭(包括 e_entry、段表等)。動態鏈接器是個非常特殊的共享對象,它不僅是個共享對象,還是個可執行的程序,可以直接在命令行下面運行:
其實 Linux的內核在執行 execve()時不關心目標ELF文件是否可執行(文件頭 e_type是 ET_EXEC還是 ET_DYN),它只是簡單按照程序頭表里面的描述對文件進行裝載然后把控制權轉交給ELF入口地址(沒有“.interp”就是ELF文件的 e_entry;如果有“.interp”的話就是動態鏈接器的 e_entry)。這樣我們就很好理解為什么動態鏈接器本身可以作為可執行程序運行,這也從一個側面證明了共享庫和可執行文件實際上沒什么區別,除了文件頭的標志位和擴展名有所不同之外,其他都是一樣的。 Windows系統中的EXE和DLL也是類似的區別,DLL也可以被當作程序來運行, Windows提供了一個叫做rund32exe的工具可以把一個DLL當作可執行文件運行。
Linux的ELF動態鏈接器是Glbc的一部分,它的源代碼位於Glibc的源代碼的elf目錄下面,它的實際入口地址位於 sysdeps/i386/d1-manchine.h中的__start(普通程序的入口地址start()在 sysdeps/i386/elf/start.S,本書的第4部分還會詳細分析)
start調用位於 elf/rtld.c的_dl_start函數。dl start函數首先對ldso(以下簡稱ld x.y.z.so為ld.so)進行重定位,因為ld.so自己就是動態鏈接器,沒有人幫它做重位工作,所以它只好自己來,美其名曰“自舉”。自舉的過程需要十分的小心謹慎,因為有很多限制.這個我們在前面已經介紹過了。完成自舉之后就可以調用其他函數並訪問全局變量了。調用_dl_start_final,收集一些基本的運行數值,進入_ dl_sysdep_start,這個函數進行一些平台相關的處理之后就進入了 _dl_main,這就是真正意義上的動態鏈接器的主函數了。 _dl_main在一開始會進行一個判斷:
很明顯,如果指定的用戶入口地址是動態鏈接器本身,那么說明動態鏈接器是被當可
執行文件在執行。在這種情況下,動態鏈接器就會解析運行時的參數,並且進行相應的處理_dl_main本身非常的長,主要的工作就是前面提到的對程序所依賴的共享對象進行裝載、符號解析和重定位,我們在這里就不再詳細展開了,因為它的實現細節又是一個非常大的話題
關於動態鏈接器本身的細節實現雖然不再展開,但是作為一個非常有特點的,也很特殊的共享對象,關於動態鏈接器的實現的幾個問題還是很值得思考的:
- 動態鏈接器本身是動態鏈接的還是靜態鏈接的?
動態鏈接器本身應該是靜態鏈接的,它不能依賴於其他共享對象,動態鏈接器本身是用來幫助其他ELF文件解決共享對象依賴問題的,如果它也依賴於其他共享對象,那么誰來幫它解決依賴問題?所以它本身必須不依賴於其他共享對象。這一點可以使用ldd來判斷:
$ ldd /lib/ld-linux so 2
statically linked
- 動態鏈接器本身必須是PC的嗎?
是不是PC對於動態鏈接器來說並不關鍵,動態鏈接器可以是PC的也可以不是,但往
往使用PIC會更加簡單一些。一方面,如果不是PC的話,會使得代碼段無法共享,浪
費內存:另一方面也會使ldso本身初始化更加復雜,因為自舉時還需要對代碼段進行
重定位。實際上的ld- linux.so.2是PIC的。
- 動態鏈接器可以被當作可執行文件運行,那么的裝載地址應該是多少?
ld.so的裝載地址跟一般的共享對象沒區別,即為0x0000這個裝載地址是一個無
效的裝載地址,作為一個共享庫,內核在裝載它時會為其選擇一個合適的裝載地址。