链接原理
本文简单介绍了程序的链接原理。学习链接原理有助于程序员理解程序的本质,同时也可以为日后的大型软件的代码开发打下坚实的基础。由此可知链接原理的重要性,尤其是一些程序员被一些莫名其妙的错误困扰的时候,更加能够体会到这一点。
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值(这个值通常是吓一跳指令的存储位置)加上该符号的偏移值。绝对地址引用表示将当前指令中已经指定的地址引用直接作为跳转的地址,不需要进行任何修改。
有了这些信息,链接器就可以将符号在存储段中的偏移值加上该段在重定位后的新地址,这样就得到了一个新的引用地址,而这个引用地址就是该符号的最终地址。同样,在程序中所有引用该地址的部分都要做修改,使用这个新的绝对地址代替旧的偏移地址。当新的符号地址被修改完毕以后,链接器的工作就结束了。