当我们有两个目标文件时,如何将它链接起来成一个可执行文件?这个过程发生了什么?这基本上就是链接的核心内容:静态链接。
我们以使用下面源文件“a.c”和“b.c"作为例子展开分析:
/*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;
}
我们首先将编译成a.o和b.o;从代码中可以看到,“b.c”总共定义了两个全局符号,一个是变量“shared”,另外一个是“swap”;“a.c”里面定义了全局符号是“main”。模块a.c引用到了b.c中的swap和shared。接下来我们要做的就是将“a.o”“b.o”这两个目标文件链接在一起并最终形成一个可执行文件"ab";
1.空间与地址分配
对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。可执行文件中代码段和数据段都是由输入的目标文件中合并而来的,那么我们链接过程就很明显产生了第一个问题,对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?或者说,输出文件中的空间如何分配给输入文件?
1.1 按序叠加
一个最简单的方案就是将输入目标文件按照次序叠加起来,如图1所示:
问题:
这样在有很多输入文件的情况下,输出文件将会有很多零散的段。比如一个稍大规模的应用程序可能会有数百个目标文件,如果每个目标文件都分别有.text段、.data段和.bss段,那么最后输出的文件会有许多零散的段。这种做法非常浪费空间,因为每个段都会要求字节对齐要求,比如对于x86空间来说,段的装载地址和空间的对齐单元为页,也就是4096字节。那么就是说如果一个段的长度只有一个字节,它也要在内存中占用4096字节,这样会造成空间内存的大量内部碎片。所以并不是很好的方案;
1.2 相似段合并
一个更实际的方法是将相同性质的段合并在一起,比如讲所有输入文件“.text”合并到输出文件的“.text”段,接着“.data”段、“.bss”段等,如图4-2所示:
正如我们前文所提到的,“.bss”段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以在链接器在合并各个段的同时,也将“.bss”合并,并且分配虚拟空间。
“链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义:
- 在输出的可执行文件中的空间;
- 装载后的虚拟地址中的虚拟地址空间。
比如在“.text”和".data"来说,它们在文件中和虚拟地址都要分配空间,因为它们在这两者都存在;而在“.bss”这样的段来说,分配空间只局限与虚拟地址空间,因为它在文件中并没有内容。事实上,我们在这里谈到的空间分配只关注于虚拟地址空间分配;
现在的链接器空间分配策略基本上采用上述方式中的第二种,使用这种方法的链接器一般都采用一种叫两步链接的方法。也就是整个链接过程分两步。
-
空间与地址分配
扫描所有的输入目标文件,并且获得它们各个段的长度、属性和位置,并且将输入目标文件中的符号表中的所有符号定义和符号引用收集起来,统一放到一个全局符号表。这一步,链接器能够获得所有输入目标段长度,并且将它们合并,计算出输出文件中的各个段合并后的长度与位置,并建立映射关系; -
符号解析与重定位
使用上面一步收集到的所有信息,读取输入段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址。事实上,第二步是链接的核心,特别是重定位的过程。
我们使用ld链接器将“a.o”和“b.o”链接起来:
$ ld a.o b.o -e main -o ab
- -e main 表示将main函数作为程序入口,ld链接器默认入口为_start。
- -o ab表示链接输出文件名为ab,默认为a.out。
让我们使用objdump来查看链接前后地址分配情况,代码如清单4-1所示:
VMA表示虚拟地址,LMA表示加载地址,正常情况下两个值都是一样的,但是在有些嵌入式系统中,特别是程序放在ROM的系统中时,LMA和VMA是不相同。这里我们只要关注VMA即可。
链接前后的程序所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面的VMA和Size,而忽略文件偏移。我们可以看到,在链接之前,目标文件中的所有段VMA都是0,因为虚拟地址还没有分配,所以它们默认都为0;等到链接的之后,可执行文件“ab”中的各个段都被分配到了相应的虚拟地址。
1.3 符号地址的确定
我们还是以“a.o”和“b.o”作为例子,来分析两个步骤中链接器的工作过程。在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后虚拟地址就已经确定,比如“.text”段起始地址为0x08048094,“.data”段的起始地址位0x08049108;
当前面一步完成之后,链接器开始计算每个符号的虚拟地址,因为每个符号在段内的相对位置是固定的,所以其实“main”、“shared”和“swap”的地址已经是确定的了,只不过链接器需要给每个符号增加上一个偏移量,使它们能够调整到正确的虚拟地址。
比如我们假设“a.o”中的main函数相对于“a.o”的text段的偏移是X,但是经过链接合并后,“a.o”的“text”段位于虚拟地址的0x08048094,那么“main”的地址段位于虚拟地址的0x08048094+x,从前面的objdump输出可以看到,“main”这个符号在最终输出文件中的地址应该是0x08048094+0,即0x08048094。我们也可以通过相同方式知道符号的地址;