本文简单介绍了程序的链接原理。学习链接原理有助于程序员理解程序的本质,同时也可以为日后的大型软件的代码开发打下坚实的基础。理解链接原理有助于我们在日常开发中解决一些莫名其妙的问题。
简单来说,链接是将一个项目中各种代码和部分数据收集起来并组合成一个单一可执行文件的过程,组合成的这个文件是可以被加载到内存中执行的。
链接可以发生在以下三种情况中:
1.编译时:源代码被翻译成机器代码时
2.加载时:程序被加载到内存中并执行时
3.运行时:应用程序来执行时
1. 静态链接
1.1 程序编译流程
//示例程序1
/* /code/link/main.c */
void swap();
int buf[2] = {1, 2};
int main()
{
swap();
return 0;
}
/* /code/link/swap.c */
extern int buf[];
int *bufp0 = &buf[0];
int *bufp1;
void swap()
{
int temp;
bufp1 = &buf[1];
temp = *bufp0;
*bufp0 = *bufp1;
*bufp1 = temp;
}
以上是一个简单的两数交换程序,它们生成可执行目标文件的过程如下所示:
c语言预处理器(cpp):将c语言源程序*.c翻译成一个ASCII码的中间文件*.i
c编译器(ccl):将*.i翻译成一个ASCII码的汇编语言文件*.s
汇编器(as):将*.s翻译成一个可重定位目标文件*.o
最后通过链接器程序ld,将所有的*.o文件以及一些必要的系统文件组合起来,创建一个可执行的目标文件
1.2 链接器的任务
链接器将多个目标文件链接成一个完整的、可加载、可执行的目标文件。其输入是一组可重定位的目标文件。链接的两个主要任务如下:
1.符号解析:将目标文件内的符号引用和符号定义联系起来。每个函数和每个变量都可看作是一个符号,将目标文件中每个符号都和符号定义联系起来。
2.重定位:链接器通过把每个符号的定义与一个具体的内存(RAM)位置联系起来,然后去修改所有对这些符号的引用,使得他们都指向这个内存位置。
1.3 目标文件
目标文件的三种形式:
1.可重定位的目标文件
这种文件包含二进制代码和数据,这些代码和数据已经经过编译转换成了机器指令代码和数据,但是还不可以直接执行。因为这些指令和数据中往往引用了其他模块(目标文件)中的符号,这些其他模块的符号对于本模块来说是未知的,这些符号的解析需要链接器将所有模块进行链接。这种操作称为重定位,因此,这种目标文件被称为“可重定位的目标文件”,后缀名通常为*.o
2.可执行的目标文件
这种文件同样包含了二进制代码和数据。所不同的是,这种文件已经经过了链接操作,和所有的模块(目标文件)都产生了联系。链接器将所有需要的可重定位目标文件连接成一个可执行目标文件。这时,每个目标文件中引用其他目标文件中的符号都已经得到了解析和重定位。因此,每个符号都是已知的了,该文件可以被机器直接执行。
3. 共享目标文件
这是一种特殊的可定位目标文件,可以在需要它的程序运行或加载时,动态地加载到内存中运行。这种文件的后缀名通常是*.so。共享目标文件通常又被称为“动态库”文件或者“共享库”文件。
1.4 可重定位的目标文件
Linux环境下一个典型的可重定位目标文件和可执行文件通常是ELF(Excutable Linkable File)格式,ELF文件的典型结构如下所示:
该目标文件主要由两部分组成:ELF文件头和目标文件的段。ELF文件头的前16个字节构成了一个字节序,描述了生成该文件系统的字长以及字节序。剩下的部分包括了ELF文件的一些其他信息,其中包括ELF文件头的大小、目标文件的类型、目标机的类型、段头部表在目标文件内的文件偏移位置等。在链接和加载ELF格式的程序时,这些信息是很重要的。
/*ELF文件头*/
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x4003e0
Start of program headers: 64 (bytes into file)
Start of section headers: 6736 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 28
除了ELF文件头之外,剩下的部分由目标文件的段组成。这些段是ELF文件中的核心部分。由以下几个段组成:
●.text:代码段,存储的二进制的机器指令,这些指令可以被机器直接执行。
●.rodata:只读数据段,存储程序中使用的复杂常量,例如字符串等。
●.data:数据段,存储程序中已经被明确初始化的全局数据。包括C语言中的全局变量和静态变量。如果这些全局数据被初始化为0,则不存储在数据段中,而是被存储在块存储段中。C语言局部变量保存在栈上,不出现在数据段中。
●.bss:块储存段,存储未被明确初始化的全局数据。 在目标文件中这个段并不占用实际的空间,而仅仅是一个占位符,以告知指定位置上应当预留全局数据的空间。块存储段存在的原因是为了提高磁盘上存储空间的利用率。
●.symtab:符号表,存储定义和引用的函数和全局变量。每个可重定位的目标文件中都要有一个这样的表。在该表中,所有引用的本模块内的全局符号(包括函数和全局变量)以及其他模块(目标文件)中的全局符号都会有一个登记。链接中的重定位操作就是将这些引用的全局符号的位置确定。
●.rel.text:代码段需要重定位(relocate)的信息,存储需要靠重定位操作修改位置的符号的汇总。这些符号在代码段中,通常是一个函数名和标号。
●.rel.data:数据段需要重定位的信息,存储需要靠重定位操作修改位置的符号的汇总。这些符号在数据段中,是一些全局变量。
●.debug:调试信息,存储一个用于调试的符号表。在编译程序时使用gcc编译器的-g选项会生成该段,该表包括源程序中所有符号的引用和定义,有了这个段在使用gdb调试器对程序进行调试的时候才可以打印并观察变量的值。
●.line: 源程序的行号映射,存储源程序中每一个语句的行号。在编译程序时使用gcc编译器的-g选项会生成该段,在使用gdb调试器对程序进行调试的时候这个段的作用很大。
●.strtab:字符串表,存储.symtab符号表和.debug符号表中符号的名字,这些名字是一些字符串,并且以‘\0’结尾。
1.5 目标文件中的符号和符号表
符号解析是链接的主要任务之一。只有在正确解析了符号之后才能够更改引用符号的位置,从而完成重定位,生成一个可以被机器直接加载执行的可执行目标文件。每个可重定位目标文件都有一个符号表,在这个符号表中存储符号,这些符号分为3类:
1.本模块中定义的全局符号
2.本模块中引用的其他模块所定义的全局符号
3.本模块中定义和引用的局部符号
注意:局部变量和局部符号不是一回事。局部变量存储在栈中,是一个仅仅在内存中出现的概念;而局部符号包括静态变量和局部标号,这些内容也可能出现在磁盘文件中。
符号表结构:
typedef struct{
int name; //目标符号的名字
int value; //符号的地址。对于可重定位模块:该值是距定义目标节的起始位置的偏移;
// 对于可执行目标文件:该值是一个绝对运行时地址。
int size; //目标符号的大小(字节为单位)
char type:4; //目标符号的类型
char binding:4; //目标符号是本地的还是全局的
char reserved; //保留
char section; //表示目标符号和目标文件的某个节关联(符号表中的Ndx字段)
}Elf_Symbol;
示例程序1main.c中的符号表
Symbol table '.symtab' contains 11 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 6
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 5
8: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 buf
9: 0000000000000000 21 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
符号表含义解释:
**buf:**位于.data节中偏移为0(value值)的8字节目标,全局符号
**main:**位于.text节中偏移为0的21字节的函数,全局函数
**swap:**来自外部符号swap的引用,外部符号
符号表中用整数来标识每个不同的节:Ndx=1表示.text节;Ndx=3表示.data节;ABS代表不该被重定位的符号;UNDEF代表未定义的符号,也就是在本目标模块中引用,在其他地方定义的符号;COMMON表示还未被分配位置的未初始化的数据目标,也就是未初始化的全局或局部静态变量。其中LOCAL表示本地符号,GLOBAL表示全局符号。
1.6 符号解析
链接器解析符号引用的方法就是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。
1.本地符号解析
对于那些引用定义在相同模块中的本地符号的引用,符号解析非常简单。编译器只允许每个本地目标文件中每个本地符号只有一个定义。当然对于本地的静态变量,他们会被编译器分配一个本地链接器符号,拥有唯一的名字。
2.全局符号解析
在解析全局符号时,当编译器遇到一个不在当前模块中定义的符号(变量或函数)时,他会假设该符号是在某个其他模块中定义的,生成一个链接器符号条目表,并把它交给链接器处理。在接下的链接重定位过程中如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,编译就会报错。
3.对于多个目标文件中定义相同的全局符号编译器解析规则
规则1:不允许有多个强符号
规则2:如果有一个强符号和多个弱符号,则选择强符号
规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个
强符号:被初始化的全局符号
弱符号:未初始化的全局符号
1.7 重定位
当符号解析结束之后,每个符号的定义位置以及大小都是已知的了。重定位操作只需要将这些符号链接起来。在这个步骤中,链接器需要将所有参与链接的目标文件合并,并且为每一个符号分配存储内容的运行时地址。重定位分为以下两步进行:
1.重定位节和符号定义
在这一步中,链接器将所有相同类型的节合并成一个新的节。例如,所有输入目标模块中的.data节都将会被合并成可执行目标文件中的.data节,然后链接器将运行时内存地址赋值给新的.data节。其他节的处理过程也是一样的,当这一步完成时,程序中的每一个指令和全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时内存地址。
当编译器生成一个目标文件后,其并不知道代码和变量最终的存储位置,也不知道定义在其他文件中的外部符号。因此,无论何时汇编器遇到对最终位置未知的目标引用,编译器会生成一个重定位表目,里面存储着关于每一个符号的信息。这个表目告知链接器在合并目标文件时应该如何修改每个目标文件中对符号的引用。这种重定位表目存储在**.rel.text**段和.rel.data段中。该表目可以理解为一个结构体,其中存储着每一个符号的重定位信息。
typedef struct {
int offset;/*偏移值*/
int symbol;/*所代表的符号*/
int type;/*符号的类型*/
}symbol_rel;
/*
offset表示该符号在存储的段中的偏移值。symbol代表该符号的名称,字符串实际存储在.strtab段中,这里存储的是该字符串首地址的下标。type表示重定位类型,链接器只关心两种类型,一种是与PC相关的重定位引用,另一种是绝对地址引用。
*/
PC相关的重定位引用表示将当前的PC值(这个值通常是下一跳指令的存储位置)加上该符号的偏移值。绝对地址引用表示将当前指令中已经指定的地址引用直接作为跳转的地址,不需要进行任何修改。
有了这些信息,链接器就可以将符号在存储段中的偏移值加上该段在重定位后的新地址,这样就得到了一个新的引用地址,而这个引用地址就是该符号的最终地址。同样,在程序中所有引用该地址的部分都要做修改,使用这个新的绝对地址代替旧的偏移地址。当新的符号地址被修改完毕以后,链接器的工作就结束了。
1.8 可执行目标文件
一个可执行目标文件(ELF)的格式:
ELF头部描述文件的总体格式,类似于可重定位目标文件的格式,但是它包括程序的入口点(entry point)。
段头部表:描述了可执行文件连续的片是被映射到存储器的哪些连续段。
.init定义了一个函数:_init,程序初始化代码会调用。
.text、.rodata、.data和之前的可重定位目标文件中的节类似,但是这些节已经被重定位到了他们最终运行时的内存地址。
段头部表示例:
off:文件偏移;vaddr:虚拟地址;paddr:物理地址;align:段对齐;
filesz:目标文件中的段大小;memsz:存储器中的段大小;flags:操作权限
解释:
第1行和第2行告诉我们第一个段(代码段)对齐到一个4KB的边界,有读/执行权限,开始于存储器地址0x08048000处,总共的存储器大小是0x448字节,并且被初始化为可执行目标文件的头0x448个字节,其中包括ELF头部、段头部表以及.init、.text和.rodata节。
第3行和第4行告诉我们第二个段(数据段)被对齐到一个4KB的边界,有读/写权限,开始于存储器地址0x08049448处,总的存储器大小为0x104字节,并用从文件偏移0×448处开始的0xe8个字节初始化,在这种情况下,偏移0x448处正是.data节的开始。该段中剩下的字节对应于运行时将被初始化为零的.bss数据。