咱们继续来说解连接器的重定位。程序员
程序的运行过程就是CPU不断的从内存中取出指令而后执行执行的过程,对于函数调用来讲好比咱们在C/C++语言中调用简单的加法函数add,其对应的汇编指令多是这样的:微信
call 0x4004fdapp
其中0x4004fd即为函数add在内存中的地址,当CPU执行这条语句的时候就会跳转到0x4004fd这个位置开始执行函数add对应的机器指令。函数
再好比咱们在C语言中对一个全局变量g_num不断加一来进行计数,其对应的汇编指令多是这样的:ui
mov 0x400fda %eaxspa
add $0x1 %eax操作系统
这里的意思是把内存中 0x400fda 这个地址的数据放到寄存器当中,而后将寄存器中的数据加一,在这里g_num这个全局变量的内存地址就是0x400fda。.net
好奇的同窗可能会问,那这些函数以及数据的内存地址是怎么来的呢?3d
肯定程序运行时的内存地址就是接下来咱们要讲解的重点内容,这里先给出答案,可执行文件中代码以及数据的运行时内存地址是连接器指定的,也就是上面示例中add的内存地址0x4004fd其是连接器指定的。肯定程序运行时地址的过程就是这里重定位(Relocation)。blog
为何这个过程叫作重定位呢,之因此叫作重定位是由于肯定可执行文件中代码和数据的运行时地址是分为两个阶段的,在第一个阶段中没法肯定这些地址,只有在第二个阶段才能够肯定,所以就叫作重定位。接下来让咱们来看看这两个阶段,合并同类型段以及引用符号的重定位。
编译器的工做
让咱们回忆一下前几节的内容,源文件首先被编译器编译生成目标文件,目标文件种有三段内容:数据段、代码段以及符号表,全部的函数定义被放在了代码段,全局变量的定义放在了数据段,对外部变量的引用放到了符号表。
编译器在将源文件编译生成目标文件时能够肯定一下两件事:
定义在该源文件中函数的内存地址
定义在该源文件中全局变量的内存地址
注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于本身的。为何只是一个相对地址呢?由于在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行连接生成最后的可执行文件,而连接器是知道要连接哪些目标文件的。所以编译器仅仅生成一个相对地址。
而对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是没法肯定其内存地址的,这不是编译器须要关心的,肯定引用类变量的内存地址是连接器的任务,连接器在进行连接时可以肯定这类变量的内存地址。所以当编译器在遇到这样的变量时,好比使用了外部定义的函数时,其在目标文件中对应的机器指令多是这样的:
call 0x000000
也就是说对于编译器不能肯定的地址都这设置为空(0x000000),同时编译器还会生成一条记录,该记录告诉连接器在进行连接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text段中。相应的若是是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data段中。即连接器须要在连接过程当中根据.rel.data以及.rel.text来填好编译器留下的空白位置
(0x000000)。所以在这里咱们进一步丰富目标文件中的内容,如图所示:
生成目标文件后,编译器完成任务,编译器肯定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能肯定的引用类变量,编译器在目标文件的.rel.text以及.rel.data段中生成相应的记录告诉连接器要修正这些变量的地址。
接下来就是连接器的工做了。
连接器的工做
咱们在静态库下可执行文件的生成一节中知道,连接器会将全部的目标文件进行合并,全部目标文件的数据段合并到可执行文件的数据段,全部目标文件的代码段合并到可执行文件的代码段。当全部合并完成后,各个目标文件中的相对地址也就肯定了。所以在这个阶段,连接器须要修正目标文件中的相对地址。
在这里咱们以合并目标文件中的数据段为例来讲明连接器是如何修正目标文件的相对地址的,合并代码段时修正相对位置的原理是同样的。
咱们假设连接器须要连接三个目标文件:
目标文件一:该文件数据段定义了两个变量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。
从这个过程当中能够看到,数据段中的相对地址是经过这个公式来修正的,即:
相对地址 + offset(偏移) = 最终内存地址
而每一个段的偏移只有在连接完成后才能肯定,所以对相对地址的修正只能由连接器来完成,编译器没法完成这项任务。
当全部目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被连接器修正为最终的内存位置,这样全部的变量以及函数都肯定了其各自位置。
至此,重定位的第一阶段完成。接下来是重定位的第二阶段,即引用符号的重定位。
相对地址是编译器在编译过程当中肯定了,在连接器完成后被连接器修正为最终地址,而对于编译器没有肯定的所引用的外部函数以及变量的地址,编译器将其记录在了.rel.text和.rel.data中。
因为在第一阶段中,全部函数以及数据都有了最终地址,所以重定位的第二阶段就相对简单了。咱们知道编译器引用外部变量时将机器指令中的引用地址设置为空(好比call 0x000000),并将该信息记录在了目标文件的.rel.text以及.rel.data段中。所以在这个阶段连接器依次扫描全部的.rel.text以及.rel.data段并找到相应变量的最终地址(这些位置都已在第一阶段肯定),并将机器指令中的0x000000修正为所引用变量的最终地址就能够了。
到这里连接器的重定位就讲解的这里,做为程序员通常不多会有问题出如今重定位阶段,所以这个阶段对程序员相对透明。请同窗们注意一点,这里的分析仅限于目标文件的静态连接。咱们知道静态连接下,连接器会将须要的代码和数据都合并到可执行文件当中,所以须要肯定代码和数据的最终位置。而对于动态连接库来讲状况则有所不一样,动态连接库能够同时被多个进程使用,若是动态连接库的机器指令中不能够存在引用变量的最终位置,不然在被多个进程使用时会出现一个进程中使用的数据被其它进程修改。所以动态库下的机器指令都是PIC代码,即位置无关代码(Position-Independent Code)。关于PIC的机制原理就不在这里阐述了,对此感兴趣的同窗能够关注微信公众号,码农的荒岛求生,我会在那里来说解。
问题:为何连接器能肯定运行时地址
咱们知道只有把可执行文件加载到内存当中程序才能够开始运行。不一样的程序会被加载到内存的不一样位置。咱们从前两节的过程当中能够看出,连接器彻底没有考虑不一样的程序会被加载不一样的内存位置被执行。好比对于一个可执行文件咱们分别运行两次,以下图所示,由于两个程序数据段变量的地址是同样的,那么程序一的数据会不会被程序二修改呢?
若是你去试一试的话就会发现显然不会有这种问题的。而当可执行文件加载到内存的时候也不会根据程序加载的起始地址再去修改可执行文件中变量的地址(这样就启动速度就太慢了),那么操做系统又是如何能作到基于同一个可执行文件的两个程序能在各自的内存空间中运行而不相互干扰呢,连接器在可执行文件中肯定的究竟是不是程序最终的运行地址呢,我会在后面的文章当中给出答案,欢迎同窗们关注微信公共帐号码农的荒岛求生获取更多内容。
《完全理解连接器:六,大型项目是如何被构建(build)出来的》,欢迎关注微信公众号,码农的荒岛求生,获取更多内容。
本文分享自微信公众号 - 码农的荒岛求生(escape-it)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。