動態(tài)鏈接的一點小總結(jié) 《程序員的自我修養(yǎng)》·筆記

動態(tài)鏈接的一點小總結(jié)

  • 動態(tài)鏈接(相對于靜態(tài)鏈接)的優(yōu)勢

    • 動態(tài)鏈接可以節(jié)省內(nèi)存和磁盤空間。動態(tài)鏈接使得內(nèi)存和磁盤中的編譯完成的目標文件只保留一份,這樣也可以減少物理頁的換入換出,同時也可以增加CPU緩存的命中率。
    • 動態(tài)鏈接便于程序的更新、部署、發(fā)布;
    • 動態(tài)鏈接下,程序在運行期間可以動態(tài)地加載各種程序模塊,也就是我們經(jīng)常說的插件;
    • 動態(tài)鏈接可以加強程序的兼容性,程序和不同平臺之間可以加入一個“中間層”,讓程序在不同的平臺可以動態(tài)地鏈接到有操作系統(tǒng)提供的動態(tài)鏈接庫,從而消除程序?qū)Σ煌脚_依賴的差異性;
  • 動態(tài)鏈接的基本實現(xiàn)

    • Linux系統(tǒng)中,ELF動態(tài)鏈接文件被稱為動態(tài)共享對象,一般是以".so"為擴展名;在win下被稱為動態(tài)鏈接庫,以".dll"結(jié)尾;
    • C語言庫的動態(tài)鏈接庫文件是“l(fā)ibc.so”,程序與之真正的鏈接工作是由動態(tài)鏈接器完成的,動態(tài)鏈接是把鏈接這個過程從本來的程序裝載前被推遲到了裝載的時候,這樣的推遲也造成了動態(tài)鏈接的性能損失,不過我們也有相應(yīng)的優(yōu)化策略:延遲綁定。之后會詳細說明。
  • 通過一個簡單的例子引入

/* Program1.c */
#include "Lib.h"
int main()
{
    foobar(1);
    return 0;
}
/* Program2.c*/
#include "Lib.h"
int main()
{
    foobar(2);
    return 0;
}
/* Lib.c */
#include <stdio.h>
void foobar(int i)
{
    printf("Printing from Lib.so %d\n",i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
- 程序很簡單,兩個程序的主模塊Program1.c和Program2.c分調(diào)用Lib.c里面的foobar()函數(shù)執(zhí)行相應(yīng)的操作。
- 接下來我們使用gcc將Lib.c編譯成為一個共享對象文件(即動態(tài)鏈接文件)如下:  
`gcc -fPIC -shared -o Lib.so Lib.c`  
其中,`-shared`是表示產(chǎn)生共享對象的參數(shù),這樣我們就得到了一個Lib.so文件。
- 接著,我們編譯鏈接Program1.c和Program2.c文件:  
`gcc -o Program1 Program1.c ./Lib.so`  
`gcc -o Program2 Program2.c ./Lib.so`  
這樣我們就得到了兩個程序**Program1**和**Program2**,整個編譯以及鏈接過程大致如下:
![](http://7xl3j2.com1.z0.glb.clouddn.com/cxy-9.jpg)
【引入模塊module的概念】在動態(tài)鏈接下,一個程序被分成若干個文件,有程序的主要部分,即可執(zhí)行文件(Program1),也有程序所依賴的共享對象(Lib.so),很多時候,我們也把這些部分稱為模塊,即動態(tài)鏈接下可執(zhí)行文件和共享對象都可以看作是程序的一個模塊。
- 當(dāng)鏈接器將Program1.o鏈接成可執(zhí)行文件的時候,這個時候鏈接器必須確定Program1.o所引用的foobar()函數(shù)的性質(zhì),如果該函數(shù)是一個定義在某個動態(tài)共享對象中的函數(shù),那么鏈接器就會將這個符號的引用標記為一個動態(tài)鏈接的符號,不對他進行地址重定位,把這個過程留到裝載的時候再進行。判斷函數(shù)是靜態(tài)符號還是動態(tài)符號也簡單,Lib.so保存了完整的符號信息。

【需要注意的是】共享對象的最終裝載地址在編譯的時候是不確定的,而是在裝載的時候,裝載器根據(jù)當(dāng)前地址空間的空閑狀況,動態(tài)分配一塊足夠大小的虛擬地址空間給相應(yīng)的共享對象。這樣的話,關(guān)于共享對象的地址就會出現(xiàn)問題,下面會給出問題以及對應(yīng)的解決方案。

  • 地址無關(guān)代碼的引入(確定共享對象加載的時候在虛擬地址空間的位置)

    • 固定裝載地址的困擾
      ??實現(xiàn)共享對象在任意地址加載,也就是說共享對象在編譯的時候不能假設(shè)自己在進程虛擬地址空間的位置。但是可執(zhí)行文件基本可以確定自己在虛擬空間中的起始位置,因為可執(zhí)行文件往往是第一個被加載的文件。
    • 裝載時重定位(暫時的一個解決方法)
      ??實現(xiàn)共享對象在任意地址位置的裝載的大致基本思路:在鏈接的時候,對所有的地址引用不作重定位,而把這一步推遲到裝載的過程。一旦模塊裝載地址確定,即目標地址確定,那么系統(tǒng)就對程序中的所有絕對地址引用進行重定位。比如,上面的例子,假設(shè)foobar相對于代碼段的的起始地址是0x100,當(dāng)模塊被裝載到0x10000000時,我們假設(shè)代碼段位于模塊的最開始,即代碼段的裝載地址也是0x10000000,那么我們就可以確定foobar的裝載地址為0x10000100。這時系統(tǒng)遍歷模塊中的重定位表,把所有的foobar的地址引用重定位為0x10000100即可。
    • Linux和GCC支持這種裝載時重定位的方法,我們前面在產(chǎn)生共享對像的時候,使用了兩個GCC參數(shù)"-shared"和"-fPIC",如果只使用"-shared",那么輸出的共享文件對象就是使用裝載時重定位的方法。
    • 但是上述的裝載時重定位的方法有一個問題:指令部分無法在多個進程之間共享。這樣就會失去動態(tài)鏈接節(jié)省內(nèi)存的優(yōu)勢。其實需要解決的問題就是,希望程序模塊中共享的指令部分在裝載的時候不需要因為裝載地址的改變而改變
    • 改進的基本思想:把指令中那些需要修改的部分分離出來,跟數(shù)據(jù)部分放在一起,這樣指令部分就可以保持不變了,而數(shù)據(jù)部分可以在每個進程中保留一個副本。這種方案就是目前的地址無關(guān)代碼,即PIC技術(shù)
    • PIC技術(shù)
      將對共享對象模塊的地址引用分為指令引用和數(shù)據(jù)引用,如下圖,后面會詳細介紹:

    1.模塊內(nèi)部函數(shù)調(diào)用、跳轉(zhuǎn)等。
    - 位置相對固定,可以采用相對地址調(diào)用或基于寄存器相對調(diào)用,這種指令不需要重定位;

    2.模塊內(nèi)部數(shù)據(jù)訪問。
    - 相對地址,一個模塊前面一般是若干個頁的代碼,后面緊跟著若干個頁的數(shù)據(jù),這些頁之間的相對位置是固定的,也就是說任一條指令與模塊內(nèi)部數(shù)據(jù)之間的相對位置也是固定的。PC + 偏移值即相對地址;

    3.模塊外部函數(shù)調(diào)用、跳轉(zhuǎn)等。
    - 因為模塊間的數(shù)據(jù)訪問目標地址要等到裝載的時候才能確定。要想讓代碼地址無關(guān),就要將與地址相關(guān)的部分放至數(shù)據(jù)段。ELF的做法是在數(shù)據(jù)段里面建立一個指向這些變量的指針數(shù)組,也被稱為全局偏移表(GOT),當(dāng)代碼需要引用改全局變量耳朵時候,可以通過GOT中相對應(yīng)的項簡介引用。其機制如下:

    4.模塊外部數(shù)據(jù)訪問。模塊間的數(shù)據(jù)訪問目標地址要等裝載時才決定,這時就用到了代碼地址無關(guān)技術(shù),把跟地址相關(guān)的部分放到數(shù)據(jù)段。ELF建立一個指向其他模塊全局變量的指針數(shù)組(GOT),采用它間接調(diào)用。
    - 與3的解決方法類似,如下:

  • 延遲綁定(PLT

    • 據(jù)統(tǒng)計,ELF程序的靜態(tài)鏈接比動態(tài)鏈接稍微快一些,大約為1%~5%。主要原因:
      • 動態(tài)鏈接對全局的和靜態(tài)的數(shù)據(jù)的訪問都要進行復(fù)雜的GOT定位,然后間接尋址;對于模塊間的調(diào)用也要先進行GOT定位,然后再進行間接跳轉(zhuǎn)。
      • 程序開始執(zhí)行時,,動態(tài)鏈接器都要執(zhí)行一次鏈接工作。
    • 優(yōu)化
      ??可以想象,有些函數(shù)在程序執(zhí)行過程中很少用到,比如錯誤處理函數(shù)或者是用戶很少用到的程序功能模塊,所以一開始就將所有的函數(shù)都鏈接好是一種浪費。優(yōu)化的思路:當(dāng)程序第一次被用到的時候才進行綁定(符號查找、重定位等),如果沒有用到就不進行綁定。
    • PLT的真正實現(xiàn)
      • ELF將GOT拆分成兩個表,".got"和".got.plt",前者保存全局變量的引用的地址,后者對應(yīng)函數(shù)引用的地址。對于".got.plt",其前三項有特殊的含義:
        1.".dynamic"段的地址;
        2.本模塊的ID;
        3._dl_runtime_resolve()(完成地址綁定的函數(shù))的地址。
        其中的第二項和第三項由動態(tài)鏈接器在裝載共享變量的時候進行初始化,".got.plt"的其余項分別對應(yīng)每個外部函數(shù)的引用。如下:

通過上面原理的了解,接下來我們考慮一下動態(tài)鏈接的基本實現(xiàn)過程

  • 動態(tài)鏈接的相關(guān)結(jié)構(gòu)

    • 基本過程(簡述)
      1.動態(tài)鏈接的裝載。首先操作系統(tǒng)會讀取可執(zhí)行文件的頭部,檢查文件的合法性,之后從頭部中的"Program Header"中讀取每個"Segment"的虛擬地址、文件地址和屬性,并將其映射到進程虛擬空間的相應(yīng)位置。
      2.如果是靜態(tài)鏈接,上述過程之后,操作系統(tǒng)就會把控制權(quán)交給可執(zhí)行文件的入口地址,但是在動態(tài)鏈接中,操作系統(tǒng)接下來會啟動一個動態(tài)鏈接器。
      3.在Linux下,動態(tài)鏈接器ld.so實際上是一個共享對象,操作系統(tǒng)同樣通過映射的方式將其加載到進程的地址空間。當(dāng)動態(tài)鏈接器得到控制權(quán)之后,它就開始執(zhí)行一系列的自身的初始化操作,然后根據(jù)當(dāng)前的環(huán)境參數(shù)對可執(zhí)行文件進行動態(tài)鏈接工作。
      4.當(dāng)所有的動態(tài)鏈接工作完成之后,動態(tài)鏈接器將控制權(quán)交給可執(zhí)行文件,程序開始正式執(zhí)行。
    • 動態(tài)鏈接相關(guān)的段
      1.".interp"段(是ELF可執(zhí)行文件中的一個段,下面段的也一樣)
      ??動態(tài)鏈接器需要被加載到進程空間,所以事先要知道動態(tài)鏈接器的位置,".interp"段保存的就是動態(tài)鏈接器的地址路徑。
      2.".dynamic"段
      ??里面包含了動態(tài)鏈接所需要的基本信息,比如依賴于哪些共享對象、動態(tài)鏈接符號表的位置、動態(tài)鏈接重定位表的位置、共享對象初始化代碼的地址等等。
      ??".dynamic"段保存的信息(類型、格式)有點像ELF文件頭,也可以將其看作是動態(tài)鏈接情況下的ELF文件的“文件頭”
      3.動態(tài)符號表
      ??類似于靜態(tài)鏈接的".symtab"符號表,動態(tài)鏈接有一個動態(tài)符號表".dynsym",該符號表僅僅保存了與動態(tài)鏈接相關(guān)的符號。
      ??很多時候動態(tài)鏈接的模塊同時擁有".symtab"和".dynsym"兩個表,".symtab"往往保存了多有的符號,包括".dynsym"中的符號(相當(dāng)于".dynsym"將動態(tài)鏈接的相關(guān)符號單獨取出來了)。
      ??動態(tài)鏈接也需要一些輔助的表,用于保存符號名的字符串等,即動態(tài)符號字符串表".dynstr"。
      4.動態(tài)鏈接重定位表
      ??上述的".got.plt"從第四項開始就是外部函數(shù)的引用。動態(tài)鏈接器到動態(tài)全局符號表里面找到這些引用進行重定位,下面會提到。
    • 動態(tài)鏈接的步驟和實現(xiàn)
      1.動態(tài)鏈接器“自舉”
      ??動態(tài)鏈接器本身就是一個共享對象,但是又有一些特殊性。動態(tài)鏈接器本身不可以依賴其他任何的共享對象;其次是動態(tài)鏈接本身的所需要的全局和靜態(tài)變量的重定位工作由自己完成。
      ??動態(tài)鏈接器的入口地址即自舉代碼的入口。自舉代碼會首先找到他自己的GOT。而GOT的第一個入口保存的即是".dynamic"段的偏移地址,由此就可以找到動態(tài)鏈接器自身的".dynamic"段,也就可以得到重定位表以及符號表,從而得到動態(tài)鏈接器本身的重定位入口。
      2.裝載共享對象
      ??完成基本的自舉之后,動態(tài)鏈接器可以將可執(zhí)行文件和鏈接器本身的符號表合并進而得到全局符號表。然后鏈接器開始尋找可執(zhí)行文件所依賴的共享文件,我們前面提到過".dynamic"段中,有一類的入口叫做DT_NEEDED,對應(yīng)的就是該可執(zhí)行文件(或者共享文件)所依賴的共享對象。由此,鏈接器可以列出可執(zhí)行文件所需要的所有共享對象,并將這些共享對象的名字放入到一個裝載器集合中。
      ??然后鏈接器開始從集合中取出一個所需的共享對象的名字,找到相應(yīng)的文件之后打開該文件,讀取相應(yīng)的ELF文件頭和".dynamic"段,將相應(yīng)的代碼段和數(shù)據(jù)段映射到進程空間。(如果該共享對象還依賴于其他的共享對象,就將依賴的共享對象裝入裝載集合里面)如此循環(huán)直到所有的共享對象都裝載進來。如果我們把依賴關(guān)系看作一個圖的話,那么裝載的整個過程就類似于圖的遍歷過程(深度遍歷和廣度遍歷,多廣度遍歷)。
      ??當(dāng)一個新的共享對象被裝載進來的時候,它的符號表會被合并到全局符號表中,所以,當(dāng)所有的共享都被裝載進來的時候,全局符號表里面講包含進程中所有的動態(tài)鏈接需要的符號。
      3.重定位和初始化
      ??鏈接器重新遍歷可執(zhí)行文件和每個共享對象的重定位表,將他們的GOT/PLT中的每個需要重定位的位置進行修正。
      ??重定位完成之后,如果某個共享對象有".init"段,那么動態(tài)鏈接就會執(zhí)行該段中的代碼,用于實現(xiàn)共享對象特有的初始化過程。比如,共享對象中的C++的全局/靜態(tài)對象的構(gòu)造就需要通過".init"段來初始化。相應(yīng)的,共享對象中還可能有".finit"段,當(dāng)進程退出的時候會執(zhí)行該段的代碼,可以實現(xiàn)類似C++全局對象析構(gòu)之類的操作。
      ??當(dāng)完成重定位和初始化之后,所有的準備工作就完成了,就將進程的控制權(quán)交還給程序入口開始執(zhí)行。
  • 問題

    • 動態(tài)鏈接器本身是靜態(tài)鏈接還是動態(tài)鏈接?
      • 靜態(tài)鏈接。執(zhí)行過程不依賴于其他的共享變量??梢酝ㄟ^ldd命令查看。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容