深入理解重定位全过程
转载整理:从编译器、链接器的角度介绍重定位的全过程和利用一个实例帮助理解。
参考资料
重定位过程解析
重定位分为对定义符号的重定位和对引用符号的重定位,定义符号新的地址只需要加上所在段的新的偏移地址即可,而引用符号的重定位需要用到重定位表与符号表。
原文:参考资料1
重定位表与符号表
对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。
通过命令可以查看目标文件的重定位表。
OFFSET是重定位的入口偏移,表示该入口在要被重定位的段中的位置。“.text”表示这个重定位表示代码段的重定位表,所以偏移表示代码段中需要被调整的位置。这里的0x1c和0x27分别就是代码段中“mov”指令和“call”指令的地址部分。
重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
通过命令查看“a.o”的符号表。
可以看到shared和swap的类型都是“UND”,即“undefined”未定义类型,在链接器扫描完所有的输入目标文件后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。这种一般都是链接时缺少了某些库,或者输入目标文件路径不正确或符号的声明与定义不一样。
重定位模式(指令修改方式)
不同的处理器指令对于地址的格式和方式都不一样。
对于32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:
- 绝对近址32位寻址。
- 相对近址32位寻址。
这两种重定位方式指令修正方式每个被修正的位置的长度都是32位。
这两种方式的定义:
实例
看这段代码的反汇编结果。
“main”的起始地址为0x00000000,这是因为在未进行空间分配之前,目标文件代码段中的起始地址以0x00000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置。
偏移为0x18的地址上是一条mov指令,总共8个字节,它的作用是将“shared”的地址赋值到esp寄存器+4的偏移地址中去,前面4个字节“c7442404”是mov的指令码,后面4个字节是“shared”的地址。
偏移为0x26的地址上是一条调用指令,它表示对swap函数的调用。这条指令共5个字节,前面的0xe8是操作码,这是一条近址相对位移调用指令,后面4个字节就是被调用函数的相对于调用指令的下一条指令的偏移量。在没有重定位之前,相对偏移被置为0xFFFFFFFC(小端),它是常量“-4”的补码形式。
通过前面的重定位表可以看到swap符号的类型为R_386_PC32,这是一条相对位移调用指令。而shared符号的类型为R_386_32,它修正的是一条传输指令的源,即shared的绝对地址。
假设在将a.o和b.o链接成最终可执行文件后,main函数的虚拟地址为0x1000,swap函数的虚拟地址为0x2000,shared变量的虚拟地址为0x3000。
首先看偏移为0x18的这条mov指令的修正,它是绝对寻址修正,它修正后的结果是S+A。
- S是符号shared的实际地址,即0x3000。
- A是被修正位置的值,即0x00000000。
所以它的修正后的地址为:0x3000+0x00000000=0x3000。
再来看偏移为0x26的这条call指令的修正,它是相对寻址修正,它修正后的结果是S+A-P。
- S是符号swap的实际地址,即0x2000。
- A是被修正位置的值,即0xFFFFFFFC(-4)。
- P为被修正的位置,当链接成可执行文件时,这个值应该是被修正位置的虚拟地址,即0x1000+0x27。
所以它的修正后的地址为0x2000+(-4)-(0x1000+0x27)=0xFD5。
这条相对位移调用指令的调用地址是该指令下一条指令的起始地址加上偏移量,即:0x102b+0xfd5=0x2000,刚好是swap函数的地址。
从这两个例子可以看出来,绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。
编译器的工作
原文:参考资料2
编译器在将源文件编译生成目标文件时可以确定一下两件事:
- 定义在该源文件中函数的内存地址
- 定义在该源文件中全局变量的内存地址
注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于自己的。为什么只是一个相对地址呢?因为在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行链接生成最后的可执行文件,链接器才知道要链接哪些目标文件,因此编译器仅仅生成一个相对地址。
而对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是无法确定其内存地址的,这不是编译器需要关心的,确定引用类变量的内存地址是链接器的任务,链接器在进行链接时能够确定这类变量的内存地址。因此当编译器在遇到这样的变量时,比如使用了外部定义的函数时,其在目标文件中对应的机器指令可能是这样的:
1 | call 0x000000 |
也就是说对于编译器不能确定的地址都这设置为空(0x000000),同时编译器还会生成一条记录,该记录告诉链接器在进行链接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text段中。相应的如果是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data段中。即链接器需要在链接过程中根据.rel.data以及.rel.text来填好编译器留下的空白位置(0x000000)。因此在这里我们进一步丰富目标文件中的内容,如图所示:
生成目标文件后,编译器完成任务,编译器确定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能确定的引用类变量,编译器在目标文件的.rel.text以及.rel.data段中生成相应的记录告诉链接器要修正这些变量的地址。
接下来就是链接器的工作了。
链接器的工作
原文:参考资料2
我们在静态库下可执行文件的生成一节中知道,链接器会将所有的目标文件进行合并,所有目标文件的数据段合并到可执行文件的数据段,所有目标文件的代码段合并到可执行文件的代码段。当所有合并完成后,各个目标文件中的相对地址也就确定了。因此在这个阶段,链接器需要修正目标文件中的相对地址。
在这里我们以合并目标文件中的数据段为例来说明链接器是如何修正目标文件的相对地址的,合并代码段时修正相对位置的原理是一样的。
我们假设链接器需要链接三个目标文件:
- 目标文件一:该文件数据段定义了两个变量apple和banana,apple的长度为2字节,banana的长度4字节,因此目标文件一的数据段长度为6字节。从图中也可以看出apple的内存地址为0,也就是相对地址,即apple这个变量在目标文件一的地址是0,banana的地址为2。
- 目标文件二:该文件的数据段比较简单,只定义了一个变量orange,其长度为2,因此该目标文件的数据段长度为2。
- 目标文件三:该文件的数据段定义了三个变量grape、mango以及limo,其长度分别为4字节、2字节以及2字节,因此该目标文件的数据段长度为8字节。
链接器在链接三个目标文件时其顺序是依次链接的,链接完成后:
- 目标文件一:该数据段的起始地址为0,因此该数据段中的变量的最终地址不变。
- 目标文件二:由于目标文件一的数据段长度为6,因此链接完成后该数据段的起始地址为6(这里的起始地址其实就是偏移offset),相应的orange的最终内存地址为0+offset即6。
- 目标文件三:由于前两个数据段的长度为8,因此该数据段的起始地址为8(即offset为8),因此所有该数据段中的变量其地址都要加上该offset,即grape的最终地址为8,即0+offset,mango的最终地址为4+offset即12,limo的最终地址为6+offset即14。
从这个过程中可以看到,数据段中的相对地址是通过这个公式来修正的,即:
1 | 相对地址 + offset(偏移) = 最终内存地址 |
而每个段的偏移只有在链接完成后才能确定,因此对相对地址的修正只能由链接器来完成,编译器无法完成这项任务。
当所有目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置。
至此,重定位的第一阶段完成。接下来是重定位的第二阶段,即引用符号的重定位。
相对地址是编译器在编译过程中确定了,在链接器完成后被链接器修正为最终地址,而对于编译器没有确定的所引用的外部函数以及变量的地址,编译器将其记录在了.rel.text和.rel.data中。
由于在第一阶段中,所有函数以及数据都有了最终地址,因此重定位的第二阶段就相对简单了。我们知道编译器引用外部变量时将机器指令中的引用地址设置为空(比如call 0x000000),并将该信息记录在了目标文件的.rel.text以及.rel.data段中。因此在这个阶段链接器依次扫描所有的.rel.text以及.rel.data段并找到相应变量的最终地址(这些位置都已在第一阶段确定),并将机器指令中的0x000000修正为所引用变量的最终地址就可以了。
作为程序员一般很少会有问题出现在重定位阶段,因此这个阶段对程序员相对透明。请同学们注意一点,这里的分析仅限于目标文件的静态链接。我们知道静态链接下,链接器会将需要的代码和数据都合并到可执行文件当中,因此需要确定代码和数据的最终位置。而对于动态链接库来说情况则有所不同,动态链接库可以同时被多个进程使用,如果动态链接库的机器指令中不可以存在引用变量的最终位置,否则在被多个进程使用时会出现一个进程中使用的数据被其它进程修改。因此动态库下的机器指令都是PIC代码,即位置无关代码(Position-Independent Code)。