链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于:
1、编译时(compile time), 也就是在源代码被翻译成机器代码时;
2、加载时(load time), 也就是在程序被加载器(loader)加载到内存并执行时;
3、甚至执行于运行时(run time), 也就是由应用程序来执行。
在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
编译器驱动程序
考虑图7-1 中的C 语言程序。它将作为贯穿本章的一个小的运行示例,帮助我们说明关于链接是如何工作的一些重要知识点。
大多数编译系统提供编译器驱动程序(compiler driver), 它代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。比如,要用GNU编译系统构造示例程序,我们就要通过在shell中输入下列命令来调用GCC 驱动程序:
Linux> gcc -Og -o prog main.c sum.c
图7-2 概括了驱动程序在将示例程序从ASCII 码源文件翻译成可执行目标文件时的行为。(如果你想看看这些步骤,用-v 选项来运行GCC )驱动程序首先运行C预处理器(cpp)它将C的源程序main.c 翻译成一个ASCII 码的中间文件main.i:
cpp [other arguments] main.c /tmp/main.i
接下来,驱动程序运行C 编译器(ccl), 它将main.i 翻译成一个ASCII汇编语言文件main.s:
ccl /tmp/main. i -Og [other arguments] -o /tmp/main. s
然后,驱动程序运行汇编器(as), 它将main.s 翻译成一个可重定位目标文件(reloeatable objeet file) main. o:
as [other arguments] -o /tmp/main.o /tmp/main. s
驱动程序经过相同的过程生成sum.o。最后,它运行链接器程序ld, 将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)prog:
ld -o prog [system object files and args] /tmp/main. o /tmp/sum. o
要运行可执行文件prog, 我们在Linux shell 的命令行上输入它的名字:
linux> ./prog
shell调用操作系统中一个叫做加载器(loader) 的函数,它将可执行文件prog中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
静态链接器
像Linux LD 程序这样的静态链接器(static linker )以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的代码和数据节(section)组成,每一节都是一个连续的字节序列。指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为了构造可执行文件,链接器必须完成两个主要任务:
• 符号解析(symbol resolution)。目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
• 重定位(relocadon)。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目( relocation entry )的详细指令,不加甄别地执行这样的重定位。
关于链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。
目标文件
目标文件有三种形式:
•可重定位目标文件。包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
•可执行目标文件。包含二进制代码和数据,其形式可以被直接复制到内存并执行。
•共享目标文件。一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
编译器和汇编器生成可重定位目标文件(包括共享目标文件)。链接器生成可执行目标文件。从技术上来说,一个目标模块( object module )就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块。
目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。从贝尔实验室诞生的第一个Unix 系统使用的是a.out 格式(直到今天,可执行文件仍然称为a .out文件)。Windows 使用可移植可执行(Portable Executable, PE)格式。MacOS-X 使用Mach-O格式。现代x86-64 Linux 和Unix 系统使用可执行可链接格式(Executable and Linkable Format, ELF)。
可重定位目标文件
图7-3 展示了一个典型的ELF 可重定位目标文件的格式。ELF头(ELF header)以一个16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64) 、节头部表(section header table) 的文件偏移,以及节头部表中条目的大小和数最。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry) 。
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
.text: 已编译程序的机器代码。
.rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表。
.data: 已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.dada节中,也不出现在.bss节中。
.bss: 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0 。
.symtab: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,. symtab 符号表不包含局部变量的条目。
.rel.text: 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。
. rel. data: 被模块引用或定义的所有全局变最的重定位信息。一般而言,任何已初始化的全局变最,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
.debug: 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.line: 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
.strtab: 一个字符串表,其内容包括.symtab 和.debug 节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
• 由模块m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和全局变量。
• 由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态C函数和全局变最。
• 只被模块m定义和引用的局部符号。它们对应于带static属性的C函数和全局变量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。
.symtab中的符号表不包含对应于本地非静态程序变量的任何符号。这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣。
定义为带有C static属性的本地过程变量是不在栈中管理的。相反,编译器在.data或.bss 中为每个定义分配空间, 并在符号表中创建一个有唯一名字的本地链接器符号。比如,假设在同一模块中的两个函数各自定义了一个静态局部变量X。在这种情况中,编译器向汇编器输出两个不同名字的局部链接器符号。比如,它可以用x.1表示函数f中的定义,而用x.2表示函数g中的定义。
符号表是由汇编器构造的,使用编译器输出到汇编语言. s 文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组。图7-4 展示了每个条目的格式。
//----code/link/elfstructs.c
typedef struct
{
int name; //String table offset /
char type:4; //Function or data (4 bits)
binding:4;// Local or global (4 bits)
char reserved; // Unused /
short section; // Section header index /
long value; // Section offset or absolute //address
long size; // Object size in bytes */
} Elf64_Symbol;
图7-4 ELF 符号表条目。type 和binding 字段每个都是4 位
name 是字符串表中的字节偏移,指向符号的以null 结尾的字符串名字。
type 通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。
binding 字段表示符号是本地的还是全局的。
section 字段表示每个符号都被分配到目标文件的某个节,该字段也是一个到节头部表的索引,有三个特殊的伪节(pseudosection) , 它们在节头部表中是没有条目的:ABS 代表不该被重定位的符号; UNDEF 代表未定义的符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON 表示还未被分配位置的未初始化的数据目标。对于COMMON 符号, value 字段给出对齐要求,而size 给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。COMMON 和.bss 的区别很细微。现代的GCC 版本根据以下规则来将可重定位目标文件中的符号分配到COMMON 和.bss 中:
COMMON 未初始化的全局变量
.bss 未初始化的静态变量,以及初始化为0的全局或静态变量
value,对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移;对于可执行目标文件来说,该值是一个绝对运行时地址。
size 是目标的大小(以字节为单位)。
GNU READELF 程序是一个查看目标文件内容的很方便的工具。比如,下面是图7-1中示例程序的可重定位目标文件main.o的符号表中的最后三个条目。开始的8个条目没有显示出来,它们是链接器内部使用的局部符号。
Num: Value Size Type Bind Vis Ndx Name
8: 0000000000000000 24 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 array
10: 0000000000000000 0 N0TYPE GLOBAL DEFAULT UND sum
在这个例子中,我们看到全局符号main定义的条目,它是一个位于.text节中偏移量为0(即value值)处的24字节函数。其后跟随着的是全局符号array的定义,它是一个位于.data节中偏移量为0处的8字节目标。最后一个条目来自对外部符号sum的引用。READELF用一个整数索引来标识每个节。Ndx=1表示.text节,而Ndx=3表示.data节。
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单明了的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
不过,对全局符号的引用解析就棘手得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。
链接器如何解析多重定义的全局符号
链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。如果多个模块定义同名的全局符号,会发生什么呢?下面是Linux 编译系统采用的方法。根据强弱符号的定义, Linux 链接器使用下面的规则来处理多重定义的符号名:
• 规则1:不允许有多个同名的强符号。
• 规则2: 如果有一个强符号和多个弱符号同名,那么选择强符号。
• 规则3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
用像 gcc -fno-common 标志这样的选项调用链接器,这个选项会告诉链接器,在遇到多重定义的全局符号时,触发一个错误。或者使用 -Werror 选项,它会把所有的警告都变为错误。
与静态库链接
所有的编译系统都提供一种机制,将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library), 它可以用做链接器的输入。当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
为什么系统要支持库的概念呢?以ISO C99 为例,它定义了一组广泛的标准I/O 、字符串操作和整数数学函数,例如atoi 、printf 、scanf 、strcpy 和rand 。它们在libc.a 库中,对每个C 程序来说都是可用的。ISO C99 还在libc.a 库中定义了一组广泛的浮点数学函数,例如sin 、cos 和sqrt。
让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些函数。
一种方法是让编译器辨认出对标准函数的调用,并直接生成相应的代码。Pascal( 只, 提供了一小部分标准函数)采用的就是这种方法,但是这种方法对C而言是不合适的,因为C标准定义了大最的标准函数。这种方法将给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本.
另一种方法是将所有的标准C函数都放在一个单独的可重定位目标模块中(比如说libc.o中)应用程序员可以把这个模块链接到他们的可执行文件中:
linux> gcc main.o /usr/lib/libc. o
一个很大的缺点是系统中每个可执行文件现在都包含着一份标准函数集合的完全副本,这对磁盘空间是很大的浪费。(在一个典型的系统上, libc.a大约是5MB, 而libm.a 大约是2MB 。)更糟的是,每个正在运行的程序都将它自己的这些函数的副本放在内存中,这是对内存的极度浪费。另一个大的缺点是,对任何标准函数的任何改变,无论多么小的改变,都要求库的开发人员重新编译整个源文件,这是一个非常耗时的操作,使得标准函数的开发和维护变得很复杂。
我们可以通过为每个标准函数创建一个独立的可重定位文件,把它们存放在一个为大家都知道的目录中来解决其中的一些问题。然而,这种方法要求应用程序员显式地链接合适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:
linux> gcc main.c /usr/lib/printf.o /usr/lib/scanf.o .. .
静态库概念被提出来,以解决这些不同方法的缺点。相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。比如,使用C标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:
linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a
在链接时,链接器将只复制被程序引用的目标模块,这就减少了可执行文件在磁盘和内存中的大小。另一方面,应用程序员只需要包含较少的库文件的名字(实际上,C编译器驱动程序总是传送libc.a给链接器, 所以前面提到的对libc.a的引用是不必要的)。
在Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀.a 标识。
//------code/link/addvec.c
int addcnt = 0;
void addvec(int x, int y, int *z, int n)
{
int i;
addcnt++;
for (i = 0;i < n; i++)
{z[i] = x[i] + y[i];}
}
//-------code/link/multvec.c
int multcnt = 0;
void multvec(int x,int y, int z,int n)
{
int i;
multcnt++;
for (i = 0; i < n; i++)
{z[i] = x[i] y[i];}
}
要创建这些函数的一个静态库,我们将使用AR 工具,如下:
linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o
为了使用这个库,我们可以编写一个应用,main2.c, 它调用addvec库例程。包含(或头)文件vector.h定义了libvector.a中例程的函数原型.
//--code/link/main2.c
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2]; int
main()
{
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
return 0;
}
为了创建这个可执行文件,我们要编译和链接输入文件main.o 和libvector .a:
linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a
或者等价地使用:
linux> gcc -c main2. c
linux> gee -static -o prog2c main2.o -L. -lvector
-static 参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无须更进一步的链接。-lvector 参数是libvector.a的缩写, -L. 参数告诉链接器在当前目录下查找libvector.a。
当链接器运行时,它判定main2.o引用了addvec.o定义的addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。
链接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。(驱动程序自动将命令行中所有的.c文件翻译为.o文件。)在这次扫描中,链接器维护一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U, 以及一个在前面输入文件中已定义的符号集合D。初始时,E 、U 和D均为空。
•对于命令行上的每个输入文件f, 链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器把f添加到E, 修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
•如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m, 定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D都不再发生变化。此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
• 如果当链接器完成对命令行上输入文件的扫描后, U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
重定位
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
• 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的.data节被全部合并成一个节,这个节称为输出的可执行文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
• 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation en try) 的数据结构,我们接下来将会描述这种数据结构。
重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在 .rel.text 中。已初始化数据的重定位条目放在 .rel.data中。
//---code/link/elfstructs.c
typedef struct
{
long offset; // Offset of the reference to //relocate /
long type:32, // Relocation type /
symbol:32; // Symbol table index /
long addend; // Constant part of relocation //expression */
} Elf64_Rela;
图7- 9 ELF 重定位条目。每个条目表示一个必须被重定位的引用,并指明如何计算被修改的引用
图7- 9 展示了ELF 重定位条目的格式。
offset 是需要被修改的引用的节偏移。
type 告知链接器如何修改新的引用。
symbol标识被修改引用应该指向的符号。
addend 是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
ELF 定义了32种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型:
•R_X86_64_PC32。重定位一个使用32位PC相对地址的引用。一个PC 相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU 执行一条使用PC 相对寻址的指令时,它就将在指令中编码的32位值加上PC 的当前运行时值,得到有效地址(如call指令的目标),PC 值通常是下一条指令在内存中的地址。
• R_X86_64_32。重定位一个使用32 位绝对地址的引用。通过绝对寻址,CPU直接使用在指令编码中的32位值作为有效地址,不需要进一步修改。
这两种重定位类型支持X86-64 小型代码模型(small code model), 该模型假设可执行目标文件中的代码和数据的总体大小小于2GB,因此在运行时可以用32位PC相对地址来访问。GCC默认使用小型代码模型。大于2GB 的程序可以用-mcmodel=medium(中型代码模型)和-mcmodel=large(大型代码模型)标志来编译。
重定位符号引用
图7-10展示了链接器的重定位算法的伪代码。第1行和第2行在每个节s以及与每个节相关联的重定位条目r上迭代执行。假设每个节s是一个字节数组,每个重定位条目r是一个类型为Elf64_Rela的结构,如图7-9 中的定义。假设当算法运行时,链接器已经为每个节(用ADDR(s)表示和每个符号都选择了运行时地址(用ADDR(r.Symbol)表示)。第3行计算的是需要被重定位的4字节引用的数组s中的地址。如果这个引用使用的是PC. 相对寻址,那么它就用第5~9行来重定位。如果该引用使用的是绝对寻址,它就通过第11~13 行来重定位。
让我们来看看链接器如何用这个算法来重定位图7-1 示例程序中的引用。图7-11给出了(用objdump-dx main .o 产生的)GNU OBJDUMP 工具产生的main .o 的反汇编代码。
main函数引用了两个全局符号:array和sum。为每个引用,汇编器产生一个重定位条目,显示在引用的后面一行上。这些重定位条目告诉链接器对sum的引用要使用32位PC相对地址进行重定位,而对array的引用要使用32位绝对地址进行重定位。
1 . 重定位PC相对引用
图7-11 的第6行中,函数main调用sum函数,sum函数是在模块sum.o中定义的。
call指令开始于节偏移0xe的地方,包括1字节的操作码0xe8, 后面跟着的是对目标sum的32位PC相对引用的占位符。
相应的重定位条目r由4个字段组成:
r.offset = Oxf
r.symbol = sum
r.type = R_X86_64_PC32
r.addend = -4
这些字段告诉链接器修改开始于偏移量Oxf处的32位PC相对引用,这样在运行时它会指向sum例程。现在,假设链接器已经确定
ADDR(s) = ADDR(.text) = 0x4004d0
和
ADDR(r.symbol) = ADDR(sum) = 0x4004e8
使用图7-10 中的算法,链接器首先计算出引用的运行时地址(第7 行):
refaddr = ADDR(s) + r.offset
= 0x4004d0 + Oxf
= 0x4004df
然后,更新该引用,使得它在运行时指向sum程序(第8 行):
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)
= (unsigned)(0x4004e8 + (-4) - 0x4004df)
= (unsigned)(0x5)
在得到的可执行目标文件中,call 指令有如下的重定位的形式:
4004de: e8 05 00 00 00 callq 4004e8 <sum> sum()
在运行时,call指令将存放在地址0x4004de处。当CPU执行call指令时,PC的值为0x4004e3, 即紧随在call指令之后的指令的地址。为了执行这条指令,CPU 执行以下的步骤:
1) 将PC 压入栈中
2) PC <---- PC +0x5 = 0x4004e3+0x5 = 0x4004e8
因此,要执行的下一条指令就是sum 例程的第一条指令,这当然就是我们想要的!
2. 重定位绝对引用
图7-11 的第4 行中,mov指令将array的地址(一个32位立即数值)复制到寄存器%edi中。mov指令开始于节偏移量0x9的位置,包括1字节操作码0xbf,后面跟着对array的32位绝对引用的占位符。
对应的占位符条目r包括4个字段:
r.offset = Oxa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
这些字段告诉链接器要修改从偏移量0xa开始的绝对引用,这样在运行时它将会指向array的第一个字节。现在,假设链接器已经确定
ADDR(r.symbol) = ADDR(array) = 0x601018
链接器使用图7-10中算法的第13行修改了引用:
refptr =(unsigned)(ADDR(r.symbol)+ r.addend)
= (unsigned)(0x601018 + 0)
= (unsigned)(0x601018)
在得到的可执行目标文件中,该引用有下面的重定位形式:
4004d9: bf 18 10 60 00 mov $0x601018,%edi //%edi = &array
综合到一起,图7-12 给出了最终可执行目标文件中已重定位的.text节和.data节。在加载的时候,加载器会把这些节中的字节直接复制到内存,不再进行任何修改地执行这些指令。
可执行目标文件
可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格式。它还包括程序的入口点(entry point) , 也就是当程序运行时要执行的第一条指令的地址。.text 、.rodata和.data节与可重定位目标文件中的节是相似的,除了这些节巳经被重定位到它们最终的运行时内存地址以外。. init节定义了一个小函数,叫做_init, 程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位) , 所以它不再需要.rel节。
ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk) 被映射到连续的内存段。程序头部表(program header table) 描述了这种映射关系。图7-14 展示了可执行文件prog的程序头部表,是由OBJDUMP 显示的。
------------code/linklprog-exe.d
Read-only code segment
1 LOAD off 0x0000000000000000 vaddr 0x0000000000400000 paddr 0x0000000000400000 align 2**21
2 filesz 0x000000000000069c memsz 0x000000000000069c flags r-x
Read /write data segment
3 LOAD off 0x0000000000000df8 vaddr 0x0000000000600df8 paddr 0x0000000000600df8 align 2**21
4 filesz 0x0000000000000228 memsz 0x0000000000000230 flags rw-
------------code/linklprog-exe.d
图7 - 1 4 示例可执行文件prog 的程序头部表
off : 目标文件中的偏移; vaddr/paddr : 内存地址; align : 对齐要求; filesz: 目标文件中的段大小;
memsz: 内存中的段大小; flags: 运行时访问权限。
从程序头部表,我们会看到根据可执行目标文件的内容初始化两个内存段。
第1行和第2行告诉我们第一个段(代码段)有读/执行访问权限,开始于内存地址0x400000处,总共的内存大小是0x69c字节,并且被初始化为可执行目标文件的头0x69c个字节,其中包括ELF头、程序头补表以及.init、.text和.rodata节。
第3 行和第4行告诉我们第二个段(数据段)有读/写访问权限,开始于内存地址0x600df8处,总的内存大小为0x230字节,并用从目标文件中偏移0xdf8处开始的.data节中的0x228个字节初始化。该段中剩下的8 个字节对应于运行时将被初始化为0的.bss数据。
对于任何段s, 链接器必须选择一个起始地址vaddr, 使得
vaddr mod align= off mod align
这里, off 是目标文件中段的第一个节的偏移量, align 是程序头部中指定的对齐2**21=0x200000) 。例如,图7-14 中的数据段中
vaddr mod align=0x600df8 mod 0x200000 = 0xdf8
以及
off mod align=0xdf8 mod 0x200000 = 0xdf8
这个对齐要求是一种优化,使得当程序执行时,目标文件中的段能够很有效率地传送到内存中。原因有点儿微妙,在于虚拟内存的组织方式,它被组织成一些很大的、连续的、大小为2的幂的字节片。
加载可执行文件
要运行可执行目标文件prog, 我们可以在Linux shell 的命令行中输入它的名字:
linux> ./prog
因为prog不是一个内置的shell 命令,所以shell会认为prog是一个可执行目标文件,通过调用某个驻留在存储器中称为加载器(loader) 的操作系统代码来运行它。任何Linux 程序都可以通过调用execve函数来调用加载器,加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。
每个Linux程序都有一个运行时内存映像,类似于图7-15 中所示。在Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆在数据段之后,通过调用malloc库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(2**48 -1)开始,向较小内存地址增长。栈上的区域,从地址2**48开始,是为内核(kernel)中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。
为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用户地址处。实际上,由于.data段有对齐要求,所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布局随机化。虽然每次程序运行时这些区域的地址都会改变,它们的相对位置是不变的。
当加载器运行时,它创建类似于图7-15所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk) 复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有的C 程序都是一样的。_start 函数调用系统启动函数__libc_start_main, 该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。