參考文獻:
《ELF V1.2》
《程序員的自我修養---鏈接、裝載與庫》第6章 可執行文件的裝載與進程 第7章 動態鏈接
《Linux GOT與PLT》
開發平台:
[root@tanghuimin dynamic_link]# uname -a Linux tanghuimin 2.6.32-358.el6.x86_64 #1 SMP Fri Feb 22 00:31:26 UTC 2013 x86_64 x86_64 x86_64 GNU/Linux
實例講解之前先來一段理論鋪墊,文字很繁瑣但很必要事先了解。
1. ELF文件的裝載
《程序員的自我修養》6.5節對Linux內核裝載ELF的過程有描述。
首先在用戶界面,bash進程會調用fork()系統調用創建一個新的進程,然後新的進程調用execve()系統調用執行指定的ELF文件,原先的bash進程繼續返回等待剛才啟動的新進程結束,然後繼續等待用戶輸入命令。
在進入execve()系統調用之後,Linux內核就開始真正的裝載工作。在內核中,execve()系統調用相應的入口是sys_execve(),sys_execve()進行一些參數的檢查復制之後,調用do_execve()。do_execve()檢查被執行的文件的格式,調用search_binary_handle()查找合適的可執行文件裝載處理過程。elf可執行文件的裝載處理過程叫做load_elf_binary(),load_elf_binary()主要的工作是:
(1) 檢查ELF可執行文件格式的有效性,比如魔數,程序頭表中段(segment)的數量;
(2) 查找動態鏈接的“.interp”段,設置動態連接器路徑;
(3) 根據ELF可執行文件的程序頭表的描述,對ELF文件進行映射,比如代碼,數據,只讀數據;
(4) 初始化ELF進程環境,在進程堆棧中保存命令行參數,環境變量,及輔助信息數組(Auxiliary Vector),輔助信息數組為動態連接器提供可執行文件各個段的信息和程序的入口地址,關於輔助信息數組的詳細描述在《程序員的自我修養》的7.5.5節;
(5) 將系統調用的返回地址改成ELF可執行文件的入口點,這個入口點取決於程序的鏈接方式。對於靜態鏈接的ELF可執行文件,這個程序入口就是ELF文件的文件頭中的e_entry所指的地址,對於動態鏈接的ELF可執行文件,程序的入口點是動態鏈接器,動態鏈接器會被加載。
(6) 當load_elf_binary()執行完畢,返回至do_execve()再返回至sys_execve()時,上面第5步中已經把系統調用的返回地址改成了被裝載的ELF程序的入口地址了,所以當sys_execve()系統調用從內核態返回用戶態時,EIP寄存器直接跳轉到ELF程序的入口地址,於是新的程序開始執行,ELF可執行文件裝載完成。
2. 動態鏈接的步驟
(1) 動態鏈接器的自舉
動態鏈接器是一個特殊的共享對象。首先,它是靜態鏈接的,不依賴其他任何共享對象;其次,動態鏈接器本身所需要的靜態和全局變量的重定位工作都由它本身完成。對於第二個條件,動態鏈接器在啟動時,有一段非常精巧的代碼可以完成這項艱巨的工作,同時又不用到靜態和全局變量,這樣一段啟動代碼叫做自舉。動態連接器的入口即為自舉代碼的入口。
(2) 裝載共享對象
完成自舉後,動態鏈接器將可執行文件和其本身的符號表都合並到全局符號表中,然後開始尋找可執行文件所依賴的共享對象。可執行文件的.dynamic段中的DT_NEEDED入口下,記錄了該可執行文件所依賴的共享對象。動態鏈接器將所有依賴的共享對象裝載到內存,並將符號表合並到全局符號表中,所以當所有的共享對象都被裝載進來的時候,全局符號表中包含了進程中所有動態鏈接所需要的符號。
(3) 重定位和初始化
連接器遍歷可執行文件和共享對象的重定位表,將它們的GOT/PLT中的每個需要重定位的位置進行修正。因為此時動態鏈接器已經擁有了進程的全局符號表,所以這個修正過程比較容易。重定位完成之後,如果共享對象有.init段,那麼動態鏈接器會執行.init段中的代碼,用以實現共享對象特有的初始化過程。
當重定位和初始化都完成之後,所需要的共享對象都已經裝載並鏈接完成,動態鏈接器將進程的控制權轉交給程序入口並開始執行。
3. 關於.got和.plt
got:
由於共享庫的裝載地址是不固定的,為了保持代碼段的地址無關性,代碼段中對靜態和全局變量的訪問都通過got段來周轉,當要對變量進行操作時,從got段相應入口下讀取該變量的地址。每個變量的地址對應got段的一個入口,共享庫被裝載前,got為全0,當動態鏈接器將該共享庫裝載進內存時,會將每個變量的絕對地址寫入got段。
對於共享庫中定義的全局變量,鏈接時,連接器會在可執行文件的bss段中創建該變量的副本,當共享模塊被裝載時,如果某個全局變量在可執行文件中擁有副本,那麼動態鏈接器會把got中的地址指向該副本,這樣該變量在運行時的實例只有一個。如果變量在共享模塊中被初始化,那麼動態鏈接器還要將該初始化的值復制到可執行文件的副本中,如果該全局變量在可執行文件中沒有副本,那麼got中相應地址就指向模塊內部的該變量的副本。因為共享庫中的全局變量在每個進程中都擁有一個副本,所以多個進程對該全局變量的操作不會相互干擾。
plt:
動態鏈接相比靜態鏈接,雖然節約了內存空間,但是使用動態鏈接的程序卻沒有使用靜態鏈接的程序速度快,這是因為動態鏈接下,全局變量,靜態變量,函數調用都是通過got來間接跳轉,運行速度必定會變慢;其次動態鏈接是運行時鏈接,即程序運行時,連接器會加載共享庫,進行重定位工作,而在程序的運行過程中,很多函數根本不會用到,如果一開是就把所有的函數鏈接好顯然是一種是一種浪費,所以便催生了延遲綁定技術(Lazy Binding),基本思想是,函數第一次使用時才進行重定位,否則不為其重定位。
plt(procedure linkage table)是實現延遲綁定技術的一段精巧指令序列,當函數第一次執行時,會調用_dl_runtime_resolve()函數來為函數符號進行重定位,每個函數的地址在.got.plt下對應一個入口。當函數第二次執行時,只需從.got.plt相應的入口獲取該函數的地址即可。
.got.plt段專門用來存放函數的入口地址,.got.plt的前三個入口是有特殊含義的:
1) 第一項保留的是“.dynamic”段的地址,這個段描述的是本模塊動態鏈接相關的信息;
2) 第二項為本模塊的ID,調用_dl_runtime_resolve()對函數重定位時,需要將該id作為參數傳入;
3) 第三項保留的是_dl_runtime_resolve()的地址。
第二項和第三項都是在動態鏈接器裝載共享模塊時負責將它們初始化。.got.plt的其余入口都用來存放外部函數的地址。
關於.plt段中的精巧指令,下面的實例中會講到。
4. 實例講解
創建共享庫源文件common.c
int val = 1; int func(void) { return (val+10); }
創建test.c
extern int val; extern int func(void); int main() { val = 10; func(); return 0; }
生成共享庫
gcc -shared -fPIC -o common.so common.c
生成可執行文件
gcc -o test test.c ./common.so
將可執行文件test反匯編
objdump -S test > test.S
來分析一下test.S的main函數,看看代碼是怎麼去尋找val和func的入口的
main函數實現如下
00000000004005f4 <main>: 119 4005f4: 55 push %rbp 120 4005f5: 48 89 e5 mov %rsp,%rbp 121 4005f8: c7 05 ae 03 20 00 0a movl $0xa,0x2003ae(%rip) # 6009b0 <val> 122 4005ff: 00 00 00 123 400602: e8 f9 fe ff ff callq 400500 <func@plt> 124 400607: b8 00 00 00 00 mov $0x0,%eax 125 40060c: c9 leaveq 126 40060d: c3 retq 127 40060e: 90 nop 128 40060f: 90 nop
val = 10;對應的匯編代碼為
121 4005f8: c7 05 ae 03 20 00 0a movl $0xa,0x2003ae(%rip) # 6009b0 <val>
到0x6009b0處獲取val。
readelf -S test查看section header table,
...... [25] .bss NOBITS 00000000006009b0 000009ac 57 0000000000000018 0000000000000000 WA 0 0 8 ......
0x6009b0是bss段在內存中的虛擬地址,看來共享庫中的全局變量果真在bss段有副本。
再來看func函數的調用
123 400602: e8 f9 fe ff ff callq 400500 <func@plt>
跳轉到func@plt處了
來看看.plt段到底都干了些什麼
15 Disassembly of section .plt: 16 17 00000000004004e0 <__libc_start_main@plt-0x10>: 18 4004e0: ff 35 a2 04 20 00 pushq 0x2004a2(%rip) # 600988 <_GLOBAL_OFFSET_TABLE_+0x8> 19 4004e6: ff 25 a4 04 20 00 jmpq *0x2004a4(%rip) # 600990 <_GLOBAL_OFFSET_TABLE_+0x10> 20 4004ec: 0f 1f 40 00 nopl 0x0(%rax) 21 22 00000000004004f0 <__libc_start_main@plt>: 23 4004f0: ff 25 a2 04 20 00 jmpq *0x2004a2(%rip) # 600998 <_GLOBAL_OFFSET_TABLE_+0x18> 24 4004f6: 68 00 00 00 00 pushq $0x0 25 4004fb: e9 e0 ff ff ff jmpq 4004e0 <_init+0x18> 26 27 0000000000400500 <func@plt>: 28 400500: ff 25 9a 04 20 00 jmpq *0x20049a(%rip) # 6009a0 <_GLOBAL_OFFSET_TABLE_+0x20> 29 400506: 68 01 00 00 00 pushq $0x1 30 40050b: e9 d0 ff ff ff jmpq 4004e0 <_init+0x18>
每個需要動態鏈接的函數在.plt段下都對應一個入口,如上入口都以“函數名@plt”來命名。
當調用func()時,代碼跳轉到了0x400500處,第一條指令 jmpq *0x20049a(%rip),跳轉到了0x6009a0處,0x60090在.got.plt中,readelf -r test可以知道該地址正式func函數在.got.plt中對應的入口的地址,readelf -S test查到.got.plt的虛擬地址為0x600980,段大小為0x28,每個入口8個字節,一共有五個入口。hexdump -C test來看看.got.plt段的幾個入口在程序運行前都存了些什麼。
由上圖看出,第一個入口,0x6007d8存的正式.dynamic段的地址,第二個和第三個入口應該是module id和_dl_runtime_resolve()函數的入口,這兩個現在都是全0,共享庫被加載並初始化的時候會為其賦值,第四個入口是__libc_start_main函數的入口地址,第五個是func函數的入口地址。
jmpq *0x20049a(%rip)到底跳轉到哪裡去了呢?我們發現0x9a0處存的地址是0x400506,是jumpq的下一條指令的地址,好吧,饒了半天直接跳跳下一條指令而已,繼續往下走,將0x1壓棧,然後跳轉到0x4004e0處,即.plt的第一個入口,pushq 0x2004a2(%rip) ,將0x600988處的值壓棧,0x600988是.plt的第二個入口,存的是module id,然後 jmpq *0x2004a4(%rip) ,跳轉到0x600990處,0x600990是.plt的第三個入口,即_dl_runtime_resolve()函數的入口,_dl_runtime_resolve()就是用來重定位函數的,忽然領悟,原來這就是傳說中的“延遲綁定”,即第一次調用函數的時候進行重定位。
重新看一下上面plt部分的代碼,理一下思路。當在可執行程序中第一次調用共享庫中的函數func時,跳轉到func@plt處,這時func在.got.plt中的地址是jmpq的下一條指令的地址,進而引導代碼去調用_dl_runtime_resolve()鏈接函數func,當然這之前會將func在.rel.plt中的index(0x01)和module id壓棧作為參數傳給_dl_runtime_resolve()。_dl_runtime_resolve()執行完後,.got.plt相應入口下便寫入了func的真實地址,當程序第二次調用func函數時,就能跳轉到func函數的入口了。
5. GDB調試
用gdb來調試test,看看不同時刻.got.plt中的值是怎樣變化的,還有.bss中的val值。
先把要查看的各項的地址交代一下
val:0x6009b0
module id: 0x600988
_dl_runtime_resolve()地址:0x600990
func地址:0x6009a0
$ gcc -o test -g test.c ./common.so
加上-g選項生成帶gdb調試信息的可執行文件
在main函數處設斷點,查看運行到main處時,.bss中的val值和.got.plt中的各項值
For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>... Reading symbols from /tmp/test/dynamic_link/test...done. (gdb) l 1 extern int val; 2 extern int func(void); 3 4 int main() 5 { 6 val = 10; 7 func(); 8 return 0; 9 } (gdb) b main Breakpoint 1 at 0x4005f8: file test.c, line 6. (gdb) r Starting program: /tmp/test/dynamic_link/test Breakpoint 1, main () at test.c:6 6 val = 10; Missing separate debuginfos, use: debuginfo-install glibc-2.12-1.132.el6.x86_64 (gdb)
在查看內存中各項值之前我們先預測一下:
可執行程序運行到main函數時,動態庫已經被加載了,動態庫的初始化已經完成,所以val的值應該是1,即共享庫中初始化的值,module id和_dl_runtime_resolve()的地址也應該有了。func()函數的地址應該還沒有,因為func函數還沒有被執行
好,看看內存中的情況是不是如我所測
Breakpoint 1, main () at test.c:6 6 val = 10; Missing separate debuginfos, use: debuginfo-install glibc-2.12-1.132.el6.x86_64 (gdb) x 0x6009b0 0x6009b0 <val>: 0x00000001 (gdb) x 0x600988 0x600988 <_GLOBAL_OFFSET_TABLE_+8>: 0x41021188 (gdb) x 0x600990 0x600990 <_GLOBAL_OFFSET_TABLE_+16>: 0x40e146f0 (gdb) x 0x6009a0 0x6009a0 <[email protected]>: 0x00400506 (gdb)
很好,和預測的一樣
下面單步執行val =10
(gdb) n 7 func(); (gdb) x 0x6009b0 0x6009b0 <val>: 0x0000000a (gdb) x 0x6009a0 0x6009a0 <[email protected]>: 0x00400506 (gdb)
可以看到val的值已經變成10了,func沒有被執行,地址依然沒有變
再次單步執行,func()函數被執行過了
查看func在.got.plt中的地址
(gdb) n 8 return 0; (gdb) x 0x6009a0 0x6009a0 <[email protected]>: 0xf7dfc55c (gdb)
可以看出func執行過一次之後,其在.got.plt中的地址已被重定向到正確的地址0xf7dfc55c。