目錄
- 動态連結要解決什麼問題?
- 沖突:代碼段不可寫
- 解決沖突:增加一層間接性
- 示例代碼
- b.c
- a.c
- main.c
- 編譯成動态連結庫
- 動态庫的依賴關系
- 動态庫的加載過程
- 動态連結器加載動态庫
- 動态庫的加載位址分析
- 符号重定位
- 全局符号表
- 全局偏移表GOT
- liba.so動态庫檔案的布局
- liba.so動态庫的虛拟位址
- GOT表的内部結構
- 反彙編liba.so代碼
大家好,我是道哥,你技術修煉道路上的墊腳石。
在上一篇文章中,我們一起學習了Linux系統中 GCC編譯器在編譯可執行程式時,靜态連結過程中是如何進行符号重定位的。
為了完整性,我們這篇文章來一起探索一下:動态連結過程中是如何進行符号重定位的。
老樣子,文中使用大量的【代碼+圖檔】的方式,來真實的感受一下實際的記憶體模型。
文中使用了大量的圖檔,建議您在電腦上閱讀此文。
關于為什麼使用動态連結,這裡就不展開讨論了,無非就幾點:
節省實體記憶體;
可以動态更新;
動态連結要解決什麼問題?
靜态連結得到的可執行程式,被作業系統加載之後就可以執行執行。
因為在連結的時候,連結器已經把所有目标檔案中的代碼、資料等Section,都組裝到可執行檔案中了。
并且把代碼中所有使用的外部符号(變量、函數),都進行了重定位(即:把變量、函數的位址,都填寫到代碼段中需要重定位的地方),是以可執行程式在執行的時候,不依賴于其它的外部子產品即可運作。
詳細的靜态連結過程,請參考上一篇文章:【圖檔+代碼】:GCC 連結過程中的【重定位】過程分析。
也就是說:符号重定位的過程,是直接對可執行檔案進行修改。
但是對于動态連結來說,在編譯階段,僅僅是在可執行檔案或者動态庫中記錄了一些必要的資訊。
真正的重定位過程,是在這個時間點來完成的:可執行程式、動态庫被加載之後,調用可執行程式的入口函數之前。
隻有當所有需要被重定位的符号被解決了之後,才能開始執行程式。
既然也是重定位,與靜态連結過程一樣:也需要把符号的目标位址填寫到代碼段中需要重定位的地方。
沖突:代碼段不可寫
問題來了!
我們知道,在現代作業系統中,對于記憶體的通路是有權限控制的,一般來說:
代碼段:可讀、可執行;
資料段:可讀、可寫;
如果進行符号重定位,就需要對代碼進行修改(填寫符号的位址),但是代碼段又沒有可寫的權限,這是一個沖突!
解決這個沖突的方案,就是Linux系統中動态連結器的核心工作!
解決沖突:增加一層間接性
David Wheeler有一句名言:“計算機科學中的大多數問題,都可以通過增加一層間接性來解決。”
解決動态連結中的代碼重定位問題,同樣也可以通過增加一層間接性來解決。
既然代碼段在被加載到記憶體中之後不可寫,但是資料段是可寫的。
在代碼段中引用的外部符号,可以在資料段中增加一個跳闆:讓代碼段先引用資料段中的内容,然後在重定位時,把外部符号的位址填寫到資料段中對應的位置,不就解決這個沖突了嗎?!
如下圖所示:
了解了上圖的解決思路,基本上就了解了動态連結過程中重定位的核心思想。
示例代碼
我們需要3個源檔案來讨論動态連結中重定位的過程:main.c、a.c、b.c,其中的a.c和b.c被編譯成動态庫,然後main.c與這兩個動态庫一起動态連結成可執行程式。
它們之間的依賴關系是:
b.c
代碼如下:
#include <stdio.h>
int b = 30;
void func_b(void)
{
printf("in func_b. b = %d \n", b);
}
代碼說明:
定義一個全局變量和一個全局函數,被 a.c 調用。
a.c
代碼如下(稍微複雜一些,主要是為了探索:不同類型的符号如何處理重定位):
#include <stdio.h>
// 内部定義【靜态】全局變量
static int a1 = 10;
// 内部定義【非靜态】全局變量
int a2 = 20;
// 聲明外部變量
extern int b;
// 聲明外部函數
extern void func_b(void);
// 内部定義的【靜态】函數
static void func_a2(void)
{
printf("in func_a2 \n");
}
// 内部定義的【非靜态】函數
void func_a3(void)
{
printf("in func_a3 \n");
}
// 被 main 調用
void func_a1(void)
{
printf("in func_a1 \n");
// 操作内部變量
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 <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
// 聲明外部變量
extern int a2;
extern void func_a1();
typedef void (*pfunc)(void);
int main(void)
{
printf("in main \n");
// 列印此程序的全局符号表
void *handle = dlopen(0, RTLD_NOW);
if (NULL == handle)
{
printf("dlopen failed! \n");
return -1;
}
printf("\n------------ main ---------------\n");
// 列印 main 中變量符号的位址
pfunc addr_main = dlsym(handle, "main");
if (NULL != addr_main)
printf("addr_main = 0x%x \n", (unsigned int)addr_main);
else
printf("get address of main failed! \n");
printf("\n------------ liba.so ---------------\n");
// 列印 liba.so 中變量符号的位址
unsigned int *addr_a1 = dlsym(handle, "a1");
if (NULL != addr_a1)
printf("addr_a1 = 0x%x \n", *addr_a1);
else
printf("get address of a1 failed! \n");
unsigned int *addr_a2 = dlsym(handle, "a2");
if (NULL != addr_a2)
printf("addr_a2 = 0x%x \n", *addr_a2);
else
printf("get address of a2 failed! \n");
// 列印 liba.so 中函數符号的位址
pfunc addr_func_a1 = dlsym(handle, "func_a1");
if (NULL != addr_func_a1)
printf("addr_func_a1 = 0x%x \n", (unsigned int)addr_func_a1);
else
printf("get address of func_a1 failed! \n");
pfunc addr_func_a2 = dlsym(handle, "func_a2");
if (NULL != addr_func_a2)
printf("addr_func_a2 = 0x%x \n", (unsigned int)addr_func_a2);
else
printf("get address of func_a2 failed! \n");
pfunc addr_func_a3 = dlsym(handle, "func_a3");
if (NULL != addr_func_a3)
printf("addr_func_a3 = 0x%x \n", (unsigned int)addr_func_a3);
else
printf("get address of func_a3 failed! \n");
printf("\n------------ libb.so ---------------\n");
// 列印 libb.so 中變量符号的位址
unsigned int *addr_b = dlsym(handle, "b");
if (NULL != addr_b)
printf("addr_b = 0x%x \n", *addr_b);
else
printf("get address of b failed! \n");
// 列印 libb.so 中函數符号的位址
pfunc addr_func_b = dlsym(handle, "func_b");
if (NULL != addr_func_b)
printf("addr_func_b = 0x%x \n", (unsigned int)addr_func_b);
else
printf("get address of func_b failed! \n");
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中的代碼段、資料段等加載到記憶體中,示意圖如下所示:
動态連結器自身也是一個動态庫,而且是一個特殊的動态庫:它不依賴于其他的任何動态庫,因為當它被加載的時候,沒有人幫它去加載依賴的動态庫,否則就形成雞生蛋、蛋生雞的問題了。
嵌入式物聯網需要學的東西真的非常多,千萬不要學錯了路線和内容,導緻工資要不上去!
無償分享大家一個資料包,差不多150多G。裡面學習内容、面經、項目都比較新也比較全!某魚上買估計至少要好幾十。
點選這裡找小助理0元領取:二維碼詳情
動态庫的加載位址
一個程序在運作時的實際加載位址(或者說虛拟記憶體區域),可以通過指令:$ 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中代碼段的某個位置),是以就正确的跳轉到該函數中了。
end
文章連結:https://mp.weixin.qq.com/s/6MPZ1oiOgMj5eY2WXJeZ-Q
轉載自:一口Linux
文章來源:IOT物聯網小鎮 ,作者道哥
文章連結:【圖檔+代碼】:Linux 動态連結過程中的【重定位】底層原理
版權申明:本文來源于網絡,免費傳達知識,版權歸原作者所有。如涉及作品版權問題,請聯系我進行删除。