整个静态链接会使用下面两个源代码例子来展开分析,使用gcc -c a.c b.c
将源文件分别编译成目标文件a.o
和b.o
。
// 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
,输出文件是可执行文件ab
。可执行文件中的代码段和数据段都是由输入的目标文件合并而来的。对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?
一个最简单的方案就是将输入的目标文件按照次序叠加起来,如下图所示:直接将各个目标文件依次合并。但是这样做会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。这种做法非常浪费空间,因为每个段都需要有一定的地址和空间对齐要求,比如对于x86的硬件来说,段的装载地址和空间的对齐单位是页,也就是4096字节,那么就是说如果一个段的长度只有1个字节,它也要在内存中占用4096字节。这样会造成内存空间大量的内部碎片。
一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的”.text
”合并到输出文件的”.text
”段,接着是”.data
”段、”.bss
”段等,如下图所示:”.bss
”段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将”.bss
”合并,并且分配虚拟空间。
“链接器为目标文件分配地址和空间”这句话中的”地址和空间”其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,比如”.text
”和”.data
”来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;而对于”.bss
”这样的段来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。
现在的链接器空间分配(只关注于虚拟地址空间的分配)的策略基本上都采用上述方法中的第二种(相似段合并),使用这种方法的链接器一般都采用一种叫两步链接(Two-pass Linking)的方法。也就是说整个链接过程分两步:
使用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来查看链接前后地址的分配情况,如下图所示:
VMA表示Virtual Memory Addredd,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个值应该是一样的,但是有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。这里只关注VMA即可。
链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面各个段中的VMA和Size,而忽略文件偏移(File off)。我们可以看到,在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以他们默认都为0。等到链接之后,可执行文件”ab”中的各个段都被分配到了相应的虚拟地址。这里的输出程序”ab”中,”.text
”段被分配到了地址0x08048094
,大小为0x00000071
字节;”.data
”段从地址0x08049108
开始,大小为0x00000004
字节。可以看到,”a.o”和”b.o”的代码段被先后叠加起来,合并成”ab”的一个”.text
”段,加起来的长度为0x00000071
。所以”ab”的代码段里面肯定包含了main
函数和swap
函数的指令代码。
为什么链接器将可执行文件ab的.text
分配到0x08048094
、将.data
分配到0x08049108
?而不是虚拟地址空间的0开始呢?这涉及到操作系统进程的虚拟地址空间的分配规则,在Linux下,ELF可执行文件默认从地址0x08048094
开始分配,详细分配方式会在“可执行文件的装载与进程”讲解。
在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了,比如”.text
”段起始地址为0x08048094
,”.data
”段的起始地址为0x08049108
。
当前面一步完成之后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的,所以这时候其实”main
”、”shared
”和”swap
”的地址也已经是确定的了,只不过链接器须要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。比如我们假设”a.o”中的”main
”函数相对于”a.o”的”.text
”段偏移是X
,但是经过链接合并以后,”a.o”的”.text
”段位于虚拟地址0x08048094
,那么”main”的地址应该是0x08048094+X
从前面objdump的输出看到,”main
”位于”a.o”的”.text
”段的最开始,也就是偏移为0,所以”main
”这个符号在最终的输出文件中的地址应该是 0x08048094+0
,即0x08048094
。我们也可以通过完全一样的计算方法得知所有符号的地址,在这个例子里面,只有三个全局符号,所以链接器在更新全局符号表的符号地址以后,各个符号的最终地址如下图所示:
重定位就是重新定位符号的地址。在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤。
a.o的代码段反汇编结果如下图所示:在程序的代码里面使用的都是虚拟地址,在这里也可以看到”main
”的起始地址为0x0000000000000000
,这是因为在未进行前面提到过的空间分配之前,目标文件代码段中的起始地址以0x0000000000000000
开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置。从反汇编结果中,可以看到a.o共定义了一个函数main
。这个函数占用0x33个字节,共17条指令;最左边那列是每条指令的偏移量,每一行代表一条指令(x86是变长指令,有些指令的长度很长,RISC指令是定长,32位)。图中用加粗体标出了两个引用”shared
”和”swap
”的位置,对于”shared
”的引用是一条”mov
”指指令。另外一个是偏移为0x20的指令的一条调用指令,它其实就表示对swap
函数的调用。
链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。用objdump来反汇编输出程序”ab”的代码段,可以看到main
函数的两个重定位入口都已经被修正到正确的位置,如下图所示:经过修正以后,”shared
”和“swap
”的地址分别为0x08049108
和0x00000009
(小端序列)。为什么swap
的地址为0x00000009
呢?这是因为call
指令是一条近址相对位移调用指令,它后面跟的是调用指令的下一条指令的偏移量,下一条指令是add指令,所以swap
的真实地址为0x080480bf + 0x00000009
。
专门用来保存与重定位相关的信息,它在ELF文件中往往是一个或多个段。对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。如代码段“.text
”如有要被重定位的地方,那么会有一个相对应叫“.rel.text
”的段保存了代码段的重定位表;如果数据段“.data
”有要被重定位的地方,就会有一个相对应叫“.rel.data
”的段保存了数据段的重定位表。可以使用objdump来查看文件的重定位表,如下图所示:”objdump -r a.o
”命令可以用来查看”a.o”里面要重定位的地方,即”a.o”所有引用到外部符号的地址。
每个要被重定位的地方叫一个重定位入口(Relocation Entry),可以看到”a.o”里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]”表示这个重定位是代码段的重定位表,所以偏移表示代码段中需要被调整的位置。对照前面的反汇编结果可以知道,这里的0x1c
和0x27
分别就是代码段中的”mov
”指令和”callq
”指令的地址部分。
如果直接使用ld来链接”a.o”,而不将”b.o”作为输入,链接器就会发现shared
和swap
两个符号没有被定义,没有办法完成链接工作,如下图所示:就是链接时符号未定义。导致这个问题的原因很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。
其实重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其它目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。比如查看”a.o”的符号表,如下图所示:
”GLOBAL”类型的符号,除了”main
”函数是定义在代码段之外,其它两个”shared
”和”swap
”都是”UND”,即”undefined”未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。
不同的处理器指令对于地址的格式和方式都不一样。寻址方式有如下区别:近址寻址或远址寻址;绝对寻址或相对寻址;寻址长度为8位、16位、32位或64位。绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。
重定位指令修正方法: