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 時代的人無法理解的現在的事情?
有哪些方便的備份系統的方法?

TAG:程序員 | Linux | 計算機 | 計算機科學 | 計算機專業 |