深入理解計算機系統-第七章(鏈接)筆記

深入理解計算機系統-第七章(鏈接)筆記

背景

鏈接是將各種代碼和數據部分收集起來並組合成爲一個單一文件的過程

這個文件可被加載(拷貝)到存儲器中並執行:

  • 鏈接可以執行於編譯時,也就是源代碼翻譯成機器碼時

  • 也可以執行於加載時,也就是程序被加載到存儲器並執行時

  • 甚至執行於運行時,由應用程序來執行

鏈接是由叫做鏈接器的程序自動執行的。鏈接器的出現,使得分離編譯成爲可能,我們不用將一個大型的應用程序組織爲一個巨大的源文件,而是把它分解成更小、更好管理的模塊。可以獨立的修改和編譯這些模塊。當我們改變這些模塊中的一個時,我們只要單獨地編譯它,並將它重新鏈接到應用上,而不用編譯其他文件

編譯器驅動程序

實驗例子程序:

main.c:


int sum(int *a, int n);

int array[2] = {1,2};

int main() 
{

    int val = sum(array,2);
    return val;

}

sum.c:


int sum(int *a, int n)

{

    int i, s=0;

    for(i = 0; i < n; i++) 
    {
        s += a[i];
    }
    return s;
}


大多數編譯系統提供編譯驅動程序,對於上述例子:

  • 它首先運行C預處理器cpp,將C源程序main.c翻譯成一個ASCII碼的中間文件main.i

  • 接下來,C編譯器cc1將main.i翻譯成一個ASCII彙編語言文件main.s

  • 最後,彙編器as將main.s翻譯成一個可重定位目標文件main.o

類似地,生成sum.o。最後,運行鏈接器程序ld,將main.o和sum.o以及一些必要的系統目標文件組合起來,創建一個可執行目標文件prog

圖示:

圖1.png

靜態鏈接

像Linux ld程序這樣的靜態鏈接器以一組可重定位目標文件和命令行參數作爲輸入,生成一個完全鏈接的可以加載和運行的可執行目標文件作爲輸出。輸入的可重定位目標文件由各種不同的代碼和數據節組成。指令在一個節中,初始化的全局變量在另一個節中,而未初始化的變量又在另外一個節中。

爲了構造可執行文件,鏈接器必須完成兩個主要任務:

  • 符號解析。目標文件定義和引用符號。符號解析的目的是將每個符號引用剛好和一個符號定義聯繫起來。

  • 重定位。編譯器和彙編器生成從地址0開始的代碼和數據節。鏈接器通過把每個符號定義與一個存儲器位置聯繫起來,然後修改所有對這些符號的引用,使得他們指向這個存儲器,從而重定位這些節。

目標文件純粹是字節塊的結合,有些包含代碼,有些包含數據。

目標文件

目標文件有三種形式:

  • 可重定位目標文件。包含二進制代碼和數據,其可以在編譯時與其他可重定位目標文件合併起來,創建一個可執行目標文件

  • 可執行目標文件。包含二進制代碼和數據,其形式可以被直接拷貝到存儲器並執行

  • 共享目標文件。一種特殊類型的可重定位目標文件,可以在加載或者運行時被動態地加載到存儲器並鏈接

編譯器和彙編器生成可重定位目標文件(包括共享目標文件)

鏈接器生成可執行目標文件

各系統之間,目標文件的格式各不相同,早期的UNIX使用的是一般目標文件COFF。Windows NT使用的是COFF的一個變種,叫做可移植性可執行PE格式。現代Unix系統(包括linux,solaris)使用的是Unix可執行和可鏈接格式ELF。這些格式儘管各不相同,但基本的概念是類似的。

可重定位目標文件

典型的ELF可重定位目標文件:

圖2.png

ELF頭以一個16字節的序列開始,這個序列描述了生成該文件的系統的字的大小和字節順序。ELF頭剩下的部分包含幫助鏈接器語法分析和解釋目標文件的信息。其中包括ELF頭的大小、目標文件的類型(可重定位、可執行或者共享的)、機器類型(如IA32)、節頭部表的文件偏移,以及節頭部表中的條目大小和數量。

夾在ELF頭和節頭部表之間的都是節。一個典型的ELF可重定位目標文件包含下面幾個節:

  • .text:已編譯程序的機器代碼

  • .rodata:只讀數據

  • .data:已初始化的全局C變量,局部C變量在運行時保存在棧中

  • .bss:未初始化的全局變量。在目標文件中這個節不佔據實際的空間,它僅僅是一個佔位符。目標文件格式區分初始化和未初始化變量是爲了空間效率。

  • .symtab:符號表,它存放在程序中定義和引用的函數和全局變量的信息。

  • .rel.text:一個在.text節中位置的列表,當鏈接器把這個目標文件和其他文件結合時,需要修改這些位置。一般而言,任何調用外部函數或者引用全局變量的指令都需要修改。

  • .rel.data:被模塊引用或者定義的任何全局變量的重定位信息。一般而言,任何已初始化的全局變量,如果它的初始值是一個全局變量地址或者外部定義函數的地址,都需要被修改。

  • .debug:一個調試符號表,其條目是程序中定義的局部變量和類型定義,程序中定義和引用的全局變量,以及原始的C源程序。

  • .line:原始C源程序中的行號和.text節中機器指令之間的映射。

  • .strtab:一個字符串表,其內容包括.symtab和.debug節中的符號表,以及節頭部中的節名字。

符號和符號表

每個可重定位目標模塊m都有一個符號表,它包含m所定義和引用的符號信息。在鏈接器的上下文中,有三種不同的符號:

  • 由m定義並能被其他模塊引用的全局符號。全局鏈接器符號對應於非靜態的C函數以及被定義爲不帶C static屬性的全局變量。

  • 由其他模塊定義並被模塊m引用的全局符號。這些符號稱爲外部符號,對應於定義在其他模塊中的C函數和變量。

  • 只被模塊m定義和引用的局部符號。它們對應於帶static屬性的C函數和全局變量,這些符號在模塊m中任何位置都可見,但是不能被其他模塊引用。

在.symtab中的符號表不包含對應於本地非靜態程序變量的任何符號。這些符號在運行時再棧中管理。鏈接器對此類符號不感興趣。

定義爲帶有C static屬性的本地過程變量是不在棧中管理的,相反,編譯器在.data或.bss中爲每個定義分配空間,並在符號表中創建一個有唯一名字的本地鏈接器符號

比如,同一個模塊中兩個函數各自定義了一個靜態局部變量x:


int f()
{

    static int x = 0;
    return x;
}

int g()
{
    static int x = 1;
    return x;
}

這種情況下,編譯器向彙編器輸出兩個不同名字的局部鏈接器符號,比如它可以用x.1表示函數f中的定義,而用x.2表示函數g中的定義

符號解析

鏈接器解析符號引用的方法是將每個引用與它輸入的可重定位目標文件的符號表中的一個確定符號定義聯繫起來

對於那些和引用定義在同一模塊中的局部符號的引用,符合解析時非常簡單明瞭的。編譯器只允許每個模塊中每個局部符號只有一個定義,靜態局部變量也會有本地鏈接器符號,編譯器還要確保它們擁有唯一的名字。

但如果編譯器遇到一個不是在當前模塊中定義的符號(變量或者函數)時,它就會假設該符號是在其他某個模塊中定義的,生成一個鏈接器符號表條目,把它交給鏈接器處理。如果鏈接器在它任何輸入模塊中都找不到這個被引用的符號,它就輸出一條符號解析的錯誤信息並終止。

鏈接器如何解析多重定義的全局符號

鏈接器的輸入是一組可重定位目標模塊,每個模塊定義一組符號,有些是局部的(只對定義該符號的模塊可見),有些是全局的(對其他模塊可見)。如果多個模塊定義同名的全局符號,會發生什麼?下面是Linux編譯系統採用的方法:

在編譯時,編譯器向彙編器輸出每個全局符號,或者是強,或者是弱,而彙編器把這個信息隱含地編碼在可重定位目標文件的符號表裏。函數和已初始化的全局變量是強符號,未初始化的全局變量是弱符號。

根據強弱符號的定義,Linux鏈接器使用下面的規則來處理多重定義的符號:

  • 規則1:不允許有多個強符號

  • 規則2:如果有個強符號和多個弱符號,那麼選擇強符號

  • 規則3:如果有多個弱符號,那麼從這些弱符號中任意選擇一個

例子:

假設試圖編譯和鏈接下面兩個C模塊:

/* foo1.c */
int main()
{
    return 0;
}

/* bar1.c */
int main()
{
    return 0;
}

這種情況,鏈接器將生成一條錯誤信息,因爲強符號main被定義了多次(規則1)

相似的,鏈接器對於下面的模塊也會生成一條錯誤信息,因爲強符號x被定義了兩次(規則1):


/* foo2.c */

int x = 123456;

int main()
{
    return 0;
}


/* bar2.c */

int x = 123456;

void f()
{

}

如果在一個模塊裏x未被初始化,那麼鏈接器將安靜的選擇在另一個模塊中定義的強符號(規則2):



/* foo3.c */

#include<stdio.h>

void f(void);


int x = 123456;

int main()
{
    f();

    printf("x=%d",x);

    return 0;
}

/* bar3.c */

int x;

void f()
{
    x = 12345;
}

運行時,函數f將x的值由123456改成12345

如果x有兩個弱定義,也會發生一樣的事情(規則3):


/* foo4.c */

#include<stdio.h>

void f(void);

int x;

int main()
{
    x = 123456;

    f();

    printf("x = %d",x);

    return 0;
}

/* bar4.c */

int x;

void f()
{
    x = 12345;
}



規則2和規則3的應用會造成一些不易察覺的運行時錯誤

與靜態庫鏈接

所有的編譯系統都提供一種機制,將所有相關的目標模塊打包成一個單獨的文件,叫做靜態庫

它可以用做鏈接器的輸入,當鏈接器構造一個輸出的可執行文件時,它只複製靜態庫裏被應用程序引用的目標模塊

爲何要支持庫的概念?以ISO C99爲例,它定義了一組廣泛的標準IO、字符型操作以及整數數學函數,它們在libc.a庫裏面,對於每個C程序來說都是可用的

假設不使用靜態庫,編譯器開發人員要用什麼方法向用戶提供這些函數?

  • 1 讓編譯器辨認出對標準函數的調用,並直接生成相應的代碼。

這種方法對於C來說是不合適的,因爲C標準定義了大量的標準函數,這種方法會增加編譯器的複雜性,並且每次添加,刪除,修改一個函數時,都需要一個新的編譯器版本

  • 2 將所有的標準C函數都放在一個單獨的可重定位目標模塊中(比如libc.o)應用程序員可以把這個模塊鏈接到它們的可執行文件中

這種方法的優點:

  • 將編譯器的實現與標準函數的實現分離開來

缺點:

  • 系統中每個可執行文件現在都包含着一份標準函數集合的完全副本,這對於磁盤空間是很大的浪費。

  • 對任何標準函數的任何改變,無論多麼小的改變,都要求庫的開發人員重新編譯整個源文件,這是一個非常耗時的操作

靜態庫的概念被提出了,以解決這些不同方法的缺點,相關的函數可以被編譯爲獨立的目標模塊,然後封裝成一個單獨的靜態庫文件。之後,應用程序可以通過在命令行上指定單獨的文件名字來使用這些在庫中定義的函數。

鏈接時,鏈接器值需要複製被程序引用的目標模塊,這就減少了可執行文件在磁盤和內存中的大小。

鏈接器如何使用靜態庫來解析引用

在符號解析的階段,鏈接器從左到右按照它們在編譯器驅動程序命令上出現的相同順序來掃描可重定位目標文件和存檔文件。在掃描中,鏈接器維持一個可重定位目標文件的集合E(這個集合中的文件會被合併起來形成可執行文件),一個未解析的符號(即引用了但是尚未定義的符號)集合U,以及一個在前面輸入文件中已定義的符號集合D。初始時,E、U和D都是空的。

  • 對於命令行上的每個輸入文件f,鏈接器會判斷f是一個目標文件還是一個存檔文件(靜態庫.a),如果f是一個目標文件,那麼鏈接器把f添加到E,修改U和D來反映f中的符號定義和引用,並繼續下一個輸入文件。

  • 如果f是一個存檔文件,那麼鏈接器會嘗試匹配U中未解析的符號和由存檔文件成員定義的符號。如果某個存檔文件成員m,定義了一個符號來解析U中的一個引用,那麼就將m加到E中,並且鏈接器修改U和D來反映m中的符號定義和引用。對存檔文件中所有成員目標文件都反覆進行這個過程,直到U和D都不再發生變化。在此時,任何不包含在E中的成員目標文件都簡單地被丟棄,而鏈接器將繼續處理下一個輸入文件。

  • 如果當鏈接器完成對命令行上輸入文件的掃描後,U是非空的,那麼鏈接器就會輸出一個錯誤並終止。否則,它會合並和重定位E中的目標文件,從而構建輸出的可執行文件。

重定位

一旦鏈接器完成了符號解析這一步,它就把代碼中的每個符號引用和確定的一個符號定義聯繫起來。這樣,鏈接器就知道它輸入目標模塊中的代碼節和數據節的確切大小,並根據這些對目標模塊進行重定位,合併輸入模塊,爲每個符號分配運行時地址。重定位由兩步組成:

  • 重定位節和符號定義

在這一步中,鏈接器將所有相同類型的節合併爲同一個類型的新的聚合節

  • 如來自所有輸入模塊的.data節被全部合併成一個節,這個節成爲輸出的可執行目標文件的.data節

然後,鏈接器將運行時內存地址賦給新的聚合節,賦給輸入模塊定義的每個節,以及賦給輸入模塊定義的每個符號。這一步完成之後,程序中的每條指令和全局變量都有唯一的運行時內存地址了

  • 重定位節中的符號引用

在這一步中,鏈接器修改代碼節和數據節中對每個符號的引用,使得他們指向正確的運行時地址。要執行這一步,鏈接器要依賴於可重定位目標模塊中叫做重定位條目的數據結構

重定位條目

當彙編器生成一個目標模塊時,它並不知道數據和代碼最終將存放在存儲器中的什麼位置。它也不知道這個模塊引用的任何外部定義的函數或者全局變量的位置。

所以,無論何時彙編器遇到對最終位置未知的目標引用,它就會生成一個重定位條目,告訴鏈接器在將目標文件合併成可執行文件時如何修改這個引用。代碼的重定位條目放在.rel.text中。已初始化數據的重定位條目放在.rel.data中。


typedef struct {
    
    long offset;

    long type:32,
        sumbol"32;
    
    long addend;

}Elf64_Rela;


上面展示了ELF重定位條目的格式

  • offset:需要被修改的引用的節偏移

  • symbol:標識被修改引用應該指向的符號

  • type:告知鏈接器如何修改新的引用

  • addend:一個有符號常數

ELF定義了11中不同的重定位類型,在這裏只關心其中最基本的兩種:

R_X86_64_PC32:重定位一個使用32位PC相對地址的引用。即當相對於當前距程序計數器PC的當前運行時值的偏移量。例如CALL指令的目標。

R_X86_64_32:重定位一個使用32位絕對地址的引用。通過絕對尋址,CPU直接使用在指令中編碼的32位值作爲有效地址,不需要進一步修改。

可執行目標文件

一個典型的ELF可執行文件中的各類信息:

圖3.png

鏈接器將多個目標文件合併成一個可執行目標文件,一個簡單的C程序,開始時是一組ASCII文本文件,到後來轉化成一個二進制文件,並且這個二進制文件包含加載程序到內存並運行它所需要的所有信息

可執行目標文件的格式類似於可重定位目標文件的格式。EFL頭部描述文件的總體格式。它還包括程序的入口點。也就是程序執行的第一條指令地址。.text、.data節和可重定位目標文件中的節是相似的,除了這些節已經被重定位到他們最終的運行時存儲器地址以外。.init節定義了一個小函數,叫做_init,程序的初始化代碼會調用它。因爲可執行文件是完全鏈接的(已經被重定位),所以它不再需要.rel節。

ELF可執行文件被設計的很容易加載到內存,可執行文件的連續的片被映射到連續的內存段,程序頭部表描述了這種映射關係。

加載可執行目標文件

在Linux中運行可執行目標文件prog,可以在Linux shell的命令行運行


linux> ./prog

通過調用某個駐留在存儲器中叫做加載器的操作系統代碼來運行它,加載器將可執行目標文件中的代碼與數據從磁盤複製到內存,然後通過跳轉到程序的第一條指令或入口點來運行該程序,這個將程序複製到內存並運行的過程叫做加載

每個Linux程序都有一個運行時內存映像:

圖4.png

在Linux X86-64系統中,代碼總是從地址0x400000開始,後面是數據段,運行時堆在數據段之後,通過調用malloc庫往上增長,堆後面的區域是爲共享模塊保留的。

動態鏈接共享庫

爲了解決靜態庫的一些缺點:

  • 需要定期維護和更新

  • 幾乎每個C程序都使用標準IO函數,在運行時,這些函數的代碼會被複制到每個運行進程的文本段中,在一個運行上百個進程的典型系統上,這是對稀缺的內存系統資源的極大浪費

共享庫:

一個目標模塊。在運行或加載時,可以加載到任意的內存地址,並和一個在內存中的程序鏈接起來,這個過程叫做動態鏈接,由一個叫做動態鏈接器的程序來執行。

共享庫也叫作共享目標:

  • Linux中通常用.so後綴結尾來表示

  • Windows中用.dll後綴結尾來表示

共享庫以兩種不同的方式來“共享”:

  • 任何給定的文件系統中,對於一個庫只有一個.so文件,所有引用該庫的可執行目標文件共享這個,so文件中的代碼與數據,不是像靜態庫的內容那樣被複制和嵌入到引用它們可執行的文件中

  • 在內存中,一個共享庫的.text節的一個副本可以被不同的正在運行的進程共享

位置無關代碼

共享庫的一個關鍵目的是爲了使多個進程能夠共享內存中的同一份代碼拷貝,已達到節約內存資源的目的。如何做到呢?一種方法是預先爲每一個共享庫指定好加載的地址範圍,然後要求加載器總是將共享庫加載至指定的位置。這種方法儘管很簡單,但是會產生一些嚴重的問題。因爲就算一個進程並沒有用到某個庫,相應的地址範圍依然會被保留下來,這是一種效率很低的內存使用方式。另外,這種方法管理起來也很困難。我們必須保證預留的地址塊之間沒有重疊。每當一個庫被修改後,我們還必須要保證它能被放回到修改前的位置,否則,我們還要爲它重新找一個新的位置。當我們創建一個新的庫時,我們還要爲它尋找合適的空間,地址空間碎片化造成的大量無用的內存空洞。更糟糕的是,不同的系統爲動態庫分配內存的方式不盡相同,這使得管理起來更爲困難。

一個更好的方法是將動態庫編譯成可以在任意位置加載而無需鏈接器進行修改。這樣的代碼被稱作位置無關代碼(PIC)

GNU編譯系統可以通過指定-fPIC選項來生成PIC代碼

在IA32系統中,對於同一個模塊中的符號的引用無需特殊處理使之成爲PIC,因爲其引用相對於PC地址的偏移量是已知的。但是,對外部過程的調用和對全局變量的引用一般卻不是PIC的,因此需要在鏈接的時候進行重定位

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章