天天看點

深入了解計算機系統:連結(第二章:符号解析、重定位和可執行目标檔案)1、符号解析2、重定位3、可執行目标檔案4、加載可執行目标檔案到記憶體

1、符号解析

連結器解析符号引用的方法是将每個引用與它輸入的可重定位目标檔案的符号表中的一個确定的符号定義關聯起來,即使用的符号一定要找到相應的定義。可分為局部符号解析和全局符号解析。

局部符号解析:引用定義在相同子產品中的局部符号的引用,符号解析非常的簡單明了,就不用介紹了。

全局符号解析:當編譯器遇到一個不是目前子產品中定義的符号時,會假設該符号時在其他某個子產品中定義的,生成一個連接配接器符号表條目,并把它交給連結器處理。如果連結器在它的任何輸入子產品中都找不到這個符号的定義,就輸出一條錯誤資訊并終止。

全局符号解析還因為多個目标檔案可能會定義相同的名字的全局符号。在這種情況下,連結器必須要麼标志一個錯誤,要麼以某種方法選出一個定義并抛棄其它定義。

1.1、解析多重定義的全局符号

連結器的輸入是一組可重定位目标子產品。每個子產品定義一組符号,有些是局部的(隻對定義該符号的子產品可見),有些是全局的(對其它子產品也可見)。如果多個子產品定義同名的全局符号,連接配接器會進行篩選。

在編譯時,編譯器向彙編輸出每個全局符号,或者是強或者是弱,而彙編器把這個資訊隐含地編碼在可重定位目标檔案的符号表中。函數和已初始化的全局變量是強符号,未初始化的全局變量是弱符号。

根據強弱符号的定義,Linux連結器使用下面的規則來處理多重定義的符号名:

  1. 不允許有多個同名的強符号
  2. 如果有一個強符号和多個弱符号同名,那麼選擇強符号
  3. 如果有多個弱符号同名,那麼從這些弱符号中任意選擇一個

規則2和規則3的應用會造成一些不易察覺的運作時錯誤,對于不警覺的程式員來說,是很難了解的,尤其是如果重複的符号定義還有不同的類型時。如下,x在一個子產品中為int,而在另一個子產品中為double:

foo.c 檔案中

void f(void);

int y = 15212;
int x = 15213;

int main()
{
	f();
	printf("x=0x%x y = 0x%x \n", x,y);
	return 0;
}
           

bar.c 檔案中

double x;

void f()
{
	x = -0.0;
}
           

在64位系統上,double類型是8個位元組,int類型是4個位元組。x的位址是0x601220,y的位址是0x601024。是以,bar.c檔案中的 x = -0.0 的指派會覆寫記憶體中x和y的位置。

這是一個細微的錯誤,連結器隻會發出一條警告,但是通常要在程式執行很久以後才表現出來,且遠離錯誤發生地,這種錯誤難以修正。

當你懷疑有此類錯誤時,用GCC -fno-common标志這樣的選項調用連結器,這樣連結器就會在遇到多重定義的全局符号時,觸發一個錯誤。

1.2、與靜态庫連結

迄今為止,我們都是假設連結器讀取一組可重定位目标檔案,并把它們連結起來,形成一個輸出的可執行檔案。實際上,所有的編譯系統都提供一種機制,将所有相關的目标子產品打包成一個單獨的檔案,稱為靜态庫。它可以用作連結器的輸入,當連結器構造一個輸出的可執行檔案時,它隻複制靜态庫裡被應用程式引用的目标子產品。

在Linux系統中,靜态庫以一種稱為存檔的特殊檔案格式存放在磁盤中。存檔檔案是一組連接配接起來的可重定位目标檔案的集合,有一個頭部用來描述每個成員目标檔案的大小和位置。存檔檔案名由字尾 .a 辨別。

1.3、連結器如何使用靜态庫來解析引用

雖然靜态庫很有用,但是它們同時也是一個程式員迷惑的源頭,原因在于Linux連結器使用它們解析外部引用的方式。在符号解析階段,連結器從左到右按照它們在編譯器驅動程式指令行上出現的順序來掃描可重定位目标檔案和存檔檔案。在這次掃描中,連結器維護一個可重定位目标檔案的集合E(這個集合中的檔案會被合并成可執行檔案),一個未解析的符号(即引用了但是尚未定義的符号)集合U,以及一個在前面輸入檔案中已定義的符号集合D。初始時,E、U和D均為空。

  • 對于指令行上的每個輸入檔案 f ,連結器會判斷f是一個目标檔案還是一個存檔檔案。如果 f 是一個目标檔案,那麼連結器把 f 添加到 E,修改U和D來反映 f 中的符号定義和引用,并繼續下一個輸入檔案。
  • 如果 f 是一個存檔檔案,那麼連結器就嘗試比對U中未解析的符号和由存檔檔案成員定義的符号。如果某個存檔檔案成員m,定義了一個符号來解析U中的一個引用,那麼就将 m 加到E中,并且連結器修改U和D來反映 m 中的符号定義和引用。對存檔檔案中所有的成員目标檔案都一次進行這個操作,直到U和D都不再發生變化。此時,任何不包含在E中的成員目标檔案都簡單地被丢棄,而連結器将繼續處理下一個輸入檔案。
  • 如果當連結器完成對指令行上輸入檔案的掃描後,U是非空的,那麼連結器就會輸出一個錯誤并終止。否則,它會合并和重定位E中的目标檔案,建構輸出的可執行檔案。

不幸的是,這種算法會導緻一些令人困擾的連接配接時錯誤,因為指令行上的庫和目标檔案的順序非常重要。在指令行中,如果定義一個符号的庫出現在引用這個符号的目标檔案之前,那麼引用就不能被解析,連結會失敗。

2、重定位

連結器完成了符号解析這一步,其實就給所有引用的符号找到了相應的定義。連結器就知道它輸入目标子產品中的代碼節和資料節的确切大小。現在就可以開始重定位步驟了,在這個步驟中,将合并輸入子產品,并為每個符号配置設定運作時位址。重定位由兩步組成:

1、重定位節和符号定義:在這一步中,連結器将所有相同類型的節合并為同一類型的新的聚合節。例如,來自所有輸入子產品的 .data 節被全部合并成一個節,這個節稱為輸出的可執行目标檔案的 .data節。然後,連結器将運作時記憶體位址賦給新的聚合節,賦給輸入子產品定義的每個節,以及付給輸入子產品定義的每個符号。當這一步完成時,程式中的每條指令和全局變量都有唯一的運作時記憶體位址了。

2、重定位節中的符号引用:在這一步中,連結器修改代碼節和資料節中對每個符号的引用,使得它們指向正确的運作時位址。要執行這一步,連結器依賴于可重定位目标子產品中稱為 重定位條目 的資料結構。

2.1、重定位條目

當彙編器生成一個目标子產品時,它并不知道資料和代碼最終将放在記憶體中的什麼位置。它也不知道這個子產品引用的任何外部定義的函數或者全局變量的位置。是以,無論何時彙編器遇到對最終位置未知的目标引用,他就會生成一個 重定位條目。 用來告訴連結器在目标檔案合并成可執行檔案時如何修改這個引用。代碼的重定位條目放在.real.text中。已初始化資料的重定位條目放在.real.data中。

下面的結構體展示了ELF重定位條目的格式。offset是需要被修改的引用的節偏移。symbol辨別被修改引用應該指向的符号。type告知連結器如何修改新的引用。addend是一個有符号常數,一些類型的重定位要使用它對被修改引用的值做偏移調整。

typedef struct
{
	long offset;
	long type:32,
			symbol:32;
	long addend;
}Elf64_Rela
           

ELF定義了32種不同的重定位類型,有些相當隐秘。我們隻關心其中的兩種最基本的重定位類型:

  • R_X86_64_PC32。重定位一個使用32位 PC 相對位址的引用。一個PC相對位址就是距程式計數器(PC)的目前運作時值的偏移量。當CPU執行一條使用PC相對尋址的指令時,他就将在指令中編碼的32位值加上PC的目前運作時值,得到有效位址,PC值通常是下一條指令在記憶體中的位址。
  • R_X86_64_32。重定位一個使用32位絕對位址的引用。通常絕對尋址,CPU直接使用在指令中編碼的32位值作為有效位址,不需要進一步修改。

3、可執行目标檔案

我們已經看到連結器如何将多個目标檔案合并成一個可執行目标檔案。可執行目标檔案是一個二進制檔案,這個二進制檔案包含加載程式到記憶體并運作它所需要的所有資訊。下圖概括了一個典型的ELF可執行檔案中的各類資訊:

深入了解計算機系統:連結(第二章:符号解析、重定位和可執行目标檔案)1、符号解析2、重定位3、可執行目标檔案4、加載可執行目标檔案到記憶體

可執行目标檔案的格式類似于可重定位目标檔案的格式。ELF頭描述檔案的總體格式。它還包括程式的入口點,也就是當程式運作時要執行的第一條指令的位址。.text、.rodata和.data節與可重定位目标檔案中的節是相似的,除了這些節已經被重定位到它們最終的運作時記憶體位址以外。.init節定義了一個小函數,叫做_init,程式初始化代碼會調用它。因為可執行檔案是完全連結的,是以它不在需要.rel節。

ELF可執行檔案被設計得很容易加載到記憶體,可執行檔案的連續的片被映射到連續的記憶體段。程式頭部表描述了這種映射關系。

4、加載可執行目标檔案到記憶體

要運作科執行目标檔案,需要先調用作業系統中的 加載器 。加載器将可執行目标檔案中的代碼和資料從磁盤複制到記憶體中,然後通過跳轉到程式的第一條指令或入口點來運作該程式。這個将程式複制到記憶體中的過程叫做加載。

每個Linux程式都有一個運作時記憶體映射,如下圖所示:

深入了解計算機系統:連結(第二章:符号解析、重定位和可執行目标檔案)1、符号解析2、重定位3、可執行目标檔案4、加載可執行目标檔案到記憶體

在Linux x86-64系統中,代碼段總是從位址0x400000處開始,後面是資料段,在之後是運作時堆,通過調用malloc庫往上增長。堆之後的區域是為共享子產品保留的。使用者棧總是從最大的合法使用者位址( 2 48 2^{48} 248-1)開始,向較小記憶體位址增長。棧上的區域是為核心中的代碼和資料保留的,所謂核心就是作業系統駐留在記憶體的部分。

為了簡潔,我們把堆、資料和代碼段畫的彼此相鄰,并且把棧頂放在了最大的合法使用者處。實際上,由于.data段有對其要求,是以代碼段和資料段之間是有間隙的。

當加載器運作時,它建立上圖的記憶體映射。在程式頭部表的引導下,加載器将可執行檔案的片複制到代碼段和資料段。接下來,加載器跳轉到程式的入口點,也就是_start函數的位址。這個函數是在系統目标檔案ctrl.o中定義的,對所有的C程式都是一樣的。_start函數調用系統啟動函數__libc_start_main,該函數定義在libc.so中。它初始化執行環境,調用使用者層的main函數,處理main函數的傳回值,并且在需要的時候把控制傳回給核心。

感謝大家,我是假裝很努力的YoungYangD(小羊)。

參考資料:

《深入了解計算機系統》

繼續閱讀