1. 程式人生 > >程序員的自我修養七動態鏈接

程序員的自我修養七動態鏈接

單獨 間接 程序啟動 代碼 自動 之前 pre 字符 rundll32

  • 7.1 為什麽要動態鏈接
  • 7.2 地址無關代碼
    • 7.2.1 固定裝載地址的困擾
    • 7.2.2 裝載時重定位
    • 7.2.3 地址無關代碼
  • 7.3 延遲綁定
  • 7.4 動態鏈接相關結構
    • 7.4.1 “.interp”段
    • 7.4.2 “.dynamic”段
    • 7.4.4 動態鏈接重定位表
    • 7.4.5 動態鏈接時進程堆棧初始化信息
  • 7.5 動態鏈接步驟和實現
    • 7.5.1 動態鏈接器自居
    • 7.5.2 裝載共享對象
    • 7.5.3 重定位和初始化
    • 7.5.4 Linux動態鏈接器實現
  • 7.6 顯示運行時鏈接
    • 7.6.1 c
    • 7.6.2 dlsym()
    • 7.6.3 dlerroe()
    • 7.6.4 dlclose()

正文

回到頂部

7.1 為什麽要動態鏈接

靜態鏈接使得不同的程序開發者和部門能夠相對獨立的開發和測試自己的程序模塊,從某種意義上來講大大促進了程序開發的效率,原先現在程序規模也隨之擴大。但靜態鏈接的缺點也暴露出來:浪費內存、磁盤空間、模塊更新困難。

內存和磁盤空間

靜態鏈接的方式對於計算機內存和磁盤的空間浪費非常嚴重。特別是多線程操作系統情況下,靜態鏈接極大的浪費了內存空間。

技術分享圖片

程序開發和發布

靜態鏈接堆程序的更新、部署和發布也會帶來麻煩。如果程序都使用靜態鏈接庫,那麽一旦程序有一點點改動,都會導致整個程序重新下載。

動態鏈接

要解決空間浪費和更新困難問題最簡單的辦法就是把程序的模塊相互分割開來,形成獨立的文件,而不再將它們靜態地鏈接在一起。簡單的講,就是不對那些組成程序的目標文件進行鏈接,等到程序要運行時才進行鏈接。把鏈接推遲到運行時在進行,這就是動態鏈接

技術分享圖片

當程序要升級程序庫或程序共享的某個模塊時,理論上只要簡單的將舊的目標文件覆蓋掉,而無須將所有程序在重新鏈接一遍。

程序可擴展性

動態鏈接還有一個特點就是程序在運行時可以動態地選擇加載各種程序模塊,這個優點被用來制作插件
動態鏈接還可以加強程序的兼容性。一個程序在不同的平臺運行時可以動態地鏈接到由操作系統提供的動態鏈接庫,這些動態鏈接庫相當於在程序和操作系統之間增加一個中間層,從而消除了程序對不同平臺之間依賴的差異性。

動態鏈接的基本實現

動態鏈接的基本思想是把程序按照模塊拆分成各個相對獨立部分,在程序運行時才將它們鏈接在一起,形成一個完整程序,而不是像靜態鏈接一樣把所有的程序模塊鏈接成一個單獨的可執行文件。
動態鏈接涉及運行時的鏈接及多個文件的轉載,必需要有操作系統的支持,因為動態鏈接的情況下,進程的虛擬地址空間的分布會比靜態鏈接情況下更為復雜,還有一些存儲管理、內存共享、進程線程等機制在動態鏈接下也會有一些微妙變化。
Linux系統中,ELF動態鏈接文件被稱為動態共享對象

,簡稱共享對象,它們一般是”.so”文件。在windows系統中,動態鏈接被稱為動態鏈接庫,它們通常就是我們常見的”.dll”為擴展名的文件。
當程序被轉載的時候,系統的動態鏈接器會將程序所需要的所有動態鏈接庫裝載到進程的地址空間,並將程序中所有未決議的符號綁定到相應的動態鏈接庫中,並進行重定位工作。

回到頂部

7.2 地址無關代碼

7.2.1 固定裝載地址的困擾

在動態鏈接的情況下,如果不同模塊目標裝載地址都一樣是不行的。而對於單個程序來講,我們可以手工指定各個模塊地址,但在多模塊被多個程序使用的時候,管理這些模塊的地址講非常繁瑣。
但早期的有些系統確實使用了這種做法,叫做靜態共享庫。但是它導致很多問題:地址沖突,靜態共享庫的升級。

7.2.2 裝載時重定位

在鏈接時,對所有絕對地址的引用不作重定位,而把這一步推遲到裝載時在完成。一旦模塊裝載地址確定,即目標地址確定,那麽系統就對程序所有的絕對地址引用進程重定位。
靜態鏈接時提到過重定位叫做鏈接時重定位。現在這種情況被稱為裝載時重定位,在Windows中,這種裝載時重定位被稱作基址重置

7.2.3 地址無關代碼

裝載時重定位時解決動態模塊中有絕對地址引用的辦法之一,但它有一個很大的缺點就是指令部分無法在多個進程之間共享,這樣就失去了動態鏈接節省內存的一大優勢。
目的:程序模塊中共享的指令部分在裝載時不需要因為裝載地址的改變而改變。
基本想法:把指令部分需要被修改的部分分離出來,跟數據部分放一起,這樣指令部分就可以保持不變,而數據部分可以在每個進程中擁有一個副本。這種方案被稱為地址無關代碼
模塊中各種地址引用方式可以分為以下幾種:

  • 模塊內部函數調用、跳轉等。
  • 模塊內部的數據訪問。
  • 模塊外部的函數調用、跳轉等。
  • 模塊外部的數據訪問。
類型一 模塊內部調用或跳轉

模塊內部的跳轉、函數調用都可以是相對地址調用,或者是基於寄存器的相對調用,所以對於這種指令是不需要重定位的。

類型二 模塊內部數據訪問

指令中不能直接包含數據的絕對地址,那麽唯一的辦法就是相對尋址。

類型三 模塊間數據訪問

模塊間的數據訪問目標地址要等到裝載時才決定。ELF的做法是在數據段裏面建立一個指向這些變量的指針數組,也被稱為全局偏移表,當代碼需要引用該全局變量時,可以通過GOT中相對的項間接引用。

技術分享圖片

類型四 模塊間調用、跳轉

GOT中相應的項保存的是目標函數的地址,當模塊要調用目標函數時,可以通過GOT中的項進行間接跳轉。

技術分享圖片

回到頂部

7.3 延遲綁定

動態鏈接有很多優勢,比靜態鏈接要靈活,但它犧牲了一部分性能代價。
動態鏈接比靜態鏈接慢的主要原因是動態裏鏈接對於全局和靜態的數據訪問都要進行復雜的GOT定位,然後間接尋址;對於模塊間的調用也要線定位GOT,然後進行跳轉。另一個減慢運行速度的原因是動態鏈接工作運行時完成,程序開始執行時,動態鏈接器都要進行一次鏈接。
在一個程序運行過程中,很多函數在程序執行完時都不會被用到,如果一開始把所有函數都鏈接好實際是一種浪費。所有ELF采用一種叫做延遲綁定的做法,基本思想:當函數第一次被用到時才進行綁定,如果不用到,則不綁定。
ELF使用PLT的方法來實現。
調用函數並不直接通過GOT跳轉,而是通過一個叫做PLT項結構來進行跳轉。每個外部函數在PLT中都有一個相對於的項,比如bar()函數在PLT中的項的地址我們稱為bar@plt。
bar@plt實現:
bar@plt:

jmp *(bar@GOT)//通過GOT間接跳轉的指令.連接器初始化階段並沒有將bar()地址填入該項,而是將下面的代碼地址填入
push n//將決議符號下標壓入堆棧
push moduleID//將模塊ID壓入堆棧
jump _dl_runtime_resolve//調用鏈接器_dl_runtime_resolve函數來完成符號解析和重定位工作

ELF將GOT拆分成兩張表叫做”.got”和”.got.plt”。
“.got”用來保存全局變量引用地址
“.got.plt”用來保存函數引用地址

回到頂部

7.4 動態鏈接相關結構

動態鏈接下,可執行文件的轉載與靜態鏈接情況基本一樣。首先操作系統會讀取可執行文件的頭部,檢查文件的合法性,然後從頭部中的”Program Header”中讀取每個”Segment”的虛擬地址、文件屬性和地址,並將它們映射到進程虛擬空間的相應位置。
在靜態鏈接情況下,操作系統接著就可以把控制權轉交給可執行文件的入口地址,然後程序開始執行。但在動態鏈接情況下,操作系統還不能在裝載完可執行文件就把控制權交給可執行文件,這個時候,可執行文件對於很多外部符號的引用還是處於無效地址狀態,所在在映射完可執行文件之後,操作系統會先啟動一個動態鏈接器。
操作系統加載完動態鏈接器後,將控制權交給動態鏈接器入口地址,當動態鏈接器得到控制權之後,它開始執行一系列自身初始化操作,然後根據環境參數,開始對可執行文件進行動態鏈接工作。當所有工作都完成後,動態鏈接器會將控制權轉交到可執行文件的入口地址,程序正式開始執行。

7.4.1 “.interp”段

動態鏈接器的位置由ELF可執行文件決定。在動態鏈接的ELF可執行文件中,有一個專門的段叫做”.interp”段。

“.interp”段的內容很簡單,裏面保存的就是一個字符串,這個字符串就是可執行文件所需要的動態鏈接器路徑。
動態鏈接器在Linux下是Glibc的一部分,也就是屬於系統庫級別。

7.4.2 “.dynamic”段

這是動態鏈接ELF中最重要的結構,這個段裏面保存了動態鏈接器所有需要的基本信息,比如:依賴與那些共享對象、動態鏈接符號表的位置、動態鏈接重定位表的位置、共享對象初始化代碼的地址等。

“.dynamic”段可以看成動態鏈接器下ELF文件的文件頭。

7.4.4 動態鏈接重定位表

共享對象需要重定位的主要原因是導入符號的存在。
在動態鏈接中,導入符號的地址在運行時才確定,所以需要在運行時將這些導入符號引用修正,即需要重定位。

動態鏈接重定位相關結構

在動態鏈接的文件中,也有和靜態文件類似的重定位的表,分別叫做”.rel.dyn”和”.rel.plt”。

技術分享圖片

7.4.5 動態鏈接時進程堆棧初始化信息

當操作系統將控制權交給動態鏈接器時,它需要知道可執行文件和本進程的一些信息,這些信息由操作系統傳遞給動態鏈接器,保存在進程的堆棧裏面。
堆棧裏面還保存了一些輔助信息數組

操作系統傳遞給動態鏈接器的輔助信息由4個:

  • AT_PHDR,值0x08048034,程序表頭位於0x08048034
  • AT_PHENT,值為20,程序表頭中每個項的大小為20字節
  • AT_PHNUM,值為7,程序表頭共有7個項
  • AT_ENTRY,0x08048320,程序入口地址為0x08048320
回到頂部

7.5 動態鏈接步驟和實現

動態鏈接基本上分為3步:先是啟動動態鏈接器本身,然後裝載所有需要的共享對象,最後重定位和初始化。

7.5.1 動態鏈接器自居

動態鏈接器本身不可以依賴於其他任何共享對象;其次時動態鏈接器本身所需要的全局和靜態變量的重定位工作由它本身完成。編寫動態鏈接器時保證不使用任何系統庫、運行庫;對於第二個條件動態鏈接器必須在啟動時有一段代碼可以完成這項工作同時又不能用到全局和靜態變量。這種具有一定限制條件的啟動代碼往往被稱為自舉

7.5.2 裝載共享對象

完成基本自舉後,動態鏈接器將可執行文件和鏈接器本身的符號表都合並到一個符號表中,我們稱為全局符號表。然後鏈接器開始尋找可執行文件所依賴的共享對象。在”.dynamic”段中,類型入口DT_NEEDED,它所指出的是該可執行文件所依賴的共享對象。鏈接器可以列出可執行文件所需要的所有共享對象,並將這些共享對象的名字放入一個裝載集合中。然後鏈接器開始從集合裏取一個所需要的共享對象名字,找到相對應的文件後打開該文件,讀取相應的ELF文件頭和”.dynalic”段,然後將它相應的代碼段和數據段映射到進程空間。
當一個新的共享對象被裝載進來的時候,它的符號表會被合並到全局符號表中,所以當所有共享對象都被裝載進來的時候,全局符號表裏面將包含進程中所有的動態鏈接器所需要的符號。

符號的優先級

兩個不同模塊定義了同一個符號會怎麽樣?
當一個共享對象裏面的全局符號被另一個共享對象的同名全局符號覆蓋的現象稱為共享對象全局符號介入
全局符號介入這個問題,在Linux下動態鏈接器是這樣處理的:它定義了一個規則,那就是當一個符號需要被加入全局符號表時,如果相同的符號名已經存在,則後加入的符號被忽略

7.5.3 重定位和初始化

當上面的步驟完成後,鏈接器開始重新遍歷可執行文件和每個共享對象的重定位表,將它們的GOT/PLT中每個需要重定位的位置進行修正。
重定位完成之後,如果某個共享對象有”.init”段,那麽動態鏈接器會執行”.init”段中代碼,用以實現共享對象特有的初始化過程。共享對象中可能還有”.finit”段,當進程退出時,會執行”.finit”段中的代碼,可以用來實現類似C++全局對象析構之類的操作。
如果進程的可執行文件也有”.init”段,那麽動態鏈接器不會執行它,因為可執行文件中”.init”段和”.finit”段由程序初始化部分代碼負責執行。

7.5.4 Linux動態鏈接器實現

內核在裝載完ELF可執行文件以後就iu返回到用戶空間,將控制權交給程序的入口。
對於動態鏈接器的可執行文件,內核會分析它的動態鏈接器地址,將動態鏈接器映射至進程地址空間,然後把控制權交給動態鏈接器。
Linux動態鏈接器本身就是一個共享對象。共享對象其實也就是ELF文件,它也有跟可執行文件一樣的ELF文件頭。動態鏈接器是個非常特殊的共享對象,它不僅是個共享對象,還是個可執行的程序。
Linux的內核在執行execve()時不關心目標ELF文件是否可執行,它只是簡單按照程序頭表裏面的描述對文件進行裝載然後把控制權交給ELF入口地址。
windows系統中的EXE和DLL也是類似的區別,DLL也可以被當作程序來運行,WINDOWS提供一個叫做rundll32.exe的工具可以把一個DLL當作可執行文件運行。
動態鏈接器本身應該是靜態鏈接的,它不能依賴於其他共享對象,動態鏈接器本身是用來幫助其他ELF文件解決共享對象依賴問題的。

回到頂部

7.6 顯示運行時鏈接

支持動態鏈接的系統往往都支持一種更加靈活的模塊加載方式,叫做顯示運行時鏈接,有時候也叫做運行時加載
一般的共享對象不需要進行任何修改就可以進行運行時裝載,這種共享對象往往叫做動態轉載庫
動態庫實際上跟一般的共享對象沒有區別。主要區別是共享對象是由動態鏈接器在程序啟動之前負責裝載和鏈接的,這一系列步驟都由動態連接器自動完成,對於程序是透明的。而動態庫的裝載則是通過一系列由動態鏈接器提供的API,具體地講共有4個函數:打開動態庫(dlpen),查找符號(dlsym),錯誤處理(dlerror),關閉動態庫(dlclose),程序可以通過這幾個API對動態庫進行操作。

7.6.1 c

dlopen()函數用來打開一個動態庫,並將其加載到進程的地址空間,完成初始化過程。

7.6.2 dlsym()

dlsym函數基本上是運行時裝載的核心部分,可以通過這個函數找到所需要的符號。

7.6.3 dlerroe()

每次調用dlopen(),dlsym(),dlclose(),以後,我們都可以調用dlerroe()函數來判斷上次調用是否成功。

7.6.4 dlclose()

dlclose()的作用跟dlopen()剛好相反,它的作用是將一個已經加載的模塊卸載。系統會維持一個加載引用計數器,每次使用dlopen()加載某模塊時,相應的的計數器加一;每次使用dlclose()卸載模塊時,相應的計算器減一。只有當計數器減到0時,模塊才被真正卸載掉。

程序員的自我修養七動態鏈接