鏈接器 基礎

 

有時能學到知識,卻學不到工夫。-- 鍾雲龍


Basic: https://blog.csdn.net/qq_35865125/article/details/105214201


總覽

在編譯系統中,鏈接器扮演類似“膠水”的角色。它把彙編器處理生成的 可重定位目標文件 黏合、拼接爲一個可執行的ELF文件。然而,鏈接器並非機械地拼接目標文件,它還需要完成彙編階段無法完成的 段地址分配、符號地址計算 以及 數據/指令內容修正的工作。

這三個主要任務涉及了鏈接器工作的核心流程:地址空間分配、符號解析 和 重定位。


在可重定位目標文件的section header table的各個表項中,段的虛擬地址都是默認設爲0。這是因爲在彙編器處理階段,是不可能知道段的加載地址的。鏈接器的地址空間分配操作的主要目的是爲段指定加載地址(即確定目標文件中的各個section放到可執行文件內的哪個位置)。

 

在確定了section的加載地址(簡稱段基址)後,根據目標文件內符號的section內偏移地址,可以計算得到符號在可執行文件內的地址(簡稱符號地址, 例如定義的函數的地址)。      鏈接器的符號解析操作並不止於計算符號地址,它還需要分析目標文件之間的符號引用的情況,計算目標文件內引用的外部符號的地址。

 

符號解析之後,所有目標文件的符號地址(e.g:在可執行文件內的地址)都已經確定。鏈接器通過重定位操作,修正代碼段或數據段內引用的符號地址 (eg.代碼段有call printf, 需要將printf修改成該函數的地址)

 

最後,鏈接器將以上操作處理後的文件信息導出爲可執行ELF文件,完成鏈接的工作。

 

信息收集

對 鏈接器 來說, 其 輸入是一 系列 的 可重定 位 目標 文件。 鏈接 器 欲 完成 後續 的 工作, 必須 逐個 掃描 目標 文件, 提取 需要 的 信息 進行 處理。

鏈接器需要分析目標文件內符號的引用情況。 之所以要分析 符號 的 引用 信息, 是因爲 在 鏈接 器 處理 的某個目標 文件 中, 存在未定義 的符號, 即對其他目標文件符號的引用。   爲了 方便 鏈接 器 符號 解析 的 處理, 一般 會 定義 兩個 符號 集合: 一個 是 導出 符號 集合, 表示 所有 目標 文件 內 定義 的 可以 被 其他 目標 引用 的 全局 符號 集合; 另一個 是 導入符號 集合, 表示 目標文件自己內部未定義, 需要引用 其他目標文件的符號集合。

 

地址空間分配

彙編器生成目標文件時,由於無法確定段的加載地址,因此默認將段基址記爲0。鏈接器的第一步工作便是確定需要加載段的段基址,爲待加載段指定段基址的過程稱爲地址空間分配。

鏈接器爲段指定基址,需要從三個方面進行考慮。

1)段加載的起始地址。

      該地址是所有加載段的起始位置,在32位Linux系統中,一般設置爲0x08048000。

2)段的拼接順序。

     鏈接器按序掃描各個目標文件內同名的段,並將段的二進制數據依次“擺放”。

3)段對齊方式。

      段對齊包含兩個層面:段文件偏移的對齊和段基址的對齊。

在可重定位的目標文件內,一般將段的文件偏移對齊設置爲4字節,不考慮段基址的對齊(段基址都是0,沒有對齊的意義)。

而在可執行文件內,會將代碼段“.text”的文件偏移對齊設置爲16字節,其他段的文件偏移對齊方式仍默認爲4字節。     而段基址的對齊則比較複雜,需要保證段的線性地址與段對應文件偏移相對於段對齊值(即頁面大小,Linux下默認爲4096字節)取模相等。

(Program header table內的段對齊字段p_align:  p_ align 表示 段 對齊 方式, 對齊 規則 爲 p_ vaddr% p_ align= 0, 即 段 的 線性 地址 必須 是 p_ align 的 整 數倍。 一般 情況下, p_ align 取值 爲 0x1000= 4096, 即 Linux 操作系統 默認的頁大小)。

 

下圖給出了一個地址空間分配的例子。目標文件a.o的代碼段大小爲0x4a字節,數據段大小爲0x08字節,b.o的代碼段大小爲0x21字節,數據段大小爲0x04字節。

 

--可執行文件內不需要section header table,這個只在object文件內需要。

 

符號解析

目標文件符號表內保存了每個定義的符號相對於所在段基址的偏移,當段的地址空間分配結束後每個段的基址都被確定下來,因此符號地址可以使用如下公式計算:

符號地址 = 段基址 + 符號相對段基址的偏移

不過在計算符號地址之前,仍需要做一些準備工作。

首先需要掃描目標文件內的符號表,獲取符號的定義與引用的信息,即上文描述的導出符號集合和導入符號集合。

其次,需要對導入符號集合和導出符號集合進行合法性驗證。符號驗證包含兩個方面:

1)符號重定義:即導出符號集合存在同名的符號。由於目標文件鏈接時,對符號的處理是按名檢索的方式,符號重定義將導致引用該符號的文件無法確定應該具體使用哪個符號。

2)符號未定義:即導入符號集合包含導出集合不存在的符號。當目標文件引用的外部符號在其他目標文件內找不到對應的定義時,就無法確定符號的地址。一旦出現符號重定義或未定義的情況,鏈接器的工作就無法繼續進行。

 

Note:

目標 文件 和 可執行 文件 有一個 很大 的 區別: 目標 文件 的 文件 頭 的 程序 入口 點 e_ entry 字段 爲 0, 而可 執行 文件 的 程序 入口 點 是一 個 線性 地址。 我們 需要 先 假定 程序 的 入口 地址 被 記錄 到 一個 名爲“@ start” 的 符號 內, 顯然 這個 符號 不可能 是 編譯器 生成 的 符號 名。 爲了 保證 鏈接 器 可以 找到 程序 入口 點, 那麼 符號 引用 驗證 階段 必須 強制 要求 導出“@ start” 符號。 至於“@ start” 符號 的 提供者, 可以 暫時 認爲 來源於 一個 已有 的 目標 文件。

 

一般來說, 符號地址解析分爲兩個步驟:

1) 掃描所有 ELF 目標文件 的 本地 符號, 計算 本地 符號 的 地址。

2) 掃描 所有 導入集合的符號(即 某個文件需要使用其他目標文件定義的符號), 將 符號地址 傳遞 到 引用 該 符號 的 目標 文件 的 符號 表內。

 

重定位

 

(https://blog.csdn.net/qq_35865125/article/details/105214201

需要重定位的符號保存在各個目標文件內的 重定位表中呀, 對應名爲“. rel” 開頭 的 section內。 ELF 文件 需要 重定位 的 section, 一般 都對 應 一個 重定位 表。 比如 代碼section 即“. text”  sectioin的 重 定位 表 保存 在“. rel. text” section內, “. data” 的 重 定位 表 保存 在“. rel. data” 內)

 

目標文件的重定位信息包含三個關鍵的元素:

#重定位符號——使用哪個符號的地址進行重定位;--(各個目標文件內的 重定位表中呀)

#重定位位置——在何處進行重定位;(該信息同樣可以從目標文件的重定位表中獲取,表中保存了需要重定位的符號名,也保存了該符號屬於目標文件的哪個section,以及在這個section內的偏移, 當鏈接起完成地址空間分配後,目標文件內的這個section的地址也就確定了,因此根據偏移可以定位到該符號在可執行文件內的位置)。

#重定位類型——用何種方法進行重定位。

 

首先,由於重定位操作依賴於重定位符號的地址,因此在符號解析完成前是無法進行重定位的。

 

重定位類型有兩種:

絕對地址重定位 和 相對地址重定位。根據不同的重定位類型對段數據進行修正操作是重定位的核心。

1)絕對地址重定位操作比較簡單,需要絕對地址重定位的地方一般都是源於對符號地址的直接引用,由於彙編器不能確定符號的虛擬地址,最終使用0作爲佔位符填充了引用符號地址的地方。因此,絕對地址重定位操作只需要直接填寫重定位符號的虛擬地址到重定位位置即可。

絕對重定位地址=重定位符號地址

 

2)相對地址重定位稍微複雜一點,需要相對地址重定位的地方一般都是源於跳轉類指令引用了其他文件的符號地址

雖然彙編器不能確定被引用符號的虛擬地址,但是並不使用0作爲佔位符填充引用符號地址的地方,而是使用“重定位位置相對於下一條指令地址的偏移”填充該位置。鏈接器進行相對地址重定位操作時,會計算符號地址相對於重定位位置的偏移,然後將該偏移量累加到重定位位置保存的內容。

相對重定位地址= 重定位符號地址–重定位位置+重定位位置數據內容

                           =(重定位符號地址–重定位位置)+(重定位位置–下一條指令地址)

                           = 重定位符號地址–下一條指令地址

根據上面的計算,可以很清晰地看出最終計算的相對重定位地址正是符號地址相對於下一條指令地址的偏移,也正符合跳轉類指令對操作數的要求。至於爲何對相對地址重定位進行如此“繁瑣”的計算,筆者認爲按照這樣的方式,對於不同長度和設計結構的指令,只要重定位位置的數據按照相對地址的方式進行修正,那麼相對重定位地址的計算方式不變,區別僅僅是重定位位置處的數據的值不同。比如對於Intel32位跳轉指令該位置數據值是–4,對於Intel64位跳轉指令該位置數據值是–8。

 

下面結合一個例子描述重定位的過程。

 

 

程序入口點與運行時庫

前面章節提到程序入口點地址被保存在一個名爲“@start”的特殊符號內,而定義該符號的目標文件並不是編譯器根據源代碼生成的。那麼這就有兩個問題需要弄清楚:

1)爲什麼引入新的符號而不是main函數作爲程序入口點?

2)定義新的符號的目標文件該如何得到?

首先解釋第一個問題。對於main函數生成的彙編代碼片段形式如下:

 

本質上講,main函數與普通的函數並沒有太大的區別:包含函數入棧代碼(第3~5行)、函數體代碼(第6行省略內容)和函數出棧代碼(第7~9行)。  假定使用main函數作爲程序入口點,即將main符號的線性地址寫入ELF文件頭部的e_entry字段,那麼程序加載運行後會從main符號的地址位置讀取指令開始執行。整個main函數執行過程不會出現任何問題,直到ret指令執行結束後。 根據ret指令的語義,程序會從棧頂取出32位的數據作爲返回地址,然後跳轉到該地址繼續執行!然而,程序執行main函數之前,棧頂保存的數據是未知的,因此導致程序的最終行爲無法預測,最常見後果是觸發進程“SegmentFault”。

因此,爲了讓程序可以“優雅”地退出,必須構造一個main函數的調用者完成函數調用後的“清理”工作。這也爲第二個問題提供瞭解決辦法。

在Linux的系統調用中,調用號爲1的系統調用是exit,使用exit可以使進程正常退出。 調用exit的彙編代碼如第6~8行所示,其中寄存器eax保存exit系統調用號1,ebx保存exit系統調用的參數0,int指令觸發exit系統調用退出進程。   符號“@start”處的代碼會調用main函數後使用exit系統調用退出進程,在調用main函數前後可以執行一些初始化工作(第3行省略內容)和清理工作(第5行省略的內容)。

如果編譯器將上述代碼保存在start.s,彙編器處理後,可以得到目標文件start.o。然後,使用readelf工具查看start.o的符號表:

 

從整個編譯系統的工作流程來看,start.o文件是編譯系統正常工作必需的目標文件。無論編譯系統處理的源代碼如何定義,在最終的鏈接階段必須將start.o和其他目標文件一起鏈接才能正常生成可執行文件。對於這樣的目標文件,有一個統一的名稱——“語言運行時庫”。顯然,start.o應該是最簡單的運行時庫了,它只負責引導調用main函數,別的什麼也沒做。

 

  ------大徹大悟-----------

根據類似的方式,可以很方便地擴展程序設計語言運行時庫的功能。

比如可以定義printf.s實現標準輸出函數printf,經過彙編器處理後生成printf.o目標文件。只需要源碼聲明使用了printf函數,在鏈接時將printf.o鏈接到可執行文件,即可在高級語言中實現標準輸出的功能。   更進一步地,可以直接定義math.c文件實現數學相關的函數,經過編譯器和彙編器處理後生成math.o目標文件,這樣高級語言就可以進行復雜的數學計算。

如果在編譯系統中實現了預處理器並支持include指令,那麼像printf函數或math.c實現的函數聲明語句就可以放入類似“stdio.h”或“math.h”這樣的頭文件內。  

如果鏈接器支持輸入壓縮包格式的文件,那麼像printf.o和math.o這樣的目標文件可以打包放在類似“libc.a”這樣的壓縮包(庫)中,鏈接器只需要在鏈接之前將壓縮包解壓即可。    編寫高級語言程序時,只要包含需要的頭文件,並在鏈接階段包含對應的庫文件,就可以使用更強大的語言特性。

 

 

相比 而言, GCC 的 C 語言 運行時 庫( C Runtime Library, CRT) 複雜得多。 回顧 第 1 章 例子:

靜態 鏈接 時, GCC 將 C 語言 運行時 庫( CRT) 內 的 5 個 重要的 目標 文件 crt1. o、 crti. o、 crtbeginT. o、 crtend. o、 crtn. o 以及 3 個 靜態 庫 libgcc. a、 libgcc_ eh. a、 libc. a 鏈接 到 可執行 文件 hello.

 

描述 GCC 靜態鏈接 工作 流程 時涉及的5個目標文件crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o,以及3個靜態庫libgcc.a、libgcc_eh.a、libc.a,這些文件的功能分別爲:

1)crt1.o:定義程序入口點“_start”、調用“.init”段的代碼執行程序的初始化、調用main函數、調用“.finit”段的代碼執行程序的清理操作。早期版本爲crt0.o,不支持“.init”和“.finit”段。

2)crti.o:定義“.init”段的函數入棧代碼、調用C++全局構造代碼。

3)crtn.o:定義“.finit”段的函數出棧代碼、調用C++全局析構代碼。

4)crtbeginT.o:定義C++全局構造代碼。

5)crtend.o:定義C++全局析構代碼。

6)libc.a:定義C語言標準庫代碼。--- 應該是gcc的現成的文件,安裝gcc時就帶的吧。要使用其中的函數需要在代碼中#include相應的頭文件,頭文件的作用僅僅是聲明函數的存在,在鏈接階段,鏈接器從libc.a中去獲取這些函數。嘿嘿! http://www.delorie.com/djgpp/doc/libc/libc_1.html

7)libgcc.a:定義由於平臺差異性的輔助函數代碼。

8)libgcc_eh.a:定義C++異常處理的平臺相關代碼。

由此可見,對於一種高級語言,除了編譯器、彙編器和鏈接器是必不可少的部分之外,語言的運行時庫也是不可或缺的一部分。功能豐富的運行時庫,可以讓高級語言的表達能力更加強大。

 

ELF文件生成

 

 


Ref:

範志東; 張瓊聲. 《自己動手構造編譯系統:編譯、彙編與鏈接》機械工業出版社.

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