第七章 链接
链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程。可以执行于编译、加载和运行时,由叫做链接器(可实现分离编译)的程序自动执行。
一、 编译器驱动程序
大多数编译系统提供编译驱动程序,用户在需要时可调用语言预处理器、编译器、汇编器和链接器。
二、静态链接
注:链接器将重定位目标文件(relocatable object files)组合成一个可执行目标文件。cpp(c previous processor,C预处理器);ccl(C编译器);as(汇编器)
为了创建静态链接,链接器完成两个主要任务:
- 符号解析(symbol resolution):将每个符号引用和一个符号定义联系起来。
- 重定位(relocation):编译器和汇编器生成从0地址开始的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
三、目标文件
有三种形式:
- 可重定位目标文件:包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件:包含二进制代码和数据,其形式可以直接拷贝到存储器并执行。
- 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载到存储器并链接。
四、可重定位目标文件
-
一个典型的ELF可重定位目标文件的格式P451。ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或是共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表中的条目大小和数量。不同的节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
-
夹在ELF头和节头部表之间的都是节。一个典型的ELF可重定位目标文件包含下面几个节:
.text 已编译程序的机器代码 .rodata 只读数据 .data 已初始化的全局C变量。局部C变量在运行时保存在栈中,既不出现在.data节中 ,也不出现在.bss节中 .bass 未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间 .symtab 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表 .rel.text 一个.text节中位置的列表,当链接器吧这个目标文件和其他文件结合时,需要修改这些位置。一般而言,任何调用外部函数或引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显示第指示链接器包含这些信息 .rel.data 被模块引用或定义的任何全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改 .debug 一个调试符号表,其条目是程序总定义的局部变量和类型定义,程序中定义和引用的 全局变量,以及原始的C源文件 .line 原始C源文件中的行号和.text节中机器指令之间的映射 .strtab 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。
五、符号和符号表
-
每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息。在链接器上下文中有三种不同的符号:
(1)由m定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的C函数以及被定义为不带C static属性的全局变量
(2)由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于定义在其它函数中的C函数和变量
(3)只被模块m定义和引用的本地符号。有的本地链接器符号对应于带static属性的C函数和全局变量。这些符号在模块m中随处可见,但是不能被其他模块引用。目标文件中对应于模块m的节和相应的源文件的名字也能获得本地符号
-
定义为带有C static属性的本地过程变量不在栈中管理。相反,编译器在.data和.bss中为每个定义分配空间,并在符号表中创建一个唯一有名字的本地链接器符号。
-
利用static属性隐藏变量和函数名字。任何声明带有static属性的全局变量或函数都是模块私有的。
name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字
value是符号的地址。对于可重定位的模块而言,value是距定义目标的节的起始位置的偏移。对于可执行目标文件而言,该值是一个绝对运行时地址
size是目标的大小,以字节为单位
type是数据或函数
binding字段表示符号是本地的还是全局的
section字段:每个符号和目标文件的某节的关联,也表示一个到节头部表的索引
六、符号解析
C++和Java中能使用重载函数,是因为编译器将每个惟一的方法和参数列表组合编码成一个对链接器来说惟一的名字。这种编码过程叫做毁坏(mangling),而相反的过程叫恢复(demangling)。
C++和Java使用的是兼容的毁坏策略。一个已毁坏类的名字是由名字中字符的整数数量,后面跟原始名字组成的。比如类Foo被编译成3Foo;方法被编译成:原始方法名 + __ + 已毁坏的类名+ 再加上每个类参数的一个字母。如Foo::bar(int, long)被编译成bar__3Fooil。毁坏全局变量和模板名字的策略是相似的。
1. 链接器如何解析多处定义的全局符号
-
函数和已初始化的全局变量是强符号(strong);未初始化的全局变量是弱符号(weak)。
-
根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号:
规则1:不允许有多个强符号 规则2:如果有一个强符号和多个弱符号,那么选择强符号 规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个
2.与静态库链接
-
静态库:有的编译系统都提供一种机制,将所有相关的目标模块打包成为的一个单独的文件。
-
unix中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部描述每个成员目标文件的大小和位置;后辍名为.a。
3.链接器如何使用静态库来解析引用
(1)在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。在这次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。初始时,E、U和D都是空的。
(2)命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。
(3)关于库的一般准则:
将它们放在命令行的结尾;
另一方面,如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义实在对s的引用之后的;
如果需要满足依赖需求,可以在命令行上重复库。
七、重定位
重定位由两步组成:
- 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址。
- 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构。
1.重定位条目
重定位条目告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。 已初始化的数据的重定位条目放在.rel.data中。格式为:
解释:offset需要被修改的引用的节偏移;symbol标识被修改的引用应该指向的符号;type告知链接其如何修改新的引用。
ELF定义了11种不同的重定位类型。其中两种最基本的重定位类型:
- R_ 386_PC32 重定位一个使用32位PC相对地址的引用。
- R_ 386_32 重定位一个使用32位绝对地址的引用。
2.重定位符号引用
链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。
重定位PC相对引用
重定位绝对引用
八、可执行目标文件
注:从上图中可以看出,代码段的地址总是比数据段的地址小。
-
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头部描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text 、.rodata和.data 节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位了),所以它不再需要.rel节。
-
ELF可执行文件被设计得很容易加载到存储器,可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
九、加载可执行目标文件
-
要运行可执行目标文件p,可以在Unix外壳的命令行中输入它的名字:
unix> ./p
-
任何Unix程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第1条指令,即入口点(Entry Point),来运行该程序。将程序拷贝到存储器并运行的过程叫做加载(loading)。
-
每个Unix程序都有一个运行时存储器映像,如下图。在Linux系统中,代码段总是从地址0x08048000处开始。数据段是在接下来的下一个4KB对齐的地址处。堆在接下来的读/写段之后的第一个4KB对齐的地址处,并通过调用malloc库往上增长。从0x4000000处的段是为共享库保留的。用户栈总是从地址0xbfffffff处开始,并向下增长的(高地址向低地址增长的)。从栈的上部开始于地址0xc0000000处的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。
- 在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,也就是符号_ start的地址。在_ start地址处的启动代码是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。在从.text和.init节中调用了初始化例程后,启动代码调用atexti例程,这个程序附加了一系列在应用程序正常中止时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_ exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,它会开始执行我们的C代码。在应用程序返回之后,启动代码调用_ exit程序,它将控制返回给操作系统。
十、动态链接共享库
-
共享库是一个目标模块,在运行时,可以加载到任意的地存储器地址,并在存储器中和一个程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序执行的。
-
共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。(在MS OS 中为DLL文件)
注意:静态链接与动态链接的区别:静态链接是把程序所需要的库代码和数据拷贝和嵌入到引用它们的可执行文件中;而动态链接是所有引用该库的可执行文件文件共享这个.so(dll)文件中的代码和数据。
调用编译器构造向量运算示例程序的共享库libvector.so:
gcc -shared -fPIC -o libvector.so addvec.c multvec.c
将库链接到程序中,创建一个可执行目标文件p2:
gcc -o p2 main.c /libvector.so
动态链接器通过执行下面的重定位完成链接任务:
- 重定位libc.so的文本和数据到某个存储器段
- 重定位libvector.so的文本和数据到另一个存储器段
- 重定位p2中所有对libc.so和libvector.so定义的符号的引用
- 最后动态链接器将控制传递给应用程序,此时共享库的位置已固定,并且在程序执行的过程中不会改变
十一、从应用程序中加载和链接共享库
通过几个函数,dlopen加载和链接共享库,dlsym通过输入的共享库的符号名字,返回符号的地址;dlclose卸载共享库,dlerror返回前面函数执行情况的一个字符串。
示例代码 :
#include <stdio.h>
#include <dlfcn.h>
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int);
char *error;
/* dynamically load the shared library that contains addvec() */
handle = dlopen("./libvector.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s
", dlerror());
exit(1);
}
/* get a pointer to the addvec() function we just loaded */
addvec = dlsym(handle, "addvec");
if ((error = dlerror()) != NULL) {
fprintf(stderr, "%s
", error);
exit(1);
}
/* Now we can call addvec() it just like any other function */
addvec(x, y, z, 2);
printf("z = [%d %d]
", z[0], z[1]);
/* unload the shared library */
if (dlclose(handle) < 0) {
fprintf(stderr, "%s
", dlerror());
exit(1);
}
return 0;
}
十二、与位置无关的代码(PIC)
-
共享库的一个主要目的就是允许多个正在运行的进程共享存储器中相同的库代码,因而节约存储器资源。
-
PIC:编译库代码,不需要链接器修改库代码,就可以在任何地址加载和执行这些代码。
-
在一个IA32系统中,对同一个目标模块中过程的调用不需要特殊处理的,因为引用是PC相关的,已知偏移量,就是PIC了。然而,对外部定义的过程调用和对全局变量的引用通常不是PIC,因为它们都要求在链接时重定位。
-
如何对全局变量生成PIC引用呢?基于以下事实:无论我们在存储器中的何处加载一个目标模块(包括共享模块),数据段总是分配为紧随在代码段后面。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。
关于具体示例,可以参见本书7.12节。
十三、处理目标文件的工具
GNU binutils包。
AR:创建静态库,插入、删除、列出和提取成员
STRINGS:列出一个目标文件中所有可打印的字符串
STRIP:从目标文件中删除符号表信息
NM:列出一个目标文件的符号表中定义的符号
SIZE:列出目标文件中节的名字和大小
READELF:显示一个目标文件的完整大小,包括ELF头中编码的所有信息。包含SIZE和NM的功能
OBJDUMP:所有二进制工具之母。能够显示一个目标文件中的所有信息。它最大的作用是反汇编.text节中的二进制指令
Unix系统为操作共享库还提供了LDD程序:
LDD——列出一个可执行文件在运行时所需要的共享库
小结
链接可以在编译时由静态编译器完成,也可以加载和运行时由动态编译器完成。链接器处理称为目标文件的二进制文件,它有三种不同的形式:可重定位的,可执行的,和共享的。可重定位的目标文件由静态链接器组合成一个可执行的目标文件,它可以加载到存储器中并执行。共享目标文件(共享库)是在运行时由动态链接器链接和加载的,或者隐含地在调用程序被加载和开始执行时,或者根据需要在程序中调用dlopen库的函数时。
链接器的两个主要任务是符号解析和重定位。符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,重定位确定每个符号的最终存储器地址并修改对那些目标的引用。