《深入理解计算机系统》课本自学笔记
第七章 链接
By20135203齐岳
链接:将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载(或拷贝)到存储器并执行。
现代计算机中链接由链接器自动完成。
链接器在软件开发中的重要功能:分离编译。
编译器驱动程序
大多数编译系统提供编译驱动程序,它代表用户在需要调用语言处理器、编译器、汇编器和链接器
例如,使用GNU编译系统构造示例程序:
gcc -02 -g -o p main.c swap.c
具体步骤如下:
#运行预处理器cpp生成ASCII码的中间文件main.i
cpp [other arguments] main.c /tmp/main.i
#运行C编译器cc1,将mian.i翻译成一个ASCII汇编语言文件main.s
cc1 /tmp/main.i main.c -02 [other arguments] -o /tmp/main.s
#运行汇编器as,将mian.s翻译成一个可重定位目标文件main.o
as [other arguments] -o main.o /tpm/main.s
#运行链接器程序ld,将mian.o和swap.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件p
ld -o p [system object files and args] /tmp/main.o /tmp/swap.o
#要运行可执行文件p,直接在shell上输入它的名字
./p
外壳调用操作系统中的加载器,它拷贝可执行文件p中的代码和数据到存储器,然后控制转移到这个程序的开头。
静态链接
静态链接器
以一组可重定位的目标文件和命令行参数作为输入,生成一个完全连接的可以加载和运行的可执行目标文件作为输出。
为构造可执行文件链接器完成的两个主要任务:
-
符号解析:目标文件定义和引用符号。将每个符号引用刚好和一个符号定义联系起来。
-
重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器的位置。
目标文件
-
可重定位文件:保存代码和适当的数据,用来和其他object文件一起创建一个可执行文件或一个共享文件。主要是.o文件。
-
可执行文件:保存一个用来执行的程序,指出了exec(BA_OS)如何来创建程序进程映象,怎么把文件加载出来以及从哪里开始执行。
-
共享文件:保存着代码和数据用来被以下两个链接器链接。一是链接编译器,可以和其他的可重定位和共享文件创建其他的object文件;二是动态链接器,联合一个可执行文件和其他 共享文件来创建一个进程映象。主要是.so文件。
可重定位目标文件
ELF是目标文件的格式:
ELF头
16字节,包含系统的字的大小和字节顺序,帮助链接器语法分析和解释目标文件的信息(ELF头的大小,目标文件的类型-可重定位-共享-可执行,机器类型-IA32,节头部表的文件偏移,节头部表中的条目大小和数量)。
节头部表
描述不同节的位置和大小。节就是夹在ELF头和节头部表之间的。节头部表包含很多条目,每一个条目的大小都是一样的,每一个条目都对应一个节。
节
- .text——包含已编译的机器代码。
- .rodata—只读数据(printf中的格式串等)
- .data——已初始化的全局C变量。
- .bss——未初始化的全局C变量。(只是一个占位符,不占据空间)
- .symtab——一个符号表,包含:程序定义引用的函数和全局变量。
- .rel.text——服务于.text节,是描述.text中位置的列表。当链接器把本目标文件和其他文件结合时,这个小节,就要修改。
- .rel.data——服务于.data,被模块引用或定义的任何全局变量的重定位信息。同上,和其他文件结合时,这个小节,就要修改。
- .debug——一个调试符号表,表中记录着程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的c源文件。只有以-g选项调用编译驱动程序时才会得到这张表。
- .line——原始c源程序中的行号和.text节中机器指令之间的映射。同上-g才有。
- .strtab——一个字符串表,其中包含.symtab和.debug节中的符号表,以及节头部表中的节名字。字符串表,就是一个一个的字符串,字符串就是以NULL结尾的字符序列。
符号和符号表
每个可重定位目标模块m都有一个符号表,包含m所定义和引用的符号的信息。在链接器的上下文中有三种不同的符号:
-
由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数和不带C static属性的全局变量。
-
由其他模块定义并被模块m引用的全局符号。称为外部符号。对应于定义在其他模块中的C函数和变量。
-
只被模块m定义和引用的本地符号。称为本地链接器符号。对应于带static属性的C函数和全局变量。
符号表是一个数组,数组的元素是条目,条目的格式是固定的,如下图所示:
- name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。
- value是符号的地址,对于可重定位模块来说,这是一个相对的偏移地址;对于可执行目标文件来说,是一个绝对运行时的地址。
- size是目标的大小(字节为单位)。
- type为数据或函数,表示符号是数据还是函数还是节还是路径名。
- binding表示是全局的还是本地的。
- reserved,未使用。
- section,每个符号都和本可重定向目标模块的一个节相关联,这个值是一个索引值,节头部表的索引值,节头部表包含了节的条目。
符号解析
链接器解析符号引用的方法是将每个引用与他输入的可重定位目标文件的符号表中一个确定的符号定义联系起来。
编译器值允许每个模块中每个本地符号只有一个定义。编译器还确保静态本地变量,它们也会有本地链接器符号,拥有唯一的名字。
对于全局符号,编译器会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。对全局符号的解析很棘手,还因为多个目标文件,可能会定义相同的符号。
链接器如何解析多重定义的全局符号
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
Unix链接器使用下面的规则来处理多重定义的符号:
- 不允许有过个强符号。
- 如果有一个强符号和多个弱符号,那么选择强符号。
- 如果有多个弱符号,那么从这些弱符号中任意选择一个。
与静态库链接
将所有相关的目标模块打包成为一个单独的文件称为静态库,它可以用作链接器的输入。
如果不使用静态库,要用什么方法向用户提供c标准里的库函数?
-
编译器辨认出对标准函数的调用。——c标准函数太多,编译器太复杂,同时一个标准函数变了,那么编译器就需要推出一个新的版本。
-
所有的c标准函数都放入一个可重定位目标文件中。——那么每一个可执行目标文件,都将包含所有的c标准函数,体积过大。维护这个大的可重定位目标文件也麻烦,任意一个小的修改都要完整的编译所有c标准函数。
静态库的解决方法:相关的标准函数被编译成一个可重定位目标文件,然后将这些目标文件封装成一个静态库文件,然后应用程序通过在命令行上指定的单独文件名字来使用这些在库中定义的函数。
使用标准C库和数学库中函数的程序可以用如下形式编译:
gcc main.c /usr/lib/libm.a /usr/lib/libc.a
gcc -static,这个-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,可以加载到存储器并运行,在加载时无需更进一步的链接。
在Unix系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。
重定位
一旦链接器完成了符号解析,就把代码中的每个符号引用和确定的一个符号定义联系起来。然后进行重定位。分为以下两步:
-
重定位节和符号定义——所有相同类型的节合并为同一类型的新的聚合节。将运行时存储器地址赋给—聚合节—每个模块定义的每个节—每个符号。完成这一步之后,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
-
重定位节中的符号引用——这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。——这一步依赖于重定位条目,这个条目就是.rel.text和.rel.data两个节。
重定位条目
汇编器生成目标模块,就是ELF格式的可重定位目标模块,模块中有符号,符号中有外部符号,汇编器不知道外部符号的位置,他会在符号表中标识其为UND类型,这就是ELF可重定位目标文件,他不知道,就标为UND,然后,他会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目在.rel.text,数据的重定位条目在.rel.data中。
ELF重定位条目的格式:
- offset是需要被修改的引用的节偏移。
- symbol标识被修改的引用应该指向的符号。
- type告知链接器如何修改新的引用。
重定位符号引用
-
R _ 386 _ PC32:重定位一个使用32位PC相对地址的引用。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值上加上PC的当前运行的值,得到有效地址,PC值通常是存储器中下一条指令的地址。
-
R _ 386 _ 32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
可执行目标文件
ELF可执行目标文件的格式:
ELF可执行文件被设计的很容易加载到存储器,可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
加载可执行目标文件
加载器是一段操作系统代码,这段代码驻留在存储器中。
任何Unix程序都可以通过调用execve函数来调用加载器,这个函数是Unix提供的系统接口之一。
加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后跳转到程序的第一条指令或入口点来执行该程序。——这一过程称为加载。
在32位系统中,代码段总是从地址0x0848000开始的。从栈的上部开始的段是为操作系统驻留存储器的部分的代码和数据保留的。
加载器跳转到程序的入口点,这个入口点就是一个符号的地址,,_start这个符号源自ctrl.o。ctrl.o可以预见是标准函数的可重定位目标文件或者系统函数的可重定位目标文件。
_start是个函数,在这个函数中,会依次的调用一些其他的初始化例程。其中atexit例程附加了一系列的应用程序正常中止时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,调用main程序。
动态链接共享库
共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序连接起来。这个过程称为动态链接,由动态链接器执行。
共享库在Unix中用后缀.so表示。在微软操作系统中称为DLL。
共享库的共享通过两种不同的方式执行:
-
一个库只有一个.so文件,这是对于磁盘来说的,比如libc.so,一个ubuntu只有一个libc.so,或者说某一个版本的,只有一个libc.so。
-
对于存储器,.text节的一个副本可以被不同的正在运行的进程共享。
当加载器加载和运行一个编译包含共享库的可执行文件时,加载器会注意到一个.interp节,然后,加载器会加载和运行一个动态链接器。(动态链接器本身就是一个共享目标)。
动态链接器执行重定位完成链接任务。最后,动态链接器将控制传递给应用程序。
从应用程序中加载和链接共享库
动态链接在现实世界中的例子:分发软件,构建高性能web服务器。
linux系统为动态链接器提供了一个简单的接口,允许应用程序在运行时加载和链接共享库。
#include <dlfcn.h>
void *dlopen(const char * filename, int floag);
#成功,返回指向句柄的指针,若出错则为NULL
void * dlsym(void *handle, char *symbol);
#第一个参数是上面函数返回的句柄的指针,第二个参数是符号的名字
#成功,返回指向句柄的指针,若出错则为NULL
int dlclose(void *handle);
#如果没有其他共享库正在使用这个共享库,那么就卸载该共享库。
#成功返回0,出错返回-1
const char* dlerror(void);
#上面的3个函数运行之后,运行这个函数,可以看看最近发生的最近的错误,如果没有错误,就返回NULL
与位置无关的代码(PIC)
多个进程是如何共享程序的一个拷贝:
使链接器不需要修改库代码就可以在任何地址加载和执行这些代码,这要求编译库代码的时候要实现这种功能。
这样的代码叫做PIC(位置无关的代码),GCC用-fPIC选项来实现。
PIC数据引用
无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是被分配成紧随在代码段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码和数据段的绝对位置无关。
为了运用这个事实,编译器在共享模块的数据段开始的地方创建了一个表,叫做GOT。
PIC函数调用
PIC使用同样的方法来解析外部过程调用,ELF编译系统使用延迟绑定的技术。通过以下两个数据结构:
- GOT
- 过程连接表
处理目标文件的工具
GNU binutils包:
- AR:创建静态库,插入、删除、列出和提取成员。
- STRINGS:列出一个目标文件中所有可打印的字符串。
- STRIP:从目标文件中删除符号表信息。
- NM:列出一个目标文件的符号表中定义的符号。
- SIZE:列出目标文件中节的名字和大小。
- READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息。包括SIZE和NM的功能。
- OBJDUMP:所有二进制工具之母。能够显示一个目标文件中所有的信息。它最大的作用是反汇编.text中的二进制指令。
- LDD:列出一个可执行文件在运行时所需要的共享库。