目錄
動態鏈接要解決什么問題?
矛盾:代碼段不可寫
解決矛盾:增加一層間接性
示例代碼
b.c
a.c
main.c
編譯成動態鏈接庫
動態庫的依賴關系
動態庫的加載過程
動態鏈接器加載動態庫
動態庫的加載地址分析
符號重定位
全局符號表
全局偏移表GOT
liba.so動態庫文件的布局
liba.so動態庫的虛擬地址
GOT表的內部結構
反匯編liba.so代碼
關于為什么使用動態鏈接,這里就不展開討論了,無非就幾點:
節省物理內存;
可以動態更新;
動態鏈接要解決什么問題?
靜態鏈接得到的可執行程序,被操作系統加載之后就可以執行執行。
因為在鏈接的時候,鏈接器已經把所有目標文件中的代碼、數據等Section,都組裝到可執行文件中了。
并且把代碼中所有使用的外部符號(變量、函數),都進行了重定位(即:把變量、函數的地址,都填寫到代碼段中需要重定位的地方),因此可執行程序在執行的時候,不依賴于其它的外部模塊即可運行。
詳細的靜態鏈接過程,請參考上一篇文章:【圖片+代碼】:GCC 鏈接過程中的【重定位】過程分析。
也就是說:符號重定位的過程,是直接對可執行文件進行修改。
但是對于動態鏈接來說,在編譯階段,僅僅是在可執行文件或者動態庫中記錄了一些必要的信息。
真正的重定位過程,是在這個時間點來完成的:可執行程序、動態庫被加載之后,調用可執行程序的入口函數之前。
只有當所有需要被重定位的符號被解決了之后,才能開始執行程序。
既然也是重定位,與靜態鏈接過程一樣:也需要把符號的目標地址填寫到代碼段中需要重定位的地方。
矛盾:代碼段不可寫
問題來了!
我們知道,在現代操作系統中,對于內存的訪問是有權限控制的,一般來說:
代碼段:可讀、可執行;
數據段:可讀、可寫;
如果進行符號重定位,就需要對代碼進行修改(填寫符號的地址),但是代碼段又沒有可寫的權限,這是一個矛盾!
解決這個矛盾的方案,就是Linux系統中動態鏈接器的核心工作!
解決矛盾:增加一層間接性
David Wheeler有一句名言:“計算機科學中的大多數問題,都可以通過增加一層間接性來解決。”
解決動態鏈接中的代碼重定位問題,同樣也可以通過增加一層間接性來解決。
既然代碼段在被加載到內存中之后不可寫,但是數據段是可寫的。
在代碼段中引用的外部符號,可以在數據段中增加一個跳板:讓代碼段先引用數據段中的內容,然后在重定位時,把外部符號的地址填寫到數據段中對應的位置,不就解決這個矛盾了嗎?!
如下圖所示:
理解了上圖的解決思路,基本上就理解了動態鏈接過程中重定位的核心思想。
示例代碼
我們需要3個源文件來討論動態鏈接中重定位的過程:main.c、a.c、b.c,其中的a.c和b.c被編譯成動態庫,然后main.c與這兩個動態庫一起動態鏈接成可執行程序。
它們之間的依賴關系是:
b.c
代碼如下:
?
#includeint b = 30; void func_b(void) { printf("in func_b. b = %d ", b); }
?
代碼說明:
定義一個全局變量和一個全局函數,被 a.c 調用。
a.c
代碼如下(稍微復雜一些,主要是為了探索:不同類型的符號如何處理重定位):
?
#include// 內部定義【靜態】全局變量 static int a1 = 10; // 內部定義【非靜態】全局變量 int a2 = 20; // 聲明外部變量 extern int b; // 聲明外部函數 extern void func_b(void); // 內部定義的【靜態】函數 static void func_a2(void) { printf("in func_a2 "); } // 內部定義的【非靜態】函數 void func_a3(void) { printf("in func_a3 "); } // 被 main 調用 void func_a1(void) { printf("in func_a1 "); // 操作內部變量 a1 = 11; a2 = 21; // 操作外部變量 b = 31; // 調用內部函數 func_a2(); func_a3(); // 調用外部函數 func_b(); }
?
代碼說明:
定義了 2 個全局變量:一個靜態,一個非靜態;
定義了 3 個函數:
func_a2是靜態函數,只能在本文件中調用;
func_a1和func_a3是全局函數,可以被外部調用;
在 main.c 中會調用func_a1。
main.c
代碼如下:
?
#include#include #include // 聲明外部變量 extern int a2; extern void func_a1(); typedef void (*pfunc)(void); int main(void) { printf("in main "); // 打印此進程的全局符號表 void *handle = dlopen(0, RTLD_NOW); if (NULL == handle) { printf("dlopen failed! "); return -1; } printf(" ------------ main --------------- "); // 打印 main 中變量符號的地址 pfunc addr_main = dlsym(handle, "main"); if (NULL != addr_main) printf("addr_main = 0x%x ", (unsigned int)addr_main); else printf("get address of main failed! "); printf(" ------------ liba.so --------------- "); // 打印 liba.so 中變量符號的地址 unsigned int *addr_a1 = dlsym(handle, "a1"); if (NULL != addr_a1) printf("addr_a1 = 0x%x ", *addr_a1); else printf("get address of a1 failed! "); unsigned int *addr_a2 = dlsym(handle, "a2"); if (NULL != addr_a2) printf("addr_a2 = 0x%x ", *addr_a2); else printf("get address of a2 failed! "); // 打印 liba.so 中函數符號的地址 pfunc addr_func_a1 = dlsym(handle, "func_a1"); if (NULL != addr_func_a1) printf("addr_func_a1 = 0x%x ", (unsigned int)addr_func_a1); else printf("get address of func_a1 failed! "); pfunc addr_func_a2 = dlsym(handle, "func_a2"); if (NULL != addr_func_a2) printf("addr_func_a2 = 0x%x ", (unsigned int)addr_func_a2); else printf("get address of func_a2 failed! "); pfunc addr_func_a3 = dlsym(handle, "func_a3"); if (NULL != addr_func_a3) printf("addr_func_a3 = 0x%x ", (unsigned int)addr_func_a3); else printf("get address of func_a3 failed! "); printf(" ------------ libb.so --------------- "); // 打印 libb.so 中變量符號的地址 unsigned int *addr_b = dlsym(handle, "b"); if (NULL != addr_b) printf("addr_b = 0x%x ", *addr_b); else printf("get address of b failed! "); // 打印 libb.so 中函數符號的地址 pfunc addr_func_b = dlsym(handle, "func_b"); if (NULL != addr_func_b) printf("addr_func_b = 0x%x ", (unsigned int)addr_func_b); else printf("get address of func_b failed! "); dlclose(handle); // 操作外部變量 a2 = 100; // 調用外部函數 func_a1(); // 為了讓進程不退出,方便查看虛擬空間中的地址信息 while(1) sleep(5); return 0; }
?
糾正:代碼中本來是想打印變量的地址的,但是不小心加上了 *,變成了打印變量值。最后檢查的時候才發現,所以就懶得再去修改了。
代碼說明:
利用 dlopen 函數(第一個參數傳入 NULL),來打印此進程中的一些符號信息(變量和函數);
賦值給 liba.so 中的變量 a2,然后調用 liba.so 中的 func_a1 函數;
編譯成動態鏈接庫
把以上幾個源文件編譯成動態庫以及可執行程序:
?
$ gcc -m32 -fPIC --shared b.c -o libb.so $ gcc -m32 -fPIC --shared a.c -o liba.so -lb -L./ $ gcc -m32 -fPIC main.c -o main -ldl -la -lb -L./
?
有幾點內容說明一下:
-fPIC 參數意思是:生成位置無關代碼(Position Independent Code),這也是動態鏈接中的關鍵;
既然動態庫是在運行時加載,那為什么在編譯的時候還需要指明?
因為在編譯的時候,需要知道每一個動態庫中提供了哪些符號。Windows 中的動態庫的顯性的導出和導入標識,更能體現這個概念(__declspec(dllexport), __declspec(dllimport))。
此時,就得到了如下幾個文件:
動態庫的依賴關系
對于靜態鏈接的可執行程序來說,被操作系統加載之后,可以認為直接從可執行程序的入口函數開始(也就是ELF文件頭中指定的e_entry這個地址),執行其中的指令碼。
但是對于動態鏈接的程序來說,在執行入口函數的指令之前,必須把該程序所依賴的動態庫加載到內存中,然后才能開始執行。
對于我們的實例代碼來說:main程序依賴于liba.so庫,而liba.so庫又依賴于libb.so庫。
可以用ldd工具來分別看一下動態庫之間的依賴關系:
可以看出:
在 liba.so 動態庫中,記錄了信息:依賴于 libb.so;
在 main 可執行文件中,記錄了信息:依賴于 liba.so, libb.so;
也可以使用另一個工具patchelf來查看一個可執行程序或者動態庫,依賴于其他哪些模塊。例如:
那么,動態庫的加載是由誰來完成的呢?動態鏈接器!
動態庫的加載過程
動態鏈接器加載動態庫
當執行main程序的時候,操作系統首先把main加載到內存,然后通過.interp段信息來查看該文件依賴哪些動態庫:
上圖中的字符串/lib/ld-linux.so.2,就表示main依賴動態鏈接庫。
ld-linux.so.2也是一個動態鏈接庫,在大部分情況下動態鏈接庫已經被加載到內存中了(動態鏈接庫就是為了共享),操作系統此時只需要把動態鏈接庫所在的物理內存,映射到 main進程的虛擬地址空間中就可以了,然后再把控制權交給動態鏈接器。
動態鏈接器發現:main依賴liba.so,于是它就在虛擬地址空間中找一塊能放得下liba.so的空閑空間,然后把liba.so中需要加載到內存中的代碼段、數據段都加載進來。
當然,在加載liba.so時,又會發現它依賴libb.so,于是又把在虛擬地址空間中找一塊能放得下libb.so的空閑空間,把libb.so中的代碼段、數據段等加載到內存中,示意圖如下所示:
動態鏈接器自身也是一個動態庫,而且是一個特殊的動態庫:它不依賴于其他的任何動態庫,因為當它被加載的時候,沒有人幫它去加載依賴的動態庫,否則就形成雞生蛋、蛋生雞的問題了。
動態庫的加載地址
一個進程在運行時的實際加載地址(或者說虛擬內存區域),可以通過指令:$ cat /proc/[進程的 pid]/maps 讀取出來。
例如:我的虛擬機中執行main程序時,看到的地址信息是:
黃色部分分別是:main, liba.so, libb.so 這3個模塊的加載信息。
另外,還可以看到c庫(libc-2.23.so)、動態鏈接器(ld-2.23.so)以及動態加載庫libdl-2.23.so的虛擬地址區域,布局如下:
可以看出出來:main可執行程序是位于低地址,所有的動態庫都位于4G內存空間的最后1G空間中。
還有另外一個指令也很好用 $ pmap [進程的 pid],也可以打印出每個模塊的內存地址:
符號重定位
全局符號表
在之前的靜態鏈接中學習過,鏈接器在掃描每一個目標文件(.o文件)的時候,會把每個目標文件中的符號提取出來,構成一個全局符號表。
然后在第二遍掃描的時候,查看每個目標文件中需要重定位的符號,然后在全局符號表中查找該符號被安排在什么地址,然后把這個地址填寫到引用的地方,這就是靜態鏈接時的重定位。
但是動態鏈接過程中的重定位,與靜態鏈接的處理方式差別就大很多了,因為每個符號的地址只有在運行的時候才能知道它們的地址。
例如:liba.so引用了libb.so中的變量和函數,而libb.so中的這兩個符號被加載到什么位置,直到main程序準備執行的時候,才能被鏈接器加載到內存中的某個隨機的位置。
也就是說:動態鏈接器知道每個動態庫中的代碼段、數據段被加載的內存地址,因此動態鏈接器也會維護一個全局符號表,其中存放著每一個動態庫中導出的符號以及它們的內存地址信息。
在示例代碼main.c函數中,我們通過dlopen返回的句柄來打印進程中的一些全局符號的地址信息,輸出內容如下:
上文已經糾錯過:本來是想打印變量的地址信息,但是 printf 語句中不小心加上了型號,變成了打印變量值。
可以看到:在全局符號表中,沒有找到liba.so中的變量a1和函數func_a2這兩個符號,因為它倆都是static類型的,在編譯成動態庫的時候,沒有導出到符號表中。
既然提到了符號表,就來看看這 3 個ELF文件中的動態符號表信息:
動態鏈接庫中保護兩個符號表:.dynsym(動態符號表: 表示模塊中符號的導出、導入關系) 和 .symtab(符號表: 表示模塊中的所有符號);
.symtab 中包含了 .dynsym;
由于圖片太大,這里只貼出 .dynsym 動態符號表。
綠色矩形框前面的Ndx列是數字,表示該符號位于當前文件的哪一個段中(即:段索引);
紅色矩形框前面的Ndx列是UND,表示這個符號沒有找到,是一個外部符號(需要重定位);
全局偏移表GOT
在我們的示例代碼中,liba.so是比較特殊的,它既被main可執行程序所依賴,又依賴于libb.so。
而且,在liba.so中,定義了靜態、動態的全局變量和函數,可以很好的概況很多種情況,因此這部分內容就主要來分析liba.so這個動態庫。
前文說過:代碼重定位需要修改代碼段中的符號引用,而代碼段被加載到內存中又沒有可寫的權限,動態鏈接解決這個矛盾的方案是:增加一層間接性。
例如:liba.so的代碼中引用了libb.so中的變量b,在liba.so的代碼段,并不是在引用的地方直接指向libb.so數據段中變量b的地址,而是指向了liba.so自己的數據段中的某個位置,在重定位階段,鏈接器再把libb.so中變量b的地址填寫到這個位置。
因為liba.so自己的代碼段和數據段位置是相對固定的,這樣的話,liba.so的代碼段被加載到內存之后,就再也不用修改了。
而數據段中這個間接跳轉的位置,就稱作:全局偏移表(GOT: Global Offset Table)。
劃重點:
liba.so的代碼段中引用了libb.so中的符號b,既然b的地址需要在重定位時才能確定,那么就在數據段中開辟一塊空間(稱作:GOT表),重定位時把b的地址填寫到GOT表中。
而liba.so的代碼段中,把GOT表的地址填寫到引用b的地方,因為GOT表在編譯階段是可以確定的,使用的是相對地址。
這樣,就可以在不修改liba.so代碼段的前提下,動態的對符號b進行了重定位!
其實,在一個動態庫中存在 2 個GOT表,分別用于重定位變量符號(section名稱:.got)和函數符號( section 名稱:.got.plt)。
也就是說:所有變量類型的符號重定位信息都位于.got中,所有函數類型的符號重定位信息都位于.got.plt中。
并且,在一個動態庫文件中,有兩個特殊的段(.rel.dyn和.rel.plt)來告訴鏈接器:.got和.got.plt這兩個表中,有哪些符號需要進行重定位,這個問題下面會深入討論。
liba.so動態庫文件的布局
為了更深刻的理解.got和.got.plt這兩個表,有必要來拆解一下liba.so動態庫文件的內部結構。
通過readelf -S liba.so指令來看一下這個ELF文件中都有哪些section:
可以看到:一共有28個section,其中的21、22就是兩個GOT表。
另外,從裝載的角度來看,裝載器并不是把這些sections分開來處理,而是根據不同的讀寫屬性,把多個section看做一個segment。
再次通過指令 readelf -l liba.so ,來查看一下segment信息:
也就是說:
這28個section中(關注綠色線條):
section 0 ~ 16 都是可讀、可執行權限,被當做一個 segment;
section 17 ~ 24 都是可讀、可寫的權限,被動作另一個 segment;
再來重點看一下.got和.got.plt這兩個section(關注黃色矩形框):
可見:.got和.got.plt與數據段一樣,都是可讀、可寫的,所以被當做同一個 segment被加載到內存中。
通過以上這2張圖(紅色矩形框),可以得到liba.so動態庫文件的內部結構如下:
liba.so動態庫的虛擬地址
來繼續觀察liba.so文件segment信息中的AirtAddr列,它表示的是被加載到虛擬內存中的地址,重新貼圖如下:
因為編譯動態庫時,使用了代碼位置無關參數(-fPIC),這里的虛擬地址從0x0000_0000開始。
當liba.so的代碼段、數據段被加載到內存中時,動態鏈接器找到一塊空閑空間,這個空間的開始地址,就相當于一個基地址。
liba.so中的代碼段和數據段中所有的虛擬地址信息,只要加上這個基地址,就得到了實際虛擬地址。
我們還是把上圖中的輸出信息,畫出詳細的內存模型圖,如下所示:
GOT表的內部結構
現在,我們已經知道了liba.so庫的文件布局,也知道了它的虛擬地址,此時就可以來進一步的看一下.got和.got.plt這兩個表的內部結構了。
從剛才的圖片中看出:
.got 表的長度是 0x1c,說明有 7 個表項(每個表項占 4 個字節);
.got.plt 表的長度是 0x18,說明有 6 個表項;
上文已經說過,這兩個表是用來重定位所有的變量和函數等符號的。
那么:liba.so通過什么方式來告訴動態鏈接器:需要對.got和.got.plt這兩個表中的表項進行地址重定位呢?
在靜態鏈接的時候,目標文件是通過兩個重定位表.rel.text和.rel.data這兩個段信息來告訴鏈接器的。
對于動態鏈接來說,也是通過兩個重定位表來傳遞需要重定位的符號信息的,只不過名字有些不同:.rel.dyn和.rel.plt。
通過指令 readelf -r liba.so來查看重定位信息:
從黃色和綠色的矩形框中可以看出:
liba.so 引用了外部符號 b,類型是 R_386_GLOB_DAT,這個符號的重定位描述信息在 .rel.dyn 段中;
liba.so 引用了外部符號 func_b, 類型是 R_386_JUMP_SLOT,這個符號的重定位描述信息在 .rel.plt 段中;
從左側紅色的矩形框可以看出:每一個需要重定位的表項所對應的虛擬地址,畫成內存模型圖就是下面這樣:
暫時只專注表項中的紅色部分:.got表中的b, .got.plt表中的func_b,這兩個符號都是libb.so中導出的。
也就是說:
liba.so的代碼中在操作變量b的時候,就到.got表中的0x0000_1fe8這個地址處來獲取變量b的真正地址;
liba.so的代碼中在調用func_b函數的時候,就到.got.plt表中的0x0000_200c這個地址處來獲取函數的真正地址;
反匯編liba.so代碼
下面就來反匯編一下liba.so,看一下指令碼中是如何對這兩個表項進行尋址的。
執行反匯編指令:$ objdump -d liba.so,這里只貼出func_a1函數的反匯編代碼:
第一個綠色矩形框(call 490 <__x86.get_pc_thunk.bx>)的功能是:把下一條指令(add)的地址存儲到%ebx中,也就是:
?
%ebx = 0x622
?
然后執行: add $0x19de,%ebx,讓%ebx加上0x19de,結果就是:%ebx = 0x2000。
0x2000正是.got.plt表的開始地址!
看一下第2個綠色矩形框:
mov -0x18(%ebx),%eax: 先用%ebx減去0x18的結果,存儲到%eax中,結果是:%eax = 0x1fe8,這個地址正是變量b在.got表中的虛擬地址。
movl $0x1f,(%eax):在把0x1f(十進制就是31),存儲到0x1fe8表項中存儲的地址所對應的內存單元中(libb.so的數據段中的某個位置)。
因此,當鏈接器進行重定位之后,0x1fe8表項中存儲的就是變量b的真正地址,而上面這兩步操作,就把數值31賦值給變量b了。
第3個綠色矩形框,是調用函數func_b,稍微復雜一些,跳轉到符號 func_b@plt的地方,看一下反匯編代碼:
jmp指令調用了%ebx + 0xc處的那個函數指針,從上面的.got.plt布局圖中可以看出,重定位之后這個表項中存儲的正是func_b函數的地址(libb.so中代碼段的某個位置),所以就正確的跳轉到該函數中了。
評論
查看更多