链接
CSAPP中链接的一点总结
- 链接器操作的目标文件(可重定位)
- ELF可重定位目标文件格式
- 符号和符号表
- 链接器开始工作了
- 符号解析
- 重定位
- 可执行目标文件是什么
- ELF可执行目标文件格式
- 如何加载可执行目标文件
- 链接库
- 编译时加载静态库
- 运行时加载动态库
目标文件
源代码在经过预处理,编译,汇编等操作后生成可重定位目标文件,可重定向文件再通过链接器生成可执行目标文件。可重定位目标文件通过名字可知文件中的一些符号之后需要在内存中重定向。
掌握链接有助于理解分离编译的过程,作用域的实现以及如何利用共享库等。
在不同阶段生成的目标文件可分为三种,之后再每个详细说明:
- 可重定位目标文件
- 可执行目标文件
- 共享目标文件
现代UNIX系统一般使用的目标文件的格式为ELF:

最左边为可重定向目标文件的ELF格式,右边为可执行目标文件的ELF格式。ELF和节头部表之间每个部分都称为一个节(section)。最右边为储存器映像
1 | - .text:已编译程序的机器代码 |
链接器
每个可重定位模块 M 都有一个符号表来保存信息,分为3种:
由 M 模块定义的被其它模块引用的全局符号,包括非静态的函数,非静态的全局变量
其他模块定义被 M 模块引用的全局符号,称为外部符号,对应于定义在其他模块中的函数和变量
只被 M 模块定义和引用的本地符号,对应于static的函数和全局变量,不能被别的模块引用
该目标文件中引用的全局变量以及函数
该目标文件中定义的全局变量以及函数
两个功能:
符号解析:使每个符号引用与符号定义进行关联,对全局符号的,解析比较麻烦,需要在别的模块中进行查找,可能不存该符号在或者存在多个,如果有多个的话需要按规定选择定义,(将符号分为强弱,函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号),使用下面的规则选择多重定义的符号:
- 不允许有多个强符号
- 如果有一个强和多个弱,选择强符号
- 如果有多个弱,任意选择一个
1
2
3
4
5
6
7
8
9
10
11//示例
/*foo.c*/ /*bar.c*/
void f(void); void f()
int x = 15213; {
int main() x = 15212;
{ }
f();
printf("x=%d\n",x);
return 0;
}这里 x 是多重定义,在foo中的是强符号,在bar中的是弱符号。在printf 中使用的肯定是foo中的 x 。而main函数调用 了 f(),f() 中改变的 x 也是 foo 中的 x,因为foo中的x是强符号,所以
x = 15212这个引用关联的是 foo 中的x。相当于忽略了bar 中定义的未初始化的 x。
重定位:编译器和汇编器生成从0开始的的代码和数据节。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这个符号的引用,使它们指向这个存储器位置,从而重定位这些节。步骤:
重定义节和符号的定义:将各个文件内的相同节进行合并为一个新的聚合节,在这一步中,链接器将所有模块中的.data节合并成一个可执行目标文件的.data节,然后链接器将新的存储器地址赋给新的聚合节,此时程序中每个指令和变量都有了一个运行时存储器地址
重定义节中符号的引用:根据
可重定位条目修改符号引用的地址来保证程序的正确性,使得他们指向第一步重定位后的运行时地址。这一步依赖 relocation entry(重定位条目)的数据结构,这个条目由汇编器生成,链接器根据这个结构的内容确定符号引用的运行时地址解释一下relocation entry:因为汇编器生成的可重定位目标模块不知道最终地址是哪里,所以用relocation entry 来告诉链接器在合并目标模块时修改这个引用,代码的该条目保存在 .rel.text 中,数据的保存在 .rel.data 中。
1
2
3
4
5typedef struct {
int offset;
int symbol:24,
type:8;
}上面是重定位条目的数据结构,offset是需要被修改的引用的节偏移(相对于节的偏移),symbol是标识被修改的引用应该指向的符号(的地址),type是高职链接器如果修改新的引用。
**过程:**首先将所有相同类型的节合并为同一类型的新的聚合节,然后将运行时内存地址(虚拟地址)赋给新的聚合节,输入模块定义的节,输入模块定义的符号。最后链接器根据可重定位目标文件的重定位信息修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。可重定位目标文件的`.rel.text`和`.rel·data`节中存放着需要重定位的符号的重定位信息,以结构数组的形式存储。
**类型:**重定位类型与处理器与系统有关,`IA-32`处理器有`16`种重定位类型,`ELF`定义了`32`种重定位类型.
最基本的有`R_x86_64_PC32`(引用处采用`PC`相对地址方式)和`R_x86_64_32`(引用处采用绝对地址方式).
也就是因为符号解释才产生了作用域的区别,static属性的本地变量和全局变量一起被保存在.data和.bss中并且在符号表中创建一个有唯一名字的本地链接器符号,而不是在栈中进行管理,所以static变量不会随函数调用和退出而发生变化。
之后进行链接,链接主要分为静态链接和动态链接两大种类,先讲静态链接,静态库的使用
静态库
为什么出现
使用一些标准函数是很必要的,没有静态库时有几种方式:
- 让编译器辨认出堆标准函数的调用。就是让编译器自带这些函数,这虽然程序员用起来挺方便,但是会增加编译器的复杂度,每次增删改一个标准函数时都需要一个新的编译器版本
- 将所有的标准C函数都放在一个单独的可重定位目标模块中,然后链接该模块到可执行文件中。但是问题是每个可执行文件都会包含一份标准函数集合的完全拷贝,浪费空间。而且对标准函数的任何改变都要求库的开发人员重新编译整个源文件,浪费时间
- 还可以为每个标准函数创建一个独立的可重定位文件,把它们存放在一个为大家都知道的目录中,但需要程序员显式的链接合适的目标模块到它们的可执行文件中,耗时且易出错
所以都不太好用,静态库就解决了上面方法的一些缺点
怎样实现
相关的函数被编译成独立的目标模块,然后封装成一个单独的静态库文件,它可以用作连接器的输入。当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。例如像printf等一些常用的函数,都是在libc.a静态库中,静态库以一种存档的特殊文件(.a)格式,将可以定位的目标文件集合成一个.a文件
其实和上面的 3 方法有点像,但是3的方法在链接时需要显式的输入所有用到的独立目标模块,但静态库则不需要,只用输入静态库的名字就行了

动态库(共享库)
为什么出现
动态库是为了解决静态库的两个弊端而出现的,静态库的两个弊端:
- 静态库更新后,程序要获得该静态库然后再编译。
- 不同程序可能使用相同的静态库,导致很多静态库中的代码重复被加载到存储器中。
实现
以(.so)结尾的文件,即共享目标文件,在运行时被加载到任意存储器地址,并和存储器中的程序链接起来,以后的进程要用到这个库就从这个固定的位置开始访问。这一过程的管理交由动态链接器程序来执行。在内存中一个共享库的
.text节的一个副本会被不同的进程共享- 在第一次链接时静态执行一些链接,此时没有任何动态库的代码和数据节呗拷贝到可执行文件p2中,只拷贝了一些重定位和符号表信息,它们使运行时可以解析对 libvector.so 中代码和数据的引用
- 当加载器加载和运行p2 时,它注意到p2 包含一个 .initerp 节,这个节包含动态链接器的路径名,于是加载器加载和运行这个动态链接器,并完成几个重定位工作
- 重定位libc.so 的文本和数据到某个存储器段
- 重定位 libvector.so 的文本和数据到另一个存储器段
- 重定位 p2 中所有对由libc.so 和libvector.so 定义的符号的引用
- 最后动态链接器将控制传递给应用程序

应用程序还可能从应用程序中加载和链接任意共享库,而无需编译时链接那些库到应用中。Windows中的更新大部分是这个技术。另外还有构建高性能web服务器。
最后是将可执行文件加载到存储器中
加载可执行文件
结构图在上面,其结构整体和可重定位目标文件差不多,不过因为已经完成了重定位(称作完全链接的),所以没有了 .rel.data 和 .rel.text 节了。多了一个 .init 节,定义了一个_init函数,程序的初始化代码会调用它。
代码段总是从0x08048000开始,数据段是接下来的4kb对齐地址处,运行时堆在读写段之后,使用malloc向上增长;还有一个段为共享库保留。用户栈是在最大合法地址处开始并向下增长。再往上就是不对用户开放的内核虚拟存储器了。
什么是加载?说白了就是将程序拷贝到存储器并运行的过程。这里是由 execev函数来调用加载器(驻留在存储器中)完成的,我们要执行p文件的时候,就是使用./p来,加载器就把p的数据和代码拷贝从磁盘拷贝到了存储器中,并通过跳转到ELF头部中的程序入口点开始程序p的执行。
怎样加载?当加载器运行时,就先创建一个存储器映像(上图所示),在ELF可执行文件头部表的指示下,加载器将可执行文件的代码和数据段拷贝到0x0804800处向上的两个段中,然后跳转到程序入口点_start(在ctrl.o中定义)开始执行
下图是加载过程:
