動態連結的步驟與實現

yooooooo發表於2019-03-17

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 a1() {
    printf("a1.c\n");
}

/*a2.c*/
#include <stdio.h>
void a2() {
    printf("a2.c\n");
}

/*b1.c*/
#include <stdio.h>
void b1() {
    printf("b1.c\n");
}

/*b2.c*/
#include <stdio.h>
void b2() {
    printf("b2.c\n");
}

可以看到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本身非常的長,主要的工作就是前面提到的對程式所依賴的共享物件進行裝載、符號解析和重定位,我們在這裡就不再詳細展開了,因為它的實現細節又是一個非常大的話題

關於動態連結器本身的細節實現雖然不再展開,但是作為一個非常有特點的,也很特殊的共享物件,關於動態連結器的實現的幾個問題還是很值得思考的:

  1. 動態連結器本身是動態連結的還是靜態連結的?

動態連結器本身應該是靜態連結的,它不能依賴於其他共享物件,動態連結器本身是用來幫助其他ELF檔案解決共享物件依賴問題的,如果它也依賴於其他共享物件,那麼誰來幫它解決依賴問題?所以它本身必須不依賴於其他共享物件。這一點可以使用ldd來判斷:
$ ldd /lib/ld-linux so 2
statically linked

  1. 動態連結器本身必須是PC的嗎?

是不是PC對於動態連結器來說並不關鍵,動態連結器可以是PC的也可以不是,但往
往使用PIC會更加簡單一些。一方面,如果不是PC的話,會使得程式碼段無法共享,浪
費記憶體:另一方面也會使ldso本身初始化更加複雜,因為自舉時還需要對程式碼段進行
重定位。實際上的ld- linux.so.2是PIC的。

  1. 動態連結器可以被當作可執行檔案執行,那麼的裝載地址應該是多少?

ld.so的裝載地址跟一般的共享物件沒區別,即為0x0000這個裝載地址是一個無
效的裝載地址,作為一個共享庫,核心在裝載它時會為其選擇一個合適的裝載地址。

相關文章