链接、装载与库(四) 静态链接

对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。

“链接器为目标文件分配地址和空间”这句话中的”地址和空间”其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,比如”.text”和”.data”来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;而对于”.bss”这样的段来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。

整个链接过程分两步:

第一步:空间与地址分配:扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。

第二步:符号解析与重定位:使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

ELF文件还定义了两种特殊的段:

(1) .init:该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。

(2) .fini:该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

1. 空间与地址分配

相似段合并:将所有输入文件的”.text”合并到输出文件的”.text”段,接着是”.data”段、”.bass”段等,如下图所示:”.bss”段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将”.bss”合并,并且分配虚拟空间。

测试代码a.c和b.c内容如下:

// a.c
#include "b.h"

extern int shared;
 
int main()
{
    
    
	int a = 100;
	swap(&a, &shared);
}
//b.h
void swap(&a, &shared);
// b.c
#include "b.h"

int shared = 1;
 
void swap(int* a, int* b)
{
    
    
	*a ^= *b ^= *a ^= *b;
}

编译:

gcc -c a.c b.c 
ld a.o b.o -e main -o ab

在执行第二步链接时,系统报错:
在这里插入图片描述
出错原因编译源码到目标文件时,一定要加“-fno-stack-protector”,不然默认会调函数“__stack_chk_fail”进行栈相关检查,然而是手动裸ld去链接,没有链接到“__stack_chk_fail”所在库文件,所以在链接过程一定会报错: undefined reference to `__stack_chk_fail’

解决办法不是在链接过程中,而是在编译时加此参数,强制gcc不进行栈检查,从而解决:

gcc -c a.c b.c -fno-stack-protector
ld a.o b.o -e main -o ab

使用objdump来查看链接前后地址的分配情况,如下图所示:
在这里插入图片描述

VMA表示Virtual Memory Addredd,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个值应该是一样的,但是有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。

在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以他们默认都为0。

等到链接之后,可执行文件”ab”中的各个段都被分配到了相应的虚拟地址。这里的输出程序”ab”中,”.text”段被分配到了地址0x00000000004000e8,大小为0x00000079字节;”.data”段从地址0x0000000000601000开始,大小为0x00000004字节。可以看到,”a.o”和”b.o”的代码段被先后叠加起来,合并成”ab”的一个”.text”段,加起来的长度为0x00000079。所以”ab”的代码段里面肯定包含了main函数和swap函数的指令代码。

符号地址的确定:在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了,比如”.text”段起始地址为0x00000000004000e8,”.data”段的起始地址为0x 00000000006001b8。当前面一步完成之后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的,所以这时候其实”main”、”shared”和”swap”的地址也已经是确定的了,只不过链接器须要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。

2. 符号解析与重定位

重定位:在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤。

使用objdump的”-d”参数查看”a.o”的代码段反汇编结果,如下图所示:
在程序的代码里面使用的都是虚拟地址,在这里也可以看到”main”的起始地址为0x0000000000000000,这是因为在未进行前面提到过的空间分配之前,目标文件代码段中的起始地址以0x0000000000000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置。从反汇编结果中,可以看到”a.o”共定义了一个函数main。这个函数占用0x2d个字节,共12条指令;最左边那列是每条指令的偏移量,每一行代表一条指令(有些指令的长度很长)。shared地址0x13,swap地址0x22。
在这里插入图片描述
在这里插入图片描述

反汇编分析:https://blog.csdn.net/weixin_43202635/article/details/112167239

链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地位修正。用objdump来反汇编输出程序”ab”的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置,如下图所示:经过修正以后,”shared”和“swap”的地址分别为0x4000fb和0x40010a。

在这里插入图片描述

重定位表:专门用来保存与重定位相关的信息,它在ELF文件中往往是一个或多个段。对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。可以使用objdump来查看文件的重定位表,如下图所示:”objdump -r a.o”命令可以用来查看”a.o”里面要重定位的地方,即”a.o”所有引用到外部符号的地址。每个要被重定位的地方叫一个重定位入口(Relocation Entry),可以看到”a.o”里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,”RELOCATION RECORDS FOR [.text]”表示这个重定位是代码段的重定位表,所以偏移表示代码段中需要被调整的位置。对照前面的反汇编结果可以知道,这里的0x16和0x23分别就是代码段中的”mov”指令和”callq”指令的地址部分。

在这里插入图片描述
对于64位的重定位表是一个Elf64_Rel结构的数组,每个数组元素对应一个重定位入口。Elf64_Rel的定义如下:

typedef struct
{
    
    
	Elf64_Addr    r_offset;			/* Address */
	Elf64_Xword   r_info;			/* Relocation type and symbol index */
} Elf64_Rel;

符号解析:重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其它目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。比如查看”a.o”的符号表,如下图所示”GLOBAL”类型的符号,除了”main”函数是定义在代码段之外,其它两个”shared”和”swap”都是”UND”,即”undefined”未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

指令修正方式:不同的处理器指令对于地址的格式和方式都不一样。寻址方式有如下区别:近址寻址或远址寻址;绝对寻址或相对寻址;寻址长度为8位、16位、32位或64位。绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。

4.重复代码消除 and 全局构造与析构

C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的有两个方面,一个是C++的重复代码消除,还有一个就是全局构造与析构。另外由于C++语言的各种特性,比如虚函数、函数重载、继承、异常等,使得它背后的数据结构异常复杂,这些数据结构往往在不同的编译器和链接器之间相互不能通用,使得C++程序的二进制兼容性成了一个很大的问题。

重复代码消除:C++编译器在很多时候会产生重复的代码,比如模板(Templates)、外部内联函数(Extern Inline Function)和虚函数表(Virtual Function Table)都有可能在不同的编译单元里生成相同的代码。如模板,从本质上来讲很像宏,当模板在一个编译单元里被实例化时,它并不知道自己是否在别的编译单元也被实例化了。所以当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会生成重复的代码。当然,最简单的方案就是不管这些,将这些重复的代码都保留下来,不过这样做的主要问题有以下几个方面:空间浪费、地址容易出错、指令运行效率较低。

函数级别链接:由于现在的程序和库通常来讲都非常庞大,一个目标文件可能包含成千上百个函数或变量。当我们需要用到某个目标文件中的任意一个函数或变量时,就需要把它整个地链接起来,也就是说那些没有用到的函数也被一起链接了起来。这样的后果是链接输出文件会变得很大,所有用到的没用到的变量和函数都一起塞到了输出文件中

VISUAL C++编译器提供了一个编译选项叫函数级别链接(Functional-Level Linking, /Gy),这个选项的作用就是让所有的函数都向前面模板函数一样,单独保存到一个段里面。当链接器需要用到某个函数时,它就将它合并到输出文件中,对于那些没有用的函数则将它们抛弃。这种做法可以很大程度上减少输出文件的长度,减少空间浪费。但是这个优化选项会减慢编译和链接过程,因为链接器需要计算各个函数之间的依赖关系,并且所有函数都保存到独立的段中,目标函数的段的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目的增加也会变得相对较大。

GCC编译器也提供了类似的机制,它有两个选择分别是”-ffunction-sections”和”-fdata-sections”,这两个选项的作用就是将每个函数或变量分别保持到独立的段中。

全局构造与析构:一般的一个C/C++程序时从main开始执行的,随着main函数的结束而结束。然而,其实在main函数被调用之前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化(malloc, free)、线程子系统等。C++的全局对象构造函数也是在这一时期被执行的,C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行

Linux系统下一般程序的入口是”_start”,这个函数是Linux系统库(Glibc)的一部分。当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序的主体。在main函数执行完成以后,返回到初始化部分,它进行一些清理工作,然后结束进程。

ELF文件还定义了两种特殊的段:

(1) .init:该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。

(2) .fini:该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

这两个段.init和.fini的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fini段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数就由此实现。

C++与ABI如果要使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。API往往是指源代码级别的接口;而ABI是指二进制层面的接口。影响ABI的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响ABI。

5. 静态库链接

一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如我们在Linux中最常用的C语言静态库libc位于/usr/lib/x86_64-linux-gnu/libc.a,它属于glibc项目的一部分;像Windows这样的平台上,最常使用的C语言库是由集成开发环境所附带的运行库,这些库一般由编译器厂商提供,比如Visual C++附带了多个版本的C/C++运行库。

在一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。glibc本身是用C语言开发的,它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如输入输出有printf.o,scanf.o;文件操作有fread.o, fwrite.o;时间日期有date.o, time.o;内存管理有malloc.o等。把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们使用”ar”压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a这个静态库文件。

我们也可以使用”ar”工具来查看这个文件包含了哪些目标文件。

ar -t /usr/lib/x86_64-linux-gnu/libc.a

Visual C++也提供了与Linux下的ar类似的工具,叫lib.exe,这个程序可以用来创建、提取、列举.lib文件中的内容。使用”lib.exe /LIST …/libcmt.lib”就可以列举出libcmt.lib中所有的目标文件。

可以通过GCC的”–verbose”参数将整个编译链接过程的中间步骤打印出来,默认情况下,GCC会自作聪明地将”Hello World”程序中只使用了一个字符串参数的”printf”替换成”puts”函数,以提高运行速度,要使用”-fno-builtin”参数关闭这个内置函数优化选项,执行结果如下图所示:

关键的三个步骤在图中已经用红线框起来了,第一步是调用cc1程序,这个程序实际上就是GCC的C语言编译器,它将”hello.c”编译成一个临时的汇编文件”/tmp/ccRURta3.s”;然后调用as程序,as程序是GNU的汇编器,它将”/tmp/ccRURta3.s”汇编成临时目标文件”/tmp/ccAATVNy.o”,这个”/tmp/ccAATVNy.o”实际上就是”hello.o”;接着最关键的步骤是最后一步,GCC调用collect2程序来完成最后的链接。实际上collect2可以看做是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构。可以看到最后一步中,有几个库和目标文件被链接入了最终可执行文件。

hello.c

#include <stdio.h>
 
int main()
{
    
    
	printf("Hello World\n");
	return 0;
}

在这里插入图片描述
为什么静态运行库里面一个目标文件只包含一个函数比如libc.a里面pritnf.o只有printf()函数。链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那么没有被用到的目标文件(函数)就不要链接到最终的输出文件中

6. 链接过程控制

由于整个链接过程有很多内容需要确定:使用哪些目标文件?使用哪些库文件?是否在最终可执行文件中保留调试信息、输出文件格式(可执行文件还是动态链接库)?还要考虑是否要导出某些符号以供调试器或程序本身或其它程序使用等。

链接控制脚本:链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下三种方法:

(1). 使用命令行来给链接器指定参数,如前面所使用的ld的-o, -e参数就属于这类。

(2). 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。方法也比较常见,比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段以用来传递参数。

(3). 使用链接控制脚本,也是最为灵活、最为强大的链接控制方法。

由于各个链接器平台的链接控制过程各不相同。ld链接器的链接脚本功能非常强大。VISUAL C++也允许使用脚本来控制整个链接过程,VISUAL C++把这种控制脚本叫做模块定义文件(Module-Definition File),它们的扩展名一般为.def。

ld在用户没有指定链接脚本的时候会使用默认链接脚本。我们可以使用”$ ld -verboase”命令来查看ld默认的链接脚本。默认的ld链接脚本存放在/usr/lib/ldscripts/下,不同的机器平台、输出文件格式都有相应的链接脚本。当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本,比如可以使用-T参数,如link.script已存在,执行命令”$ ld -T link.script”。

使用ld链接脚本:如果把整个链接过程比作一台计算机,那么ld链接器就是计算机的CPU,所有的目标文件、库文件就是输入,链接结果输出的可执行文件就是输出,而链接控制脚本正是这台计算机的”程序”,它控制CPU的运行,以”程序”要求的方式将输入加工成所需要的输出结果。链接控制脚本”程序”使用一种特殊的语言写成,即ld的链接脚本语言。可以通过ld的-s参数禁止链接器产生符号表,或者使用strip命令去除程序中的符号表

7. BFD库

BFD库(Binary File Descriptor library)是一个GNU项目,它的目标就是希望通过一种统一的接口来处理不同的目标文件格式。BFD这个项目本身是binutils项目的一个子项目。BFD把目标文件抽象成一个统一的模型,比如在这个抽象的目标文件模型中,最开始有一个描述整个目标文件总体信息的”文件头”,就跟我们实际的ELF文件一样,文件头后面是一系列的段,每个段都有名字、属性和段的内容,同时还抽象了符号表、重定位表、字符串表等类似的概念,使得BFD库的程序只要通过操作这个抽象的目标文件模型就可以实现操作所有BFD支持的目标文件格式。

现在GCC、链接器ld、调试器GDB及binutils的其它工具都通过BFD库来处理目标文件,而不是直接操作目标文件。这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来,一旦我们需要支持一种新的目标文件格式,只需要在BFD库里面添加一种格式就可以了,而不需要修改编译器和链接器。

猜你喜欢

转载自blog.csdn.net/weixin_43202635/article/details/112154596