天天看點

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

1. 為什麼要動态連結

靜态連結的缺點:

(1)記憶體和磁盤空間:

比如有兩個程式,目标檔案分别為Program1.o,Program2.o,并且都用到Lib.o這個子產品。靜态連結生成可執行檔案Program1,Program2時,它們都分别存有Lib.o子產品的一個副本。當同時運作Program1和Program2時,Lib.o在磁盤和記憶體中都有兩個副本。可見會造成記憶體和磁盤空間的浪費。

(2)程式開發和釋出:

在靜态連結下,如果某個子產品發生了改變,整個程式需要重新連結,然後再重新釋出。對于使用者來說,每次更新都需要重新下載下傳整個程式。

動态連結的基本思想就是将連結這個過程推遲到運作時再進行。

以Program1和Program2為例,假設現在有Program1.o,Program2.o和Lib.o,當運作Program1時,系統首先會加載Program.o,然後發現它依賴于Lib.o,于是加載Lib.o,按照同樣的方法将需要的所有目标檔案都加載至記憶體,接着進行連結工作,和靜态連結類似,包括符号解析,位址重定位等,最後系統把控制權交給Program1.o的程式入口處,程式開始運作。如果現在需要運作Program2,系統則加載Program2.o,發現Program2.o依賴的Lib.o已經在記憶體中了,是以系統接着直接執行連結工作。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

可以看到動态連結情況下,當某個子產品發生改變時,無需重新連結一遍,隻需要簡單地将目标檔案覆寫掉,程式下次運作時,新版本的目标檔案會自動被加載并連結,程式自動完成更新。動态連結使各個子產品耦合度更小。

動态連結還有一個特點就是程式在運作時可以動态地選擇加載各種程式子產品,這就是插件(Plug-in)的原理。如某個公司開發完成了某個産品,并且給出了指定好的程式接口,第三方開發者可以按照這種接口來編寫符合要求的動态連結檔案。該産品程式可以動态地載入各種由第三方開發的子產品,實作程式功能的擴充。

動态連結還可以加強程式的相容性。一個程式在不同的平台下運作時可以動态地連結到由作業系統提供的動态連結庫,這些動态連結庫相當于在程式和作業系統之間增加一個中間層。對于靜态連結,程式需要分别連結成能夠在系統A和系統B下運作的兩個版本就分開釋出,對于動态連結,隻要系統提供了動态連結所需要的接口,則程式即可在該系統下運作,理論上隻需要一個版本。

動态連結檔案和目标檔案的結構會有所不同。Linux下ELF動态連結檔案被稱為動态共享對象(DSO,Dynamic Shared Objects),擴充名為“.so”,Windows下,動态連結檔案被稱為動态連結庫(Dynamical Linking Library),擴充名為“.dll”。

2. 簡單的動态連結例子

Windows下的PE動态連結機制和Linux下的ELF稍有不同,這裡先以ELF作為例子。例子源碼如下:

Program1.c

#include"Lib.h"

int main() {
	foobar(1);
	return 0;
}
           

Program2.c

#include"Lib.h"

int main() {
	foobar(2);
	return 0;
}
           

Lib.c

#include<stdio.h>

void foobar(int i) {
	printf("Printing from Lib.so %d\n",i);
	sleep(-1);
}
           

Lib.h

#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif
           

将Lib.c編譯成一個共享對象檔案:

gcc -fPIC -shared -o Lib.so Lib.c
           

-shared表示産生共享對象。然後分别編譯連結Program1.c和Program2.c:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so
           

整個編譯和連結過程如下:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

在指令行中可以看到Lib.so也參與了連結過程,但實際上連結的輸入目标檔案隻有Program1.o(當然還有C語言運作庫,這裡暫時忽略)。在連結過程中,對于一個定義于其他靜态目标子產品的符号,連結器會按照靜态連結的規則将符号位址重定位,而如果該符号定義于動态共享對象,則連結器會将這個符号的引用标記為一個動态連結的符号,把重定位過程留到裝載時再進行。那麼如何知道一個符号的引用屬于靜态符号還是動态符号呢,這就是前面指令中需要加上Lib.so的原因。Lib.so中儲存了完整的符号資訊(因為動态連結時需要用到這些資訊),連結器可以通過這些資訊确定那些符号屬于動态符号。是以在這裡,Lib.so隻是起提供符号資訊的作用,并沒有連結進最終可執行檔案。

與靜态連結不同,動态連結下,除了可執行檔案本身之外,所依賴的共享對象檔案也需要映射到程序的虛拟位址空間。

檢視程序的虛拟位址空間分布:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

可以看到Lib.so也被映射到程序的虛拟位址空間,還有動态連結形式的C語言運作庫libc-2.23.so,可以看到還有一個是ld-2.23.so,實際上這是Linux下的動态連結器。首先系統會把控制權交給動态連結器,由它完成所有的動态連結工作之後再把控制權交給Program1,然後開始執行。

還有一點是,共享對象的最終裝載位址在編譯時是不确定的,而是在裝載時,裝載器根據目前位址空間的空閑情況,動态配置設定一塊足夠大小的虛拟位址空間給相應的共享對象。

3. 位址無關代碼

3.1 裝載時重定位

這個想法的基本思路是,在連結時,對所有絕對位址的引用不作重定位,而把這一步推遲到裝載時再完成。一旦子產品裝載位址确定,那麼系統就對程式中所有的絕對位址引用進行重定位。

這種方法存在缺點,在動态連結子產品被裝載映射至虛拟空間後,指令部分理論上是可以在多個程序共享的。但由于裝載時重定位的方法需要修改指令,是以沒有辦法做到同一份指令被多個程序共享,因為指令被重定位後對于每個程序來講是不同的。當然對于可修改資料部分,這部分在每個程序中都有一個副本,是以這部分可以使用裝載時重定位的方法來解決。前面的編譯指令使用了-shared和-fPIC參數,如果隻使用-shared,那麼輸出的共享對象就是使用裝載時重定位的方法。

3.2 位址無關代碼

對于裝載時重定位的缺點,是以目的是希望程式子產品中共享的指令部分在裝載時不需要因為裝載位址的改變而改變。位址無關代碼(PIC,Position-independent Code)技術的基本想法是把指令中那些需要被修改的部分分離出來,跟資料部分放在一起,這樣指令部分可以保持不變,而資料部分可以在每個程序中擁有一個副本。

把共享對象子產品中的位址引用按照是否跨子產品分成子產品内部引用和子產品外部引用,按不同的引用方式分為指令引用和資料引用,是以有4種情況:

(1)子產品内部的函數調用,跳轉。

(2)子產品内部的資料通路。

(3)子產品外部的函數調用,跳轉。

(4)子產品外部的資料通路。

pic.c

static int a;
extern int b;
extern void ext();

void bar() {
	a=1; 	//type 2
	b=2; 	//type 4
}

void foo() {
	bar(); 	//type 1
	ext(); 	//type 3
}
           

實際上編譯器并不能确定b和ext是子產品外部的還是子產品内部的,因為它們有可能是被定義在同一個共享對象的其他目标檔案中。是以統一當作子產品外部來處理。

(1)子產品内部調用跳轉

被調用函數和調用者處于同一個子產品,它們之間的相對位置是固定的。子產品内部的跳轉,函數調用都可以是相對位址調用,或者是基于寄存器的相對調用。相對位址調用指令是指指令中的資料部分代表被調函數相對于調用指令下一條指令的偏移,因為這個偏移是固定不變的,是以對于這種指令是不需要重定位的。無論子產品被裝載到哪個位置,這條指令都是有效的。

(2)子產品内部資料通路

在一個子產品内,頁之間的相對位置是固定的。即任何一條指令與它需要通路的子產品内部資料之間的相對位置是固定的,那麼隻需要相對于目前指令的下一條指令的位址加上固定的偏移量就可以通路子產品内部資料了。rip寄存器存放的正是下一條指令的位址:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

可以看到%rip加上偏移0x200916就是變量a的位址,即0x71e+0x200916=0x201034。即如果子產品被裝載到0x10000000這個位址的話,則a的位址為0x10000000+0x71e+0x200916=0x10201034。

(3)子產品間資料通路

基本思想是把位址相關的部分放到資料段裡面。ELF的做法是在資料段裡面建立一個指向這些變量的指針數組,稱為全局偏移表(Global Offset Table ,GOT),當代碼需要引用該全局變量時,可以通過GOT中相對應的項間接引用。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

連結器在裝載子產品的時候會查找每個變量所在的位址,然後填充GOT中的各個項,以確定每個指針指向的位址正确。GOT是存放在資料段的,每個程序都可以有獨立的副本,互相不受影響。從第二種類型中可以看到資料段裡的子產品内部變量相對于目前指令的偏移是固定的,GOT也是存放在資料段的,是以GOT相對于目前指令的偏移也是固定的。通過這個偏移可以找到GOT,再根據變量位址在GOT中的偏移,可以得到變量的位址。當然變量位址在GOT中的偏移是由編譯器确定的。

看到上面的反彙編碼,先把變量b的位址的偏移指派給eax,即0x2008b3+0x725=0x200fd8。再通過寄存器間接尋址給變量b指派。使用objdump -R pic.so檢視pic.so在動态連結時需要重定位的項,發現b的位址的偏移确實是0x200fd8。因為還沒有進行動态連接配接,是以可以看到0x200fd8處的内容都是0。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

使用objdump -h pic.so檢視GOT的位置:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

可以知道b的位址在GOT中的偏移是8。如果指針用8位元組表示,則表示b在第二項,如果指針用4位元組表示,則表示b在第三項。

(4)子產品間調用,跳轉

這種情況也可以用GOT,GOT中相應的項儲存的是目标函數的位址。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

調用ext的彙編代碼:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

調用位址是0x5f0,是[email protected]的位址。[email protected]内容如下,其中plt是什麼後面會介紹。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

第一條指令是跳轉指令,跳轉位址是0x5f6+0x200a2a=0x201020,這就是ext函數的位址的真正偏移,檢視重定位項發現确實如此。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

GCC使用-fpic和-fPIC都可以産生位址無關代碼,其中-fpic産生的代碼相對較小,而且較快,但在某些平台上會有限制,而-fPIC則沒有。

以下指令可以判斷某個DSO是否為PIC,沒有輸出就代表是PIC。

readelf -d pic.so | grep TEXTREL
           

3.3 共享子產品的全局變量問題

對于定義在子產品内部的全局變量,實際上并不能簡單地按第一種類型來解決。比如一個子產品module.c如下:

extern int global;
int foo() {
    global=1;
}
           

global是定義在其他共享對象的全局變量。當編譯module.c時,無法判斷global是否屬于子產品間調用。

假設module.c是可執行檔案的一部分,即程式的主子產品,如果編譯該檔案時沒有使用類似PIC的機制,那麼該程式的主子產品代碼并不是位址無關代碼。它引用這個全局變量就和普通資料通路方式一樣,會産生類似下面的代碼:

movl $0x1, xxxxxxxx
           

xxxxxxxx是global的位址,是以變量的位址必須在連結過程中(靜态連結)确定下來。為了使連結過程順利進行,連結器會在可執行檔案的.bss建立一個global的副本。xxxxxxxx就是該副本的位址。但實際上global是定義在共享對象中,這樣程式運作時會發現global有多個副本。是以解決方法是ELF共享庫在編譯時,預設把定義在自身子產品内部的全局變量當作定義在其他子產品的全局變量處理,也就是第三種類型,通過GOT實作資料通路。當共享子產品被裝載時,發現某個全局變量在可執行檔案中存在一個副本,那麼動态連結器會把GOT中的相應位址指向該副本,那麼程式運作時該變量隻有一個執行個體。如果變量在共享子產品被初始化,動态連結器還會将初始值拷貝到主子產品的副本中。如果主子產品不存在副本,那麼共享對象的GOT中相應位址自然就指向自身子產品内部的該變量。

如果module.c是一個共享對象的一部分,那麼在參數-fPIC的情況下,會把global的調用按照子產品間資料通路的方式産生代碼。因為即使global屬于子產品内引用,但它也有可能被主子產品可執行檔案引用,進而使共享對象中對global的引用要執行可執行檔案中的global副本。

3.4 資料段位址無關性

資料段也會存在有絕對位址引用的問題:

static int a;
static int *p=&a;
           

a的位址随着裝載位址的改變而改變,是以這段代碼并不是位址無關的。但因為資料段每個程序都有一個副本,是以可以簡單地使用裝載時重定位的方式來解決資料段中絕對位址引用問題。

4. 延遲綁定(PLT)

動态連結比靜态連結靈活,但性能會稍微差一些,主要有兩個原因:

(1)對于子產品間的調用或資料通路,都需要進行複雜的GOT定位,即先定位GOT,再進行間接跳轉。

(2)程式開始執行時,動态連接配接器需要進行一次連結工作。

如果一個程式有很多個子產品,包含很多函數,但其中很大一部分在程式執行完畢都沒有被調用,是以為這些函數進行重定位其實是沒必要的。是以ELF采用了一種叫做延遲綁定(Lazy Binding)的做法,基本思想就是當函數第一次被用到時進行綁定。

ELF使用PLT(Procedure Linkage Table)的方法來實作。假設liba.so需要調用libc.so中的bar()函數,那麼當第一次調用時,需要調用動态連接配接器中的某個函數來完成位址綁定工作,假設這個函數為lookup()。lookup()至少要知道位址綁定發生在哪個子產品,是哪個函數。假設原型為lookup(module,function)。這個例子就分别是liba.so和bar。其實這裡lookup()函數真正的名字是_dl_runtime_resolve()。

當調用某個外部函數時,按照通常的做法是通過GOT中相應的項進行間接跳轉,PLT為了實作延遲綁定,在這個過程中增加了一層間接跳轉,調用函數是通過一個叫作PLT項的結構進行跳轉。如ext()函數在PLT中的項的位址為[email protected]。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

第一條指令是通過GOT間接跳轉的指令。跳轉位址是0x201020,這是ext函數在GOT中相應的項的位址,這個項儲存的理應是ext函數的真正位址,但因為實作了延遲綁定,是以這個項還沒有填入真正的位址,而是下一條指令的位址,可以看到0x201020的内容如下:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

内容為0x5f6,正是下一條指令的位址。是以當第一次進入[email protected]時候,第一條指令相當于什麼都沒做。下一條指令是把ext這個符号引用在重定位表.rel.plt中的索引入棧,可以通過readelf -r pic.so檢視:

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

接着跳轉到0x5d0處

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

第一條指令就是把目前子產品ID入棧,第二條就是調用_dl_runtime_resolve()函數來完成符号解析和重定位工作。0x201008和0x201010處的值是由運作時動态連結器初始化的,是以現在看到都是0。一旦解析完畢,下次調用時,[email protected]的第一條指令就直接跳轉到了ext函數的入口。并且ext在傳回時,根據堆棧裡儲存的EIP直接傳回到調用者,而不會執行[email protected]之後的指令。

以上是PLT的基本原理,實際實作回複雜些。ELF将GOT拆分成了兩個表,分别是.got和.got.plt。.got用來儲存全局變量引用位址,.got.plt用來儲存函數引用位址。其中.got.plt前三項有特殊含義:

(1)第一項儲存.dynamic段的位址。(2)第二項儲存的是本子產品的ID。(3)第三項儲存的是_dl_runtime_resolve()的位址。

之後的便是外部函數引用的位址。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

這裡每一項占8個位元組,可以看到第二項和第三項都是0,因為還沒初始化。可以看到第四項和第五項的位址初始化都是對應函數的plt中的第二條指令的位址。

關于PLT結構數組,每個外部函數引用都對應PLT結構數組中的一項。因為每個函數的plt中都有兩個同樣的操作:(1)将目前子產品ID入棧。(2)跳轉到_dl_runtime_resolve()。是以為了減少代碼重複,把這兩條指令統一放到了PLT結構數組的第一項,即PLT0。

動态連結(一)1. 為什麼要動态連結2. 簡單的動态連結例子3. 位址無關代碼4. 延遲綁定(PLT)

現在回頭看,位址0x5d0就是PLT0,接着是PLT1和PLT2。PLT結構長度是16位元組,保證能剛好存放三條指令。PLT在ELF檔案中以獨立的段存放,段名為.plt,因為本身是位址無關的代碼,是以可以和代碼段合并成一個Segment被裝載。

繼續閱讀