define 鏈接 (linking)
是將各種代碼和數據部分收集起來並組合成爲一個單一文件的過程,這 個文件可被加載(或被拷貝)到存儲器並執行。鏈接可以執行於編譯時 (compile time), 也就 是在源代碼被翻譯成機器代碼時;也可以執行於加載時 (load time) , 也就是在程序被加載器 (loader) 加載到存儲器並執行時;甚至執行於運行時 (run time), 由應用程序來執行。在早期 的計算機系統中,鏈接是手動執行的。在現代系統中,鏈接是由叫做鏈接器 (linker) 的程序自 動執行的。
鏈接器在軟件開發中扮演着一個關鍵的角色,因爲它們使得分離編譯 (separate compilation) 成爲可能。我們不用將一個大型的應用程序組織爲一個巨大的源文件,而是可以把它分解爲更 小、更好管理的模塊,可以獨立地修改和編譯這些模塊。當我們改變這些模塊中的一個時,只需 簡單地重新編譯它,並重新鏈接應用,而不必重新編譯其他文件。
- 學習鏈接之必要
- ·理解鏈接器將幫助你構造大型程序。
- 理解鏈接器將幫助你避免一些危險的編程錯誤。
- 理解鏈接將幫助你理解語言的作用域規則是如何實現的。
- 理解鏈接將幫助你理解其他重要的系統概念。
- 理解鏈接將使你能夠利用共享庫。
(美名曰總結,實爲抄書,各位看官請勿責怪)
編譯過程
- 舉例:
- 兩段程序:
/*main.c*/
void swap();
int buf[2] = {1, 2};
int main() {
swap();
return 0;
}
/*swap.c*/
extern int buf [] ;
int *bufp0 = &buf[0] ;
int *bufp1;
void swap() {
int temp;
bufp1 = &buf[1];
temp =*bufp0;
*bufp0 = *bufp1;
*bufp1 = temp;
}
大多數編譯系統提供編譯驅動程序 (compiler driver), 它代表用戶在需要時調用語言預處理 器、編譯器、彙編器和鏈接器。比如,要用 GNU 編譯系統構造示例程序,我們就要通過在外殼 中輸入下列命令行來調用 GCC 驅動程序:
unix> gee -02 -g -op main.e swap.e
圖 7-2 概括了驅動程序在將示例程序從 ASCII 碼源文件翻譯成可執行目標文件時的行爲。 (如果你想看看這些步驟,用 -v 選項來運行 GCC。)
- 驅動程序首先運行 C 預處理器 (cpp), 它 將 C 源程序 main.c 翻譯成一個 ASCII 碼的中間文件 main.i:
- 接下來,驅動程序運行 C 編譯器 (ccl), 它將 main.i 翻譯成一個 ASCII 彙編語言文件main.s 。
- 然後,驅動程序運行彙編器 (as), 它將 main.s 翻譯成一個可重定位目標文件 (relocatable
object file) main. o - 驅動程序經過相同的過程生成 swap.o。最後,它運行鏈接器程序 ld, 將 main.o 和
swap.o 以及一些必要的系統目標文件組合起來,創建一個可執行目標文件 (executable object
- 要運行可執行文件 p, 我們在 Unix 外殼 的命令行上輸入它的名字:
unix> ./p main.c.
翻譯器
外殼調用操作系統中一個叫做加載器
的函 數,它拷貝可執行文件 p 中的代碼和數據到存 儲器,然後將控制轉移到這個程序的開頭。
鏈接器功能
- 像 Unix ld 程序這樣的靜態鏈接器 (staticlinker) 以一組可重定位目標文件和命令行參數作爲輸人,生成一個完全鏈接的可以加載和運行的可執行目標文件作爲輸出。
- 輸入的可重定位目標文件由各種不同的代碼和數據節 (section) 組成:指令在一個節中,初始化的全局變量 在另一個節中,而未初始化的變量又在另外一個節中。
- 注意:目標文件純粹是字節塊的集合。這些塊中,有些包含程序代碼,有些則包含程序數據,而 其他的則包含指導鏈接器和加載器的數據結構。
- 鏈接器將這些塊連接起來,確定被連接塊的運行 時位置,並且修改代碼和數據塊中的各種位置。鏈接器對目標機器瞭解甚少。產生目標文件的編 譯器和彙編器已經完成了大部分工作。
爲了構造可執行文件,鏈接器必須完成兩個主要任務:
- 符號解析 (symbol resolution):目標文件定義和引用符號。符號解析的目的是將每個符號 引用剛好和一個符號定義聯繫起來。
- 重定位 (relocation):編譯器和彙編器生成從地址 0 開始的代碼和數據節。鏈接器通過把 每個符號定義與一個存儲器位置聯繫起來,然後修改所有對這些符號的引用,使得它們指 向這個存儲器位置,從而重定位這些節。
目標文件
目標文件有三種形式:
- 可重定位目標文件.o file:包含二進制代碼和數據,其形式可以在編譯時與其他可重定位目標文 件合併起來,創建一個可執行目標文件。
- 可執行目標文件a.out file:包含二進制代碼和數據,其形式可以被直接拷貝到存儲器並執行。
- 共享目標文件.so file:一種特殊類型的可重定位目標文件,可以在加載或者運行時被動態地加載 到存儲器並鏈接。
編譯器和彙編器生成可重定位目標文件(包括共享目標文件)。鏈接器生成可執行目標文件。
可執行可連接的目標文件ELF(統一名稱)
現代 Unix 系統(如 Linux, 還有 System V Unix 後來的版本,各種 BSD Unix, 以及 Sun Solaris) 使用的是 Unix 可執行和可鏈接 格式 (Executable and Linkable Format, ELF)。不管格式是哪種,都是類似。
- 一個典型的 ELF 可重定位目標文件如下:
- ELF 頭 (ELF header):以一個 16 字節的序列開始,這個序列描述了生成該文件的系統的字的大小和字節順序
- ELF 頭剩下的部 分包含幫助鏈接器語法分析和解釋目標文件的信息。其中包括 ELF 頭的大小、目標文件的類型 (如可重定位、可執行或者是共享的)、機器類型(如 IA32)、節頭部表 (section header table) 的 文件偏移,以及節頭部表中的條目大小和數量。不同節的位置和大小是由節頭部表描述的,其中 目標文件中每個節都有一個固定大小的條目 (entry) 。
- 夾在 ELF 頭和節頭部表之間的都是節:
- text: 已編譯程序的機器代碼
- rodata: 只讀數據,比如 printf 語句中的格式串 和開關語句的跳轉表
- data: 已初始化的全局 C 變量。局部 C 變量在運 行時保存在棧中,既不出現在 .data 節中,也不出現在 .bss 節中。
- bss: 未初始化的全局 C 變量。在目標文件中這個節 不佔據實際的空間,它僅僅是一個佔位符。 目標文件格式 區分初始化和未初始化變量是爲了空間效率:在目標文件 中,未初始化變量不需要佔據任何實際的磁盤空間。
- . symtab: 一個符號表,它存放在程序中定義和引用的函數和全局變量的信息。一些程序員錯誤地認爲必須通 過 -g 選項來編譯程序才能得到符號表信息。實際上,每個可重定位目標文件在 . symtab 中都 有一張符號表。然而,和編譯器中的符號表不同, symtab 符號表不包含局部變量的條目。 .
- rel . text: 一個 .text 節中位置的列表,當鏈接器把這個目標文件和其他文件結合時, 需要修改這些位置。一般而言,任何調用外部函數或者引用全局變量的指令都需要修改。另一方 面,調用本地函數的指令則不需要修改。注意,可執行目標文件中並不需要重定位信息, 因此通 常省略,除非用戶顯式地指示鏈接器包含這些信息。
- .rel.data: 被模塊引用或定義的任何全局變量的重定位信息。一般而言,任何已初始化 的全局變量,如果它的初始值是一個全局變量地址或者外部定義函數的地址,都需要被修改。 .debug: 一個調試符號表,其條目是程序中定義的局部變量和類型定義,程序中定義和引 用的全局變量,以及原始的 C 源文件。只有以 -g 選項調用編譯驅動程序時纔會得到這張表。 .line: 原始 C 源程序中的行號和 .text 節中機器指令之間的映射。只有以 -g 選項調用 編譯驅動程序時纔會得到這張表。 .strtab: 一個字符串表,其內容包括 .symtab 和 .debug 節中的符號表,以及節頭部中 的節名字。字符串表就是以 null 結尾的字符串序列。
符號和符號表
符號表是由彙編器構造的,使用編譯器輸出到彙編語言 .s 文件中的符號。. symtab 節中包含 ELF 符號表。這張符號表包含一個條目格式形如:
typedef struct {
int name;//是字符串表中的字節偏移,指向符號的以 null 結尾的字符串名字
int value;//符號的地址,對於可重定位模塊value是距定義目標的節的起始位置的偏移,對於可執行目標文件來說,該值是一個絕對運行時地址
int size;//size 是目標的大小,字節爲單位
char type:4,//type 通常要麼 是數據,要麼是函數
binding:4;//binding 字段表示符號是本地的還是全局的
char reserved;
char section;//每個符號都和目標文件的某個節相關聯,由 section 字段表示
} Elf_Symbol;
//符號表還可以包含各個節的條目,以及對應原始源文件的路徑名的條目。 所以這些目標的類型也有所不同。
用GNUREADELF工具分析ELF文件
- 參數
參數 | 屬性 |
---|---|
-a | all |
-h | 顯示a.out的ELF Header的文件頭信息。 |
-l | 顯示a.out的Program Header Table中的每個Prgram Header Entry的信息(如果有)頭表信息 |
-S | 顯示a.out的Section Header Table中的每個Section Header Entry的信息(如果有)節信息 |
- 使用案例:
linux> readelf -h test.o
linux> readelf -l test.o
linux> readelf -S swap.o
- 具體使用請移步:here
每個可重定位目標模塊 m 都有一個符號表,它包含 m 所定義和引用的符號的信息。在鏈接 器的上下文中,有三種不同的符號:
-
由 m 定義並能被其他模塊引用的全局符號:全局鏈接器符號對應於非靜態的 C 函數以及被 定義爲不帶 C static 屬性的全局變量。
-
由其他模塊定義並被模塊 m 引用的全局符號:這些符號稱爲外部符號 (external), 對應定義在其他模塊中的 C 函數和變量。
-
只被模塊 m 定義和引用的本地符號:有的本地鏈接器符號對應於帶 static屬性的 C 函 數和全局變量。這些符號在模塊 m 中隨處可見,但是不能被其他模塊引用。目標文件中對 應於模塊 m 的節和相應的源文件的名字也能獲得本地符號。
-
本地鏈接器符號和本地程序變量不同, .symtab 中的符號表不包含對應於本地非靜態程序變量的任何符號。這些符號在運行時在棧中被管理,鏈接器對此類符號不感 興趣。定義爲帶有 C static 屬性的本地過程變量是不在棧中管理的。相反,編譯器在 .data 和 .bss 中爲每個定義分配空間,並在符號表中創建一個有唯一名字的本地鏈接器符
鏈接器任務一,符號解析
鏈接器解析符號引用的方法是將每個引用與它輸入的可重定位目標文件的符號表中的一個確 定的符號定義聯繫起來。
1.定義在相同模塊中的本地符號的引用:
符號解析是非常簡單明瞭,編譯器只允許每個模塊中每個本地符號只有一個定義。編譯器還確保靜態本地變量, 它們也會有本地鏈接器符號,擁有唯一的名字。
2.對全局符號的引用解析
當編譯器遇到一個不是在當前模塊中定義的符號 (變量或函數名)時,它會假設該符號是在其他某個模塊中定義的,生成一個鏈接器符號表條目, 並把它交給鏈接器處理。
(1)如果鏈接器在它的任何輸入模塊中都找不到這個被引用的符號,它就輸 出一條錯誤信息並終止。
(2)如果多個目標文件可能會定義相同的符號。在這種情況中, 鏈接器必須要麼標誌一個錯誤,要麼以某種方法選出一個定義並拋棄其他定義。 對於多重定義的處理規則:
- Rule 1: 不允許有多個強符號:強符號只能被定義一次否則鏈接器產生錯誤信息
- Rule 2: 若有一個強符號和多個弱符號,則選擇強符號 (此時引用弱符號會導致解析強符號)
- Rule 3: 若有多個弱符號,則從這些弱符號中任意選擇一個(使用gcc –fno-common 選項調用鏈接器,在遇到多重定義的全局符號時,會有警告信息)舉例如下:
編程習慣
C 程序員使用 static 屬性在模塊內部隱藏變量和函數聲明。任何聲明帶有 static 屬性的全局變 量或者函數都是模塊私有的。類似地,任何聲明爲不帶 static 屬性的全局變量和函數都是公 共的,可以被其他模塊訪問。儘可能用 static 屬性來保護你的變量和函數是很好的編程習慣
- 若可以要儘量避免使用全局變量
- 否則
- 儘可能使用static
- 定義全局變量時要初始化
- 如果要使用外部全局變量需使用extern
鏈接器任務二,重定位
一旦鏈接器完成了符號解析這一步,它就把代碼中的每個符號引用和確定的一個符號定義 (即它的一個輸入目標模塊中的一個符號表條目)聯繫起來。在此時,鏈接器就知道它的輸入目 標模塊中的代碼節和數據節的確切大小。現在就可以開始重定位了,在這個步驟中,將合併輸入 模塊,併爲每個符號分配運行時地址。重定位由兩步組成:
- 重定位節和符號定義。鏈接器將所有相同類型的節合併爲同一類型的新的聚合節。這一步完成,程序中的每個指 令和全局變量都有唯一的運行時存儲器地址。
- 重定位節中的符號引用。在這一步中,鏈接器修改代碼節和數據節中對每個符號的引用,使得它們指向正確的運行時地址。爲了執行這一步,鏈接器依賴於稱爲重定位條目 (relocation entry) 的可重定位目標模塊中的數據結構。以下我們將重點介紹重定位符號引用:
1.可重定位目標模塊中的數據結構——重定位條目
-
當彙編器生成一個目標模塊時,它並不知道數據和代碼最終將存放在存儲器中的什麼位置。 它也不知道這個模塊引用的任何外部定義的函數或者全局變量的位置。所以,無論何時彙編器遇 到對最終位置未知的目標引用,它就會生成一個重定位條目,告訴鏈接器在將目標文件合併成可 執行文件時如何修改這個引用。
-
代碼的重定位條目放在 .rel.text 中。已初始化數據的重定位條目放在 .rel.data中。
-
數據結構的格式如下:
typedef struct {
int offset;
int symbol:24,
type:8; ·
}Elf32_Rel;
- 同樣可以通過READELF工具查看:
ELF 定義了 11 種不同的重定位類型,有些相當隱祕。我們只關心其中兩種最基本的重定位
類型:
- R_386_PC32: 重定位一個使用 32 位 PC 相對地址的引用。一個PC 相 對地址就是距程序計數器 (PC) 的當前運行時值的偏移量。當 CPU執行一條使用 PC 相對 尋址的指令時,它就將在指令中編碼的 32 位值上加上PC 的當前運行時值,得到有效地址 (如 call 指令的目標), PC 值通常是存儲器中下一條指令的地址。
- R_386_32: 重定位一個使用 32 位絕對地址的引用。通過絕對尋址, CPU 直接使用在指 令中編碼的 32 位值作爲有效地址,不需要進一步修改
2.重定位符號引用算法僞碼
假設鏈接器已經爲每個節(用 ADDR (s) 表示)和每個符號都選擇了運行時地址(用 ADDR(r. symbol) 表示)
foreach sections {
foreach relocation entry r {
refptr = s + r. offset; /* ptr to reference to be relocated */
/* Relocate a PC-relative reference */
if (r. type ==R386_PC32) {
refaddr = ADDR(s) + r. offset; /* ref's run-time address */
*refptr = (unsigned) (ADDR(r.symbol) + *refptr - refaddr);
}
/* Relocate an absolute reference */
if (r. type ==R_386_32)
*refptr = (unsigned) (ADDR(r.symbol) + *refptr);
}
}
3.重定位 PC 相對引用
- 重定位前:
(1)在這個列表中看到指令開始於節偏移0x12處,由 1 個字節的操作碼 0xe8 和隨後的 32 位引用 0xfffffffc (十進制 -4) 組成,它是以小端法字節順序存儲的。下一行顯示的是這個引用的重定位條目。(重定位條目和指令實際上是存放在目標文 件的不同節中的)重定位條目 r 由 3 個字段組成:
r.offset = 0x12
r.symbol = swap
r.type =R386_PC32
(2)這些字段告訴鏈接器修改開始於偏移量 0x12處的 32 位 PC 相對引用,使得在運行時它指向swap 程序。現在,假設鏈接器已經確定:
ADDR(s) = ADDR(. text) = Ox8048380 //節起始地址
ADDR(r.symbol) = ADDR(swap) = Ox80483b0 //運行時地址
(3)鏈接器首先計算出引用的運行時地址:
refaddr = ADDR(s)+ r. offset
= 0x8048380 + 0x12 = Ox8048392
(3)按照算法可以計算重定位call的偏移量:
*refptr = (unsigned) (ADDR(r.symbol) +*refptr - refaddr) = (unsigned) (Ox80483b0+ (-4) - Ox8048392) = (unsigned) (Ox9)
- 重定位後:
*運行時: PC+ refptr = 0x8048396 + 0x1a = 0x80483b0
4.重定位絕對引用
- 使用objdump -D查看swap.o可重定向文件,使用readelf -a swap.o 查看重定位條目得到:
重定位條目告訴鏈接器
這是一個 32 位絕對引用,開始於偏移 0 處,必須重定位使得它指向符號 buf。現在,假設鏈接 器已經確定: - ADDR(r.symbol) = ADDR(buf) = 0x8049620
- *refptr = (unsigned) (ADDR(r.symbol) + *refptr) = (unsigned) (0x8049620+0) = (unsigned) (0x8049620)
- 使用objdump -D run查看反彙編:
結果:可執行目標文件
可執行目標文件的格式類似於可重定位目標文件的格式。
- ELF 頭部描述文件的總體格式。它 還包括程序的入口點 (entry point), 也就是當程序運行時要執行的第一條指令的地址。
- .tex七、 .rod扛a 和 .data 節和可重定位目標文件中的節是相似的,除了這些節已經被重定位到它們最 終的運行時存儲器地址以外。
- .init 節定義了一個小函數,叫做 _init, 程序的初始化代碼會 調用它。
- 因爲可執行文件是完全鏈接的(已被重定位了),所以它不再需要 .rel 節。
加載可執行目標文件
- 運行文件:
unix> . /p
- 因爲 p 不是一個內置的外殼命令,所以外殼會認爲 p 是一個可執行目標文件
- 通過調用某個駐 留在存儲器中稱爲加載器 (loader) 的操作系統代碼來運行它。
- 任何 Unix 程序都可以通過調 用 execve 函數來調用加載器。
- 加載器將可執行目標 文件中的代碼和數據從磁盤拷貝到存儲器中,然後通過跳轉到程序的第一條指令或入口點 (entry point) 來運行該程序。這個將程序拷貝到存儲器並運行的過程叫做加載 (loading)
加載過程
- 加載器將可執行文件的相關內容拷貝到代碼和數據段。
- 接下來,加載 器跳轉到程序的入口點,也就是符號 _start 的地址。在 _start 地 址處的啓動代碼 (startup code) 是在 目標文件 ctrl . o 中定義的,對所 有的 C 程序都是一樣的。
- 圖展 示了啓動代碼中具體的調用序列。在 從.text 和 .init 節中調用了初 始化例程後,啓動代碼調用 atexit 例程
- 這個程序附加了一系列在應 用程序正常中止時應該調用的程序。
- exit 函數運行 atexit 註冊的函 數,然後通過調用 _exit 將控制返 回給操作系統。
- 接着,啓動代碼調用 應用程序的 main 程序,它會開始執 行我們的 C 代碼。在應用程序返回之後啓動代碼調用 _exit 程序,它將控制返回給操作系統。