真正了不起的程序员对自己的程序的每一个字节都了如执掌。
当我们有两个目标文件时,如何将它们链接起来形成一个可执行文件?这个过程发生了什么?这基本上就是链接的核心内容:静态链接。
/*a.c*/ extern int shared; int main()//全局符号 { int a=100; swap(&a,&shared); } /*b.c*/ int shared=1;//全局符号 void swap(int* a,int* b)//全局符号 { *a^=b^=*a^=*b; }
4.1 空间与地址分配
对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。
但链接器如何将各个段合并到输出文件?
4.1.1 按序叠加
一个简单方案就是将输入目标文件按照次序叠加起来,就是直接将各个目标文件依次合并。但这样有一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段,这样就是早成内存空间大量的内部碎片,造成空间浪费。
4.1.2 相似段合并
一个更加实际的方法是将相同性质的段合并到一起。
“链接器为目标文件分配地址和空间”中的”地址和空间”有两个含义:
- 第一个是输出的可执行文件中的空间
- 第二个是在装载后的虚拟地址中的虚拟地址空间。
现在的链接器空间分配策略都基本采用相似段合并。使用这种方法的链接器一般都采用一种叫做两步链接的方法:
- 第一步 空间与地址分配
- 第二部 符号解析与重定位
链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,就是段中的VMA和Size,而忽略文件偏移.
整个链接过程前后,目标文件各个段分配、程序虚拟地址:
4.1.3 符号地址的确定
在第一步扫描和空间分配阶段,链接器已经对空间进行了分配,这时候,输入我呢见中的各个段在链接后的虚拟地址就已经确定了。这一步完成后,链接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对位置是固定的,这个时候符号地址也已经确定了。只不过连接器要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。
4.2 符号解析与重定位
4.2.1 重定位
在完成空间和地址的分配步骤之后,链接器就进入了符号解析与重定位的步骤。
这个时候,编译器并不知道”shared”和”swap”的地址,它们是定义在其他目标文件中的,现在只是一个临时假地址。
在链接器完成地址和空间分配之后就可以确定所有符号的虚拟地址了,链接器就可以根据符号的地址对每个需要重定位的指令进行地位修正。
4.2.2 重定位表
ELF文件中,有一个叫做重定位表的结构专门用来保存这些与重定位相关的信息,它在ELF文件中往往是一个或多个段。
重定位表有时候也叫做重定位段。
重定位表的结构,它是一个Elf32——Rel结构的数组:
typedef struct{ Elf32_Addr r_offset; Elf32_Word r_info; }Elf32_Rel;
4.2.3 符号解析
在重定位的过程中也伴随这符号解析的过程,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号组成的全局符号表,找到相应的符号进行重定位。
链接器扫面完所有输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。
4.2.4 指令修正方式
不同处理器指令对于地址格式和方式都不一样。
x86系类寻址方式有如下几方面区别:
- 近址寻址或远址寻址
- 绝对寻址或相对寻址
- 寻址长度为8位、16位、32位、64位
4.3 COMMON块
目前的链接器本身并不支持符号的类型,即变量类型对于链接器来说时透明的,它只知道一个符号的名字,并不知道类型。
多个符号定义类型不一致的几种情况,主要分为三种:
- 两个或两个以上强符号类型不一致
- 有一个强符号,其他都是弱符号,出现类型不一致
- 两个或两个以上弱符号不一致
COMMON块机制:当不同的目标文件需要的COMMON块空间大小不一致,以最大的那块为准。
COMMON类型的列检规则是针对弱符号的情况。
将未初始化的全局变量标记为一个COMMON类型的变量。
4.4 C++相关问题
在编译C++的时候,当模板在一个编译单元里被实例化时,它并不知道自己是否在别的编译单元也被实例化,所以,当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会产生重复代码。
这里最简单的方法就是将这些代码都保存下来,这里会产生几个问题:
- 空间浪费
- 地址较易出错
- 指令运行效率较低
一个比较好的做法就是将每个模板的实例代码都单独存放在一个段里,当同样的模板生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。
GCC把这种类似的做法叫做”Link Once”,它的做法是将这种类型的段命名为”.gnu.linkonce.name”,其中”name”是该模板函数实例的修饰后名称。
这种消除重复代码的方法对于外部内联函数和虚函数一样类似。
函数级别链接
让所有函数都像前面模板函数一样,单独保存到一个段里面。当链接器须要用到某个函数的时候,它就将它合并到输出文件种,对于那些没有用的函数则将它们抛弃。
4.4.2 全局构造与析构
C++全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行。
Linum系统下一般程序的入口是”_start”,当我们程序与Glibc链接在一起形成最终可执行文件之后,这个函数就是程序的初始化入口,它完成之后,就会调用main函数,执行程序主体。mian函数执行完成后,返回初始化部分进行一些清理工作,然后结束进程。
下面的两种特殊段,就是在main前后执行:
- .init 该段里面保存的是可执行指令,它构成了进程的初始化代码
- .fini 该段保存着进程终止代码指令。
4.4.3 C++与ABI
如果要使两个编译器编译出来的目标文件能够互相链接,那么这两个目标文件必须满足下面条件:
- 采用同样的目标文件格式
- 拥有同样的符号修饰标准
- 变量的内存分布方式相同
- 函数的调用方式相同
API与ABI
它们都是应用程序接口,只是它们描述的结构所在的层面不一样。
API往往指源代码级别的接口,而ABI是指二进制层面的接口,ABI兼容程度比API更加严格。
4.5 静态库链接
程序之所以有用,因为它会有输入输出,一个程序要输入输出最简单的办法就是使用操作系统提供的应用程序编程接口(API)。
那么程序是如何使用操作系统提供的API。在一般情况下,一种语言的开发环境就会附带语言库,这些库是对操作系统API的包装。
一个静态文件也可以简单的看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。
链接器在链接静态库的时候,是以目标文件为单位的,所以一个目标文件中只包含一个函数。
4.6 链接过程控制
绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接,但有时候也有特殊情况。
4.6.1 链接控制脚本
链接器一般都提供多种控制整个链接过程的方法,一般链接器有如下三种:
- 使用命令行来给链接器指定参数
- 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令
- 使用链接控制脚本。
4.6.2 使用Id链接脚本
如果把整个链接过程比作一台计算机,那么Id链接器就是计算机的CPU,所有的目标文件、库文件就是输入,链接结果输出的可执行文件就是输出。
链接控制脚本”程序”使用一种特殊的语言写成,即id的链接脚本语言。
我们把输入文件的段称为输入段,输出文件中的段称为输出端。