利用動態庫,可以節省磁盤、內存空間,而且可以提高程序運行效率;不過同時也導致調試比較困難,而且可能存在潛在的安全威脅。
這裡主要討論符號的動態鏈接過程,即程序在執行過程中,對其中包含的一些未確定地址的符號進行重定位的過程。
簡介
ld.so
(Dynamic Linker/Loader) 和 ldd
都會使用到 ELF 格式中的 .dynstr
(dynamic linking string table) 字段,如果通過 strip -R .dynstr hello
命令將該字段刪除,那麼 ldd
就會報錯。
Shell 執行
大致記錄一下 bash 的執行過程,當打開終端後,可以通過 tty
命令查看當前的虛擬終端,假設為 /dev/pts/27
,然後再通過 ps -ef | grep pts/27 | grep bash | grep -v grep
查看對應的 PID 。
打開另一個終端,通過 pstack PID
即可看到對應的調用堆棧。
其中詞法語法解析通過 flex-biso
解析,涉及的文件為 parse.y
,沒有找到詞法解析的文件。
通過 strace ./hello
查看系統調用,定位到 execve()
,也就是通過該函數執行。
常見概念
解釋器 .interp
分區用於指定程序動態裝載、鏈接器 ld-linux.so
的位置,而過程鏈接表 plt
、全局偏移表 got
、重定位表則用於輔助動態鏈接過程。
符號
對於可執行文件除了編譯器引入的一些符號外,主要就是用戶自定義的全局變量、函數等,而對於可重定位文件僅僅包含用戶自定義的一些符號。
上面包含全局變量、自定義函數以及動態鏈接庫中的函數,但不包含局部變量,而且發現這三個符號的地址都沒有確定。
經鏈接之後,global
和 main
的地址都已經確定了,但是 printf
卻還沒,因為它是動態鏈接庫 glibc
中定義函數,需要動態鏈接,而不是這裡的靜態鏈接。
也就是說 main.o 中的符號地址沒有確定,而經過鏈接後部分符號地址已經確定,也就是對符號的引用變成了對地址的引用,這樣程序運行時就可通過訪問內存地址而訪問特定的數據。對於動態鏈接庫,也就是上述的 printf()
則需要在運行時通過動態鏈接器 ld-linux.so 進行重定位,即動態鏈接。
另外,除了 nm 還可以用 readelf -s
查看 .dynsym
表或者用 objdump -tT
查看。
注意,在部分新系統上,如果不使用參數 -D
,那麼可能會無法查看符號表,因為 nm 默認打印 .symtab
和 .strtab
,不過一般在打包時會通過 strip 刪除掉,只保留了動態符號 (在 .dynsym
和 .dynstr
中),以便動態鏈接器在執行程序時尋址這些外部用到的符號。
內核加載
ELF 有靜態和動態鏈接兩種方式,加載過程由內核開始,而動態鏈接庫的加載則可以在用戶層完成。GNU 對於動態鏈接過程為 A) 把 ELF 映像的裝入/啟動加載在 Linux 內核中;B) 把動態鏈接的實現放在用戶空間 (glibc),併為此提供一個稱為 “解釋器” (ld-linux.so.2) 工具。
注意,解釋器的裝入/啟動也由內核負責,詳細可以查看 內存-用戶空間 中的介紹,在此只介紹 ELF 的加載過程。
內核模塊
如果要支持不同的執行格式,需要在內核中添加註冊模塊,每種類型通過 struct linux_binfmt
格式表示,其定義以及 ELF 的定義如下所示:
其中的 load_binary
函數指針指向的就是一個可執行程序的處理函數,要支持 ELF 文件的運行,則必須通過 register_binfmt()
向內核登記這個數據結構,加入到內核支持的可執行程序的隊列中。
當要運行程序時,則掃描該隊列,讓各對象所提供的處理程序 (ELF中即為load_elf_binary()
),逐一前來認領,如果某個格式的處理程序發現相符後,便執行該格式映像的裝入和啟動。
內核加載
內核執行 execv()
或 execve()
系統調用時,會通過 do_execve()
調用,該函數先打開目標映像文件,並讀入文件的頭部信息,也就是開始 128 字節。
然後,調用另一個 search_binary_handler()
函數,該函數中會搜索上面提到的 Linux 支持的可執行文件類型隊列,讓各種可執行程序的處理程序前來認領和處理。
如果類型匹配,則調用 load_binary
函數指針所指向的處理函數來處理目標映像文件,對於 ELF 文件也就是 load_elf_binary()
函數,下面主要就是分析 load_elf_binary()
的執行過程。
加載過程
依賴動態庫時,會在加載時根據可執行文件的地址和動態庫的對應符號的地址推算出被調用函數的地址,這個過程被稱為動態鏈接。
假設,現在使用的是 Position Independent Code, PIC 模型。
1. 獲取動態鏈接器
首先,讀取 ELF 頭部信息,解析出 PT_INTERP
信息,確定動態鏈接器的路徑,可以通過 readelf -l foobar
查看,一般是 /lib/ld-linux.so.2
或者 /lib64/ld-linux-x86-64.so.2
。
2. 加載動態庫
關於加載的詳細順序可以查看 man ld
中 rpath-link 的介紹,一般順序為:
- 鏈接時
-rpath-link
參數指定路徑,只用於鏈接時使用,編譯時通過 -Wl,rpath-link=
指定;
- 鏈接時通過
-rpath
參數指定路徑,除了用於鏈接時使用,還會在運行時使用,編譯時可利用 -Wl,rpath=
指定,會生成 DT_RPATH
或者 DT_RUNPATH
定義,可以通過 readelf -d main | grep -E (RPATH|RUNPATH)
查看;
- 查找
DT_RUNPATH
或者 DT_RPATH
指定的路徑,如果前者存在則忽略後者;
- 依次查看
LD_RUN_PATH
和 LD_LIBRARY_PATH
環境變量指定路徑;
- 查找默認路徑,一般是
/lib
和 /usr/lib
,然後是 /etc/ld.so.conf
文件中的配置。
另外,需要加載哪些庫通過 DT_NEEDED
字段來獲取,每條對應了一個動態庫,可以通過 readelf -d main | grep NEEDED
查看。
示例程序
利用如下的示例程序。
然後可以通過依次設置如上的加載路徑進行測試。注意,在對 /etc/ld.so.conf
文件設置後需要通過 ldconfig
更新 cache 才會生效。
另外,推薦使用 DT_RUNPATH
而非 DT_RPATH
,此時,在編譯時需要用到 --enable-new-dtags
參數。
版本管理
不同版本的動態庫可能會不兼容,那麼如果程序在編譯時指定動態庫是某個低版本,運行是用的一個高版本,可能會導致無法運行。
假設有如下的示例:
需要注意是,參數 -Wl,soname
中間沒有空格,-Wl
選項用來告訴編譯器將後面的參數傳遞給鏈接器,而 -soname
則指定了動態庫的 soname
。運行後在當前目錄下會生成一個 libhello.so.0.0.1
文件,當運行 ldconfig -n .
命令時,當前目錄會多一個符號連接。
這個軟鏈接是根據編譯生成 libhello.so.0.0.1
時指定的 -soname
生成的,會保存到編譯生成的文件中,可以通過 readelf -d foobar
查看依賴的庫。
所以關鍵就是這個 soname,它相當於一箇中間者,當我們的動態庫只是升級一個小版本時,可以讓它的 soname 相同,而可執行程序只認 soname 指定的動態庫,這樣依賴這個動態庫的可執行程序不需重新編譯就能使用新版動態庫的特性。
測試程序
示例程序如下。
然後可以通過 gcc main.c -L. -lhello -o main
編譯,不過此時會報 cannot find -lhello.so.0
錯誤,也就是找不到對應的庫。
在 Linux 中,編譯時指定 -lhello
時,鏈接器會去查找 libhello.so
這樣的文件,如果當前目錄下沒有這個文件,那麼就會導致報錯;此時,可以通過 ln -s libhello.so.0.0.1 libhello.so
建立這樣一個軟鏈接。
通過 ldd
查看時,發現實際依賴的是 libhello.so.0
而非 libhello
也不是 libhello.so.0.0.1
,其實在生成 main 程序的過程有如下幾步:
- 鏈接器通過編譯命令
-L. -lhello
在當前目錄查找 libhello.so
文件;
- 讀取
libhello.so
鏈接指向的實際文件,這裡是 libhello.so.0.0.1
;
- 讀取
libhello.so.0.0.1
中的 SONAME
,這裡是 libhello.so.0
;
- 將
libhello.so.0
記錄到 main
程序的二進制數據裡。
也就是說 libhello.so.0
是已經存儲到 main 程序的二進制數據裡的,不管這個程序在哪裡,通過 ldd
查看它依賴的動態庫都是 libhello.so.0
。
那麼,在部署時,只需要安裝 libhello.so.0
即可。
版本更新
假設動態庫需要做一個小小的改動。
由於改動較小,編譯動態庫時仍然指定相同的 soname 。
然後重新運行 ldconfig -n .
即可,會發現鏈接指向了新版本,然後直接運行即可。
同樣,假如我們的動態庫有大的改動,編譯動態庫時指定了新的 soname,如下:
將動態庫文件拷貝到運行目錄,並執行 ldconfig -n .
,不過此時需要重新編譯才可以。
動態解析
如上所述,控制權先是提交到解釋器,由解釋器加載動態庫,然後控制權才會到用戶程序。動態庫加載的大致過程就是將每一個依賴的動態庫都加載到內存,並形成一個鏈表,後面的符號解析過程主要就是在這個鏈表中搜索符號的定義。
從上面反彙編代碼可以看出,在調用 foobar()
時,使用的是絕對地址,printf()
的調用已經換成了 puts()
,調用的是 puts@plt
這個標號,位於 0x400410
,實際上這是一個 PLT 條目,可以通過反彙編查看相應的代碼,不過它代表什麼意思呢?
在進一步說明符號的動態解析過程以前,需要先了解兩個概念,一個是 Global Offset Table
,一個是 Procedure Linkage Table
。
Global Offset Table, GOT
在位置無關代碼中,如共享庫,一般不會包含絕對虛擬地址,而是在程序中引用某個共享庫中的符號時,編譯鏈接階段並不知道這個符號的具體位置,只有等到動態鏈接器將所需要的共享庫加載時進內存後,也就是在運行階段,符號的地址才會最終確定。
因此,需要有一個數據結構來保存符號的絕對地址,這就是 GOT 表的作用,GOT 表中每項保存程序中引用其它符號的絕對地址,這樣,程序就可以通過引用 GOT 表來獲得某個符號的地址。
Procedure Linkage Table, PLT
過程鏈接表的作用就是將位置無關的函數調用轉移到絕對地址。在編譯鏈接時,鏈接器並不能控制執行從一個可執行文件或者共享文件中轉移到另一箇中(如前所說,這時候函數的地址還不能確定),因此,鏈接器將控制轉移到PLT中的某一項。而PLT通過引用GOT表中的函數的絕對地址,來把控制轉移到實際的函數。
在實際的可執行程序或者共享目標文件中,GOT表在名稱為.got.plt的section中,PLT表在名稱為.plt的section中。
PLT
在通過 objdump -S test
命令返彙編之後,其中的 .plt
內容如下。
當然,也可以通過 gdb
命令進行反彙編。
可以看到 puts@plt
中包含三條指令,而且可以看出,除 PLT0(__gmon_start__@plt-0x10)
所標記的內容,其它的所有 PLT
項的形式都是一樣的,而且最後的 jmp
指令都是 0x400400
,即 PLT0
為目標的;所不同的只是第一條 jmp
指令的目標和 push
指令中的數據。
PLT0
則與之不同,但是包括 PLT0
在內的每個表項都佔 16 個字節,所以整個 PLT 就像個數組。
另外,需要注意,每個 PLT 表項中的第一條 jmp
指令是間接尋址的,比如的 puts()
函數是以地址 0x601018
處的內容為目標地址進行中跳轉的。
GOT
從上面可以看出,這個地址實際上就是順序執行,也就是 puts@plt
中的第二條指令,不過正常來說這裡應該保存的是 puts()
函數的地址才對,那為什麼會這樣呢?
原來鏈接器在把所需要的共享庫加載進內存後,並沒有把共享庫中的函數的地址寫到 GOT 表項中,而是延遲到函數的第一次調用時,才會對函數的地址進行定位。
如上,在 jmpq
中設置一個斷點,觀察到,實際調轉到了 _dl_runtime_resolve()
這個函數。
地址解析
在 gdb 中,可以通過 disassemble _dl_runtime_resolve
查看該函數的反彙編,感興趣的話可以看看其調用流程,這裡簡單介紹其功能。
從調用 puts@plt
到 _dl_runtime_resolve
,總共有兩次壓棧操作,一次是 pushq $0x0
,另外一次是 pushq 0x200c02(%rip) # 601008
,分別表示了 puts
函數在 GOT
中的偏移以及 GOT
的起始地址。
在 _dl_runtime_resolve()
函數中,會解析到 puts()
函數的絕對地址,並保存到 GOT
相應的地址處,這樣後續調用時則會直接調用 puts()
函數,而不用再次解析。
上圖中的紅線是解析過程,藍線則是後面的調用流程。
參考
關於動態庫的加載過程,可以參考 動態符號鏈接的細節。
如果喜歡這裡的文章,而且又不差錢的話,歡迎打賞個早餐 ^_^
支付寶打賞
微信打賞
</div>