Linux動態鏈接為什麼要用PLT和GOT表?
為什麼不能用普通的重定位(即將要引用的地方修改為目標地址)?另外使用共享庫的可執行文件能用普通的重定位嗎?
在介紹PLT/GOT之前,先以一個簡單的例子引入,各位請看以下代碼:
#include &
void print_banner()
{
printf("Welcome to World of PLT and GOT
");
}
int main(void)
{
print_banner();
return 0;
}
編譯:
gcc -Wall -g -o test.o -c test.c -m32
鏈接:
gcc -o test test.o -m32
注意:現代Linux系統都是x86_64系統了,後面需要對中間文件test.o以及可執行文件test反編譯,分析彙編指令,因此在這裡使用-m32選項生成i386架構指令而非x86_64架構指令。
經編譯和鏈接階段之後,test可執行文件中print_banner函數的彙編指令會是怎樣的呢?我猜應該與下面的彙編類似:
: **080483cc &
80483cc: push %ebp
80483cd: mov %esp, %ebp
80483cf: sub $0x8, %esp
80483d2: sub $0xc, %esp
80483d5: push $0x80484a8
80483da: call **&
80483df: add $0x10, %esp
80483e2: nop
80483e3: leave
80483e4: ret
print_banner函數內調用了printf函數,而printf函數位於glibc動態庫內,所以在編譯和鏈接階段,鏈接器無法知知道進程運行起來之後printf函數的載入地址。故上述的**&
** 一項是無法填充的,只有進程運運行後,printf函數的地址才能確定。
那麼問題來了:進程運行起來之後,glibc動態庫也裝載了,printf函數地址亦已確定,上述call指令如何修改(重定位)呢?
一個簡單的方法就是將指令中的**&
**修改printf函數的真正地址即可。
但這個方案面臨兩個問題:
- 現代操作系統不允許修改代碼段,只能修改數據段
- 如果print_banner函數是在一個動態庫(.so對象)內,修改了代碼段,那麼它就無法做到系統內所有進程共享同一個動態庫。
因此,printf函數地址只能回寫到數據段內,而不能回寫到代碼段上。
注意:剛才談到的回寫,是指運行時修改,更專業的稱謂應該是運行時重定位,與之相對應的還有鏈接時重定位。
說到這裡,需要把編譯鏈接過程再展開一下。我們知道,每個編譯單元(通常是一個.c文件,比如前面例子中的test.c)都會經歷編譯和鏈接兩個階段。
編譯階段是將.c源代碼翻譯成彙編指令的中間文件,比如上述的test.c文件,經過編譯之後,生成test.o中間文件。print_banner函數的彙編指令如下(使用強調內容objdump -d test.o命令即可輸出):
:00000000 &
0: 55 push %ebp
1: 89 e5 mov %esp, %ebp
3: 83 ec 08 sub $0x8, %esp
6: c7 04 24 00 00 00 00 movl $0x0, (%esp)
d: e8 fc ff ff ff call e &
12: c9 leave
13: c3 ret
是否注意到call指令的操作數是fc ff ff ff,翻譯成16進位數是0xfffffffc(x86架構是小端的位元組序),看成有符號是-4。這裡應該存放printf函數的地址,但由於編譯階段無法知道printf函數的地址,所以預先放一個-4在這裡,然後用重定位項來描述:這個地址在鏈接時要修正,它的修正值是根據printf地址(更確切的叫法應該是符號,鏈接器眼中只有符號,沒有所謂的函數和變數)來修正,它的修正方式按相對引用方式。
這個過程稱為鏈接時重定位,與剛才提到的運行時重定位工作原理完全一樣,只是修正時機不同。
鏈接階段是將一個或者多個中間文件(.o文件)通過鏈接器將它們鏈接成一個可執行文件,鏈接階段主要完成以下事情:
- 各個中間文之間的同名section合併
- 對代碼段,數據段以及各符號進行地址分配
- 鏈接時重定位修正
除了重定位過程,其它動作是無法修改中間文件中函數體內指令的,而重定位過程也只能是修改指令中的操作數,換句話說,鏈接過程無法修改編譯過程生成的彙編指令。
那麼問題來了:編譯階段怎麼知道printf函數是在glibc運行庫的,而不是定義在其它.o中
答案往往令人失望:編譯器是無法知道的
那麼編譯器只能老老實實地生成調用printf的彙編指令,printf是在glibc動態庫定位,或者是在其它.o定義這兩種情況下,它都能工作。如果是在其它.o中定義了printf函數,那在鏈接階段,printf地址已經確定,可以直接重定位。如果printf定義在動態庫內(鏈接階段是可以知道printf在哪定義的,只是如果定義在動態庫內不知道它的地址而已),鏈接階段無法做重定位。
根據前面討論,運行時重定位是無法修改代碼段的,只能將printf重定位到數據段。那在編譯階段就已生成好的call指令,怎麼感知這個已重定位好的數據段內容呢?
答案是:鏈接器生成一段額外的小代碼片段,通過這段代碼支獲取printf函數地址,並完成對它的調用。
鏈接器生成額外的偽代碼如下:
.text
...
// 調用printf的call指令
call printf_stub
...
printf_stub:
mov rax, [printf函數的儲存地址] // 獲取printf重定位之後的地址
jmp rax // 跳過去執行printf函數
.data
...
printf函數的儲存地址:
這裡儲存printf函數重定位後的地址
鏈接階段發現printf定義在動態庫時,鏈接器生成一段小代碼print_stub,然後printf_stub地址取代原來的printf。因此轉化為鏈接階段對printf_stub做鏈接重定位,而運行時才對printf做運行時重定位。
動態鏈接姐妹花PLT與GOT
前面由一個簡單的例子說明動態鏈接需要考慮的各種因素,但實際總結起來說兩點:
- 需要存放外部函數的數據段
- 獲取數據段存放函數地址的一小段額外代碼
如果可執行文件中調用多個動態庫函數,那每個函數都需要這兩樣東西,這樣每樣東西就形成一個表,每個函數使用中的一項。
總不能每次都叫這個表那個表,於是得正名。存放函數地址的數據表,稱為全局偏移表(GOT, Global Offset Table),而那個額外代碼段表,稱為程序鏈接表(PLT,Procedure Link Table)。它們兩姐妹各司其職,聯合出手上演這一出運行時重定位好戲。
那麼PLT和GOT長得什麼樣子呢?前面已有一些說明,下面以一個例子和簡單的示意圖來說明PLT/GOT是如何運行的。
假設最開始的示例代碼test.c增加一個write_file函數,在該函數裡面調用glibc的write實現寫文件操作。根據前面討論的PLT和GOT原理,test在運行過程中,調用方(如print_banner和write_file)是如何通過PLT和GOT穿針引線之後,最終調用到glibc的printf和write函數的?
我簡單畫了PLT和GOT雛形圖,供各位參考。
當然這個原理圖並不是Linux下的PLT/GOT真實過程,Linux下的PLT/GOT還有更多細節要考慮了。這個圖只是將這些躁聲全部消除,讓大家明確看到PLT/GOT是如何穿針引線的。
============== 新增加我之前在csdn博客上寫的文章鏈接 ===========
聊聊Linux動態鏈接中的PLT和GOT(1)——何謂PLT與GOT
聊聊Linux動態鏈接中的PLT和GOT(2)——延遲重定位
聊聊Linux動態鏈接中的PLT和GOT(3)——公共GOT表項
聊聊Linux動態鏈接中的PLT和GOT(4)—— 穿針引線
動態鏈接時,因為不知道模塊載入位置,將地址相關代碼抽出,放在數據段中就是got表。
為了實現地址的延遲綁定,再加了一個中間層,是一小段精巧的指令,用於在運行中填充got表。這些指令組成plt表。
參考《程序員的自我修養》第7章動態鏈接自己看書有點理解了,當然不肯定正確,求前輩斧正。
靜態的符號解析在鏈接器處完成,輸出部分鏈接的可執行文件p,普通的重定位在這時會修改各引用地址,但因為共享庫的位置是未知的(可能都沒載入進內存),沒法改,剩餘的鏈接工作只能在p載入進內存後由動態鏈接器完成。載入器將控制轉給動態鏈接器,動態鏈接器將完成下面的重定位任務:
A、將共享庫的文本和數據載入隨便一個存儲器段(如果共享庫本不在存儲器中的話)。
B、重定位p對共享庫符號的引用(這是問題的關鍵,現在p已經在存儲器中了,.text是可讀可執行不可寫的,那麼怎樣重定位呢?所以只好用data段里的GOT表進行重定位了,延遲綁定到第一次調用該函數時)
編譯時,-fPIC編的是.so文件,這個文件也是要訪問外部變數的,但是鏈接它程序很多,它裡面的地址不能寫死。 以下引用一段話,關鍵第一句: 對於模塊外部引用的全局變數和全局函數,用 GOT表的表項內容作為地址來間接定址;對於本模塊內的靜態變數和靜態函數,用 GOT表的首地址作為一個基準,用相對於該基準的偏移量來引用,因為不論程序被載入到何種地址空間,模塊內的靜態變數和靜態函數與GOT 的距離是固定的,並且在鏈接階段就可知曉其距離的大小。這樣,PIC 使用 GOT來引用變數和函數的絕對地址,把位置獨立的引用重定向到絕對位置。
目的是節約內存佔用,因為這樣可以生成地址無關代碼,代碼段內存空間可以共享,如果向win那樣的話,就要選擇一個默認載入基地址。
推薦閱讀:
※怎麼看待霧計算?
※如何用 grasshopper 模擬建築的人流?
※大牛程序員的傳記(自傳或他人作傳), 有哪些書籍?
※有哪些 Win95/98 時代的人無法理解的現在的事情?
※有哪些方便的備份系統的方法?