0%

《程序员的自我修养》 Ch4 静态链接

所谓静态链接,就是在链接阶段把所有参与链接的目标文件合并到一个最终的可执行文件中。有静态就有动态,后面会讲的动态链接就是链接过程中只是指明了主程序在运行时需要哪些动态链接库(模块),最终链接得到的可执行文件并没有包含所有模块的代码,而是在运行过程中会动态地加载那些模块。

静态链接的过程

从目标文件到最终的可执行文件,主要分为两步:

  1. 空间与地址分配,这里的空间指的是目标文件的段在可执行文件中的空间位置,地址指的是每一条指令的虚拟地址,此步骤完成后就能确定目标文件中全局符号在可执行文件中的实际位置(虚拟地址),为第2步做准备。
  2. 符号解析与重定位,即根据重定位表的信息和第1步得到的全局符号地址对引用位置的地址进行更新。

空间与地址分配

从前面的章节可以知道,每一个目标文件都是许多段组成的,要把许多目标文件这么多不同的段合并到最终的可执行文件里,一个很自然的想法就是把不同文件中的同一种段(.text或.data或其他的)合并成一个段,实际采用的就是这种方案。

4-1空间分配策略

图4-1 空间分配策略

在合并的过程中,链接器会把所有的目标文件的符号表收集起来,组成一个新的全局符号表,为后续的符号解析和重定位做准备。

将段合并之后,就可以得到各个符号在可执行文件的段中的位置(偏移)与分配的虚拟地址(VMA)了。

这里讲一下虚拟地址(VMA)和加载地址(LMA)。

对于每一个进程,在操作系统(OS)中都有一个独立的假想的地址空间,我们称之为虚拟地址空间,虚拟地址空间可以起到隔离不同进程的作用。

现代的可执行文件中,使用的都是虚拟地址空间中的地址,在加载到物理内存的过程中,还需要经过一个地址转换的硬件模块(MMU),将虚拟地址转换为物理地址。

PS:如果没有MMU的支持,那么目标文件中的虚拟地址就等于物理地址。

加载地址,对于普通的PC机,即是把程序从硬盘中加载到内存中之后的地址,一般来说加载地址与虚拟地址是相同的(即加载到哪就在哪运行),除了嵌入式的特例(自行百度)。

符号解析与重定位

所谓符号解析,就是查找合并后文件中的段中引用的全局符号的定义(即找到定义的虚拟地址),这个步骤依赖的是全局符号表。

重定位就是修正指令中引用符号的地址,利用的是重定位表,重定位表的每一项对应一个需要修正的位置的偏移(每一个需要重定位的地方叫做重定位入口),同时包含修正方法的类型以及该符号在符号表中的地址。

修正方法的类型总的可以分为绝对地址修正相对地址修正两类。

以下是每个重定位入口对应的包含重定位需要的信息的数据结构:

1
2
3
4
5
typedef struct{
int offset; // 需要被修改的引用的段偏移(重定位入口地址)
int symbol:24, // 被修改的引用应指向的符号
type:8; // 修改方式
}ELF32_REL;

重定位段(表)的内容就是一个该数据结构的数组。

静态库链接

静态库就是一系列编译好的还未链接的目标文件的集合(通过ar打包),一般是一个库函数对应一个目标文件,因为这样可以实现用到什么函数就链接什么函数,不会附带把不需要的函数链接到可执行文件中,从而节省了空间。

这里也有张图.jpg

链接过程控制

链接控制过程有两种方式:

  1. 命令行参数式
  2. 链接脚本式

链接脚本的方式灵活性很强,甚至可以自定义段名。在进行嵌入式或无操作系统的平台开始时,通常会用这种方式来控制可执行文件的段的分布,以满足硬件的要求。

COMMON块

在链接之前的目标文件中,未初始化的全局变量会被标记为COMMON类型,且不会直接在.bss段中分配空间,为什么呢?

本质原因是链接器不能区分符号的类型,无法判断若符号的类型是否一致,导致弱符号的占用空间在链接前不能确定(强符号和弱符号那一节中讲过)。

C++相关问题

主要有两个问题:

  1. 重复代码消除
  2. 全局对象的构造和析构

重复代码消除

这里要消除的代码主要是由C++的模板机制、外部内联函数、虚函数表等造成的冗余。以模板为例,如果在不同的源代码文件(编译单元)中可能对同一个模板add<T>()进行了实例化,且实例化参数相同add<int>(),那么就会在不同目标文件中生成相同的实例化代码,如果直接合并的话,就会造成冗余。

解决的方法是对每一个实例化后的模板,将其单独存在在目标文件中的一个段中,这个段的名字是该实例化模板修饰后的名称(例如:.temp.add<int>),保证了唯一性,于是不同目标文件中的同一种实例化模板就能被检查出来,在合并的时候只需保留一个段就行。

函数级链接

函数级别链接,就是把每一个函数都单独地放到一个段里,链接器检测到用到了哪个函数就链接相应的段,没有用到的不会被链接到最终的文件里,与前面的静态库链接差不多。其目的也是为了减小最终的文件大小。

全局对象的构造和析构

在最终的可执行文件中,真正的程序入口是_start(Linux系统下),在这个函数中进行进程初始化工作,然后在其中调用main函数,进入到用户定义的主程序。全局类的构造就是在进入main之前这一段过程完成的,同样,其他基本类型的全局变量也是在这一段过程中完成初始化的。在main函数结束之后,会返回到_start中,进行一些清理工作(例如全局类的析构),然后再结束进程。

对此,ELF文件中有两个特殊的段:.init.fini。在.init段中的代码,会在main函数之前执行,而在.fini段中的代码,会在main函数之后执行。

ABI(Application Binary Interface)

API(Application Programming Interface)是源码级别的接口,而ABI是二进制级别的接口。

与ABI相关的东西有很多,具体包括:符号修饰标准,变量内存布局,函数调用方式等跟可执行文件二进制兼容性相关的内容。同时,机器的硬件平台、编程语言、编译器、链接器、操作系统等都会影响到ABI,所以,ABI的标准化一直是一个大问题。不同目标文件的ABI不同的话,就不能相互链接起来。

BFD库

BFD全称Binary File Descriptor library,是GNU的一个项目,目的是为了处理不同的目标文件格式。