本文簡單介紹了程序的鏈接原理。學習鏈接原理有助於程序員理解程序的本質,同時也可以為日后的大型軟件的代碼開發打下堅實的基礎。由此可知鏈接原理的重要性,尤其是一些程序員被一些莫名其妙的錯誤困擾的時候,更加能夠體會到這一點。
1 連接器的任務
連接器將多個目標文件鏈接成一個完整的、可加載、可執行的目標文件。其輸入是一組可重定位的目標文件。鏈接的兩個主要任務如下:
(1) 符號解析,將目標文件內的引用符號和該符號的定義聯系起來。
(2) 將符號定義與存儲器的位置聯系起來,修改對這些符號的引用。
2 目標文件
典型的目標文件分為以下3種形式:
(1) 可重定位目標文件
這種文件包含二進制代碼和數據,這些代碼和數據已經轉換成了機器指令代碼和數據,但是還不可以直接執行。因為這些指令和數據中往往引用其他模塊(目標文件)中的符號,這些其他模塊的符號對於本模塊來說是未知的,這些符號的解析需要鏈接器將所有模塊進行鏈接。這種操作稱為“重定位”,因此,這種目標文件被稱為“可重定位的目標文件”,后綴名通常為*.o
(2) 可執行目標文件
這種文件同樣包含了二進制代碼和數據。所不同的是,這種文件已經經過了鏈接操作,和所有的模塊(目標文件)都產生了聯系。鏈接器將所有需要的可重定位目標文件連接成一個可執行目標文件。這時,每個目標文件中引用其他目標文件中的符號都已經得到了解析和重定位。因此,每個符號都是已知的了,該文件可以被機器直接執行。
(3) 共享目標文件
這是一種特殊的可定位目標文件,可以在需要它的程序運行或加載時,動態地加載到內存中運行。這種文件的后綴名通常是*.so。共享目標文件通常又被稱為“動態庫”文件或者“共享庫”文件。
下面的示例演示了可重定位目標文件和可執行目標文件的產生。該程序使用兩個簡單的C語言源程序add.c和main.c文件,其中add.c中定義一個函數add(),實現兩個整數相加;main.c中定義了main函數,在該函數中調用add()函數。
//@file add.c //@brief sum 2 integers int add(int a, int b) { return (a+b); }
//@file main.c //@brief call add() from another file #include <stdio.h> #include <stdlib.h> extern int add(int,int); int main(int argc, char *argv[]) { int a, b; if (argc != 3) { printf("Usage: main a b\n"); exit(-1); } a = atoi(argv[1]); b = atoi(argv[2]); printf("Sum = %d\n", add(a, b)); return 0; }
那么,我們使用ld命令鏈接兩個文件,會提示以下錯誤,我個人覺得是因為代碼中使用到了<stdio>和<stdlib>庫中的函數,但是並沒有指定對應的目標文件導致。當然,我函數習慣直接使用gcc命令來連接這兩個文件,最終運行效果如下:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ld add.o main.o –o main ld: warning: cannot find entry symbol _start; defaulting to 0000000008048074 main.o: In function `main': main.c:(.text+0x17): undefined reference to `puts' main.c:(.text+0x23): undefined reference to `exit' main.c:(.text+0x33): undefined reference to `atoi' main.c:(.text+0x47): undefined reference to `atoi' main.c:(.text+0x6f): undefined reference to `printf' xiaomanon@xiaomanon-machine:~/Documents/c_code$ gcc add.o main.o -o main
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./main 12 19 Sum = 31
補充:關於ld的用法?
我們接下來就來解決上面使用ld命令鏈接可重定位目標文件時出錯的問題。提示信息中,第一個warningd的意思是沒有找到一個函數入口,我們可以使用ld命令的-e選項來指定:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ls add.c add.o main.c main.o xiaomanon@xiaomanon-machine:~/Documents/c_code$ ld -e main main.o
main.o: In function `main': main.c:(.text+0x29): undefined reference to `add'
這里,又有一個錯誤提示:沒有定義add,我們在其中添加對add.o的鏈接。
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ld -e main main.o add.o xiaomanon@xiaomanon-machine:~/Documents/c_code$ ls add.c add.o a.out main.c main.o
我們可以看到,最終生成了a.out文件,運行它:
xiaomanon@xiaomanon-machine:~/Documents/c_code$ ./a.out
Segmentation fault (core dumped)
結果出現了段錯誤,這是問什么呢?應該怎么解決?
3 ELF格式的可重定位目標文件
ELF(Excutable Linkable File)是Linux環境下最常用的目標文件格式,在大多數情況下,無論是可重定位的目標文件還是可執行的目標文件均可采用這種格式。ELF格式的目標文件中不僅包含了二進制的代碼和數據,還包括很多幫助鏈接器解析符號和解釋目標文件的信息。下圖展示了一個典型的ELF格式的可重定位目標文件的結構。
該目標文件主要由兩部分組成:ELF文件頭和目標文件的段。ELF文件頭的前16個字節構成了一個字節序,描述了生成該文件系統的字長以及字節序。剩下的部分包括了ELF文件的一些其他信息,其中包括ELF文件頭的大小、目標文件的類型、目標機的類型、段頭部表在目標文件內的文件偏移位置等。在鏈接和加載ELF格式的程序時,這些信息是很重要的。
除了ELF文件頭之外,剩下的部分由目標文件的段組成。這些段是ELF文件中的核心部分。由以下幾個段組成:
■ .text : 代碼段,存儲二進制的機器指令,這些指令可以被機器直接執行。
■ .rodata : 只讀數據段,存儲程序中使用的復雜常量,例如字符串等。
■ .data : 數據段,存儲程序中已經被明確初始化的全局數據。包括C語言中的全局變量和靜態變量。如果這些全局數據被初始化為0,則不存儲在數據段中,而是被存儲在塊存儲段中。C語言局部變量保存在棧上,不出現在數據段中。
■ .bss : 塊存儲段,存儲未被明確初始化的全局數據。 在目標文件中這個段並不占用實際的空間,而僅僅是一個占位符,以告知指定位置上應當預留全局數據的空間。塊存儲段存在的原因是為了提高磁盤上存儲空間的利用率。
注意:以上的4個段會在程序運行時加入到內存中,是實實在在的程序段。目標文件中還有一些輔助程序進程鏈接和加載的信息,這些信息並不加載到內存中。實際上,這些信息在生成最終的可執行目標文件時就已經被去掉了。
■ .symtab : 符號表,存儲定義和引用的函數和全局變量。每個可重定位的目標文件中都要有一個這樣的表。在該表中,所有引用的本模塊內的全局符號(包括函數和全局變量)以及其他模塊(目標文件)中的全局符號都會有一個登記。鏈接中的重定位操作就是將這些引用的全局符號的位置確定。
■ .rel.text : 代碼段需要重定位(relocate)的信息,存儲需要靠重定位操作修改位置的符號的匯總。這些符號在代碼段中,通常是一個函數名和標號。
■ .rel.data : 數據段需要重定位的信息,存儲需要靠重定位操作修改位置的符號的匯總。這些符號在數據段中,是一些全局變量。
■ .debug : 調試信息,存儲一個用於調試的符號表。在編譯程序時使用gcc編譯器的-g選項會生成該段,該表包括源程序中所有符號的引用和定義,有了這個段在使用gdb調試器對程序進行調試的時候才可以打印並觀察變量的值。
■ .line : 源程序的行號映射,存儲源程序中每一個語句的行號。在編譯程序時使用gcc編譯器的-g選項會生成該段,在使用gdb調試器對程序進行調試的時候這個段的作用很大。
■ .strtab : 字符串表,存儲.symtab符號表和.debug符號表中符號的名字,這些名字是一些字符串,並且以‘\0’結尾。
4 目標文件中的符號表
符號解析是鏈接的主要任務之一。只有在正確解析了符號之后才能夠更改引用符號的位置,從而完成重定位,生成一個可以被機器直接加載執行的可執行目標文件。每個可重定位目標文件都有一個符號表,在這個符號表中存儲符號,這些符號分為3類:
(1) 本模塊中引用的其他模塊所定義的全局符號
(2) 本模塊中定義的全局符號
(3) 本模塊中定義和引用的局部符號
注意:局部變量和局部符號不是一回事。局部變量存儲在棧中,是一個僅僅在內存中出現的概念;而局部符號包括靜態變量和局部標號,這些內容也可能出現在磁盤文件中。
下面代碼演示了在程序中使用局部符號。該程序聲明了一個靜態局部變量和一個局部變量,其中靜態局部變量是一個局部符號。
//@file cnt.c #include <stdio.h> void f(int i) { int static count = 10; int a = 0; count = i; count++; if (count >= 20) goto done; else{ printf("the count is lower than 20\n"); return; } done: printf("the count is higher than 20\n"); a = 20; printf("a is : %d\n", a); return; } int main(void) { int i; scanf("%d", &i); f(i); return 0; }
該程序中局部靜態變量count和標號done都是局部符號,會出現在目標文件的符號表中,而局部變量a存儲在棧上,因此不會出現在符號表中。
然后使用gcc –c cnt.c命令,編譯得到可重定位目標文件cnt.o,這樣我們就可以使用GNU的readelf工具查看可重定位目標文件內容,該工具可以讀物目標文件的符號表,從而得到每一個符號的信息。
xiaomanon@xiaomanon-machine:~/Documents/c_code$ readelf -a cnt.o ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Intel 80386 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 500 (bytes into file) Flags: 0x0 Size of this header: 52 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 40 (bytes) Number of section headers: 13 Section header string table index: 10 Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .text PROGBITS 00000000 000034 000095 00 AX 0 0 1 [ 2] .rel.text REL 00000000 000520 000068 08 11 1 4 [ 3] .data PROGBITS 00000000 0000cc 000004 00 WA 0 0 4 [ 4] .bss NOBITS 00000000 0000d0 000000 00 WA 0 0 1 [ 5] .rodata PROGBITS 00000000 0000d0 000045 00 A 0 0 1 [ 6] .comment PROGBITS 00000000 000115 000025 01 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 00000000 00013a 000000 00 0 0 1 [ 8] .eh_frame PROGBITS 00000000 00013c 000058 00 A 0 0 4 [ 9] .rel.eh_frame REL 00000000 000588 000010 08 11 8 4 [10] .shstrtab STRTAB 00000000 000194 00005f 00 0 0 1 [11] .symtab SYMTAB 00000000 0003fc 0000f0 10 12 10 4 [12] .strtab STRTAB 00000000 0004ec 000034 00 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings) I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown) O (extra OS processing required) o (OS specific), p (processor specific) There are no section groups in this file. There are no program headers in this file. Relocation section '.rel.text' at offset 0x520 contains 13 entries: Offset Info Type Sym.Value Sym. Name 00000011 00000301 R_386_32 00000000 .data 00000016 00000301 R_386_32 00000000 .data 0000001e 00000301 R_386_32 00000000 .data 00000023 00000301 R_386_32 00000000 .data 00000030 00000501 R_386_32 00000000 .rodata 00000035 00000b02 R_386_PC32 00000000 puts 0000004a 00000501 R_386_32 00000000 .rodata 0000004f 00000c02 R_386_PC32 00000000 printf 00000059 00000501 R_386_32 00000000 .rodata 0000005e 00000b02 R_386_PC32 00000000 puts 00000079 00000501 R_386_32 00000000 .rodata 0000007e 00000e02 R_386_PC32 00000000 __isoc99_scanf 0000008a 00000a02 R_386_PC32 00000000 f Relocation section '.rel.eh_frame' at offset 0x588 contains 2 entries: Offset Info Type Sym.Value Sym. Name 00000020 00000202 R_386_PC32 00000000 .text 00000040 00000202 R_386_PC32 00000000 .text The decoding of unwind sections for machine type Intel 80386 is not currently supported. Symbol table '.symtab' contains 15 entries: Num: Value Size Type Bind Vis Ndx Name 0: 00000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000 0 FILE LOCAL DEFAULT ABS cnt.c 2: 00000000 0 SECTION LOCAL DEFAULT 1 3: 00000000 0 SECTION LOCAL DEFAULT 3 4: 00000000 0 SECTION LOCAL DEFAULT 4 5: 00000000 0 SECTION LOCAL DEFAULT 5 6: 00000000 4 OBJECT LOCAL DEFAULT 3 count.1826 7: 00000000 0 SECTION LOCAL DEFAULT 7 8: 00000000 0 SECTION LOCAL DEFAULT 8 9: 00000000 0 SECTION LOCAL DEFAULT 6 10: 00000000 101 FUNC GLOBAL DEFAULT 1 f 11: 00000000 0 NOTYPE GLOBAL DEFAULT UND puts 12: 00000000 0 NOTYPE GLOBAL DEFAULT UND printf 13: 00000065 48 FUNC GLOBAL DEFAULT 1 main 14: 00000000 0 NOTYPE GLOBAL DEFAULT UND __isoc99_scanf No version information found in this file.
可以看到,ELF格式文件輸出信息的最后是一個符號表,這個符號表揭示了cnt.c源文件中符號的信息。符號表的第5列表示符號的作用域類型,LOCAL表示局部符號,而GLOBAL代表的是全局符號。
開始鏈接的時候,鏈接器首先完成的任務就是符號解析。由於符號已經被確定,鏈接器所要做的就是尋找所有參與鏈接的目標文件,查找這些文件中是否定義了本模塊中尚未能解析的符號。
如果查找到未解析的符號的定義,則准備開始下一步重定位;如果尋找所有參與鏈接的目標文件后仍然找不到未解析的符號的定義,則認為該符號未定義,從而將出錯信息輸出給用戶。當全部的符號都被解析之后,就可以開始鏈接的第二個任務——重定位。
5 重定位的概念
當符號解析結束之后,每個符號的定義位置以及大小都是已知的了。重定位操作只需要將這些符號鏈接起來。在這個步驟中,鏈接器需要將所有參與鏈接的目標文件合並,並且為每一個符號分配存儲內容的運行時地址。重定位分為以下兩步進行:
(1) 重定位段
這一步將所有目標文件中同類型的段合並,生成一個大段。例如,將所有參與鏈接的目標文件的數據段合並,生成一個大的數據段;所有目標文件的代碼段也被合並,生成一個大的代碼段,如下圖所示。
合並之后,程序中的指令和變量就擁有一個統一的並且唯一的運行時地址了。
(2) 重定位符號引用
由於目標文件中相同的段已經合並,因此程序中對富豪的引用位置也就都作廢了。這是鏈接器需要修改這些引用符號的地址,使其指向正確的運行時地址。
6 符號的重定位信息
當編譯器生成一個目標文件后,其並不知道代碼和變量最終的存儲位置,也不知道定義在其他文件中的外部符號。因此,編譯器會生成一個重定位表目,里面存儲着關於每一個符號的信息。這個表目告知鏈接器在合並目標文件時應該如何修改每個目標文件中對符號的引用。這種重定位表目存儲在.rel.text段和.rel.data段中。該表目可以理解為一個結構體,其中存儲着每一個符號的重定位信息。
typedef struct { int offset;/*偏移值*/ int symbol;/*所代表的符號*/ int type;/*符號的類型*/ }symbol_rel;
offset表示該符號在存儲的段中的偏移值。symbol代表該符號的名稱,字符串實際存儲在.strtab段中,這里存儲的是該字符串首地址的下標。type表示重定位類型,鏈接器只關心兩種類型,一種是與PC相關的重定位引用,另一種是絕對地址引用。
PC相關的重定位引用表示將當前的PC值(這個值通常是嚇一跳指令的存儲位置)加上該符號的偏移值。絕對地址引用表示將當前指令中已經指定的地址引用直接作為跳轉的地址,不需要進行任何修改。
有了這些信息,鏈接器就可以將符號在存儲段中的偏移值加上該段在重定位后的新地址,這樣就得到了一個新的引用地址,而這個引用地址就是該符號的最終地址。同樣,在程序中所有引用該地址的部分都要做修改,使用這個新的絕對地址代替舊的偏移地址。當新的符號地址被修改完畢以后,鏈接器的工作就結束了。