内存管理--程序的装载

来源:互联网 发布:flicker free mac 编辑:程序博客网 时间:2024/05/29 10:09

 

为了决定段的大小、符号定义、符号引用,并指出包含那些库模块、将这些段放置在输出地址空间的什么地方,链接器会将所有的输入文件进行扫描。扫描完成后的下一步就是链接过程的核心,重定位。由于重定位过程的两个步骤,判断程序地址计算最初的非空段,和解析外部符号的引用,是依次、共同处理的,所以我们讲重定位即同时涉及这两个过程。

链接器的第一次扫描会列出各个段的位置,并收集程序中全局符号与段相关的值。一旦链接器确定了每一个段的位置,它需要修改所有的相关存储地址以反映这个段的新位置。在大多数体系结构中,数据中的地址是绝对的,那些嵌入到指令中的地址可能是绝对或者相对的。链接器因此需要对它们进行修改,我们稍后会讨论这个问题。第一遍扫描也会建立第五章中所讲的全局符号表。链接器还会将符号表中的地址解析为引用全局符号时所存储的地址。

1.链接时的重定位

1.1静态链接方式(Static Linking)

我们通过一个例子来说明在实现静态链接时应解决的一些问题。在图 4-4(a)中示出了经过编译后所得到的三个目标模块 A、B、C,它们的长度分别为 L、M 和 N。在模块 A 中有一条语句 CALL B,用于调用模块 B。在模块 B 中有一条语句 CALL C,用于调用模块 C。B 和C 都属于外部调用符号,在将这几个目标模块装配成一个装入模块时,须解决以下两个问题: 

(1) 对相对地址进行修改。在由编译程序所产生的所有目标模块中,使用的都是相对地址,其起始地址都为 0,每个模块中的地址都是相对于起始地址计算的。在链接成一个装入模块后,原模块 B 和 C 在装入模块的起始地址不再是 0,而分别是 L 和 L+M,所以此时须修改模块 B 和 C 中的相对地址,即把原 B 中的所有相对地址都加上 L,把原 C 中的所有相对地址都加上 L+M。

(2) 变换外部调用符号。将每个模块中所用的外部调用符号也都变换为相对地址,如把B 的起始地址变换为 L,把 C 的起始地址变换为 L+M,如图 4-4(b)所示。这种先进行链接所形成的一个完整的装入模块,又称为可执行文件。通常都不再拆开它,要运行时可直接将它装入内存。这种事先进行链接,以后不再拆开的链接方式,称为静态链接方式。(有两个地方需要重定位:段地址和符号地址)

wpsC69D.tmp

1.2确定段和符号地址

链接器的第一遍扫描将各个段的位置列出,并收集程序中所有全局符号和段相关的值。一旦链接器决定了每一个段的位置,它就需要调整存储地址。

l (1)数据地址和段内绝对程序地址引用需要进行调整。例如,如果一个指针指向位置100,但是段基址被重定位为1000,那么这个指针就需要被调整到位置1000。

l (2)程序中的段间引用也需要被调整。绝对地址引用要调整为可以反映目标地址段的新位置,同样相对地址需要调整为可以同时反映目标段和引用所在段的新位置。

l (3)对全局符号的引用需要进行解析。如果一个指令调用了例程detonate,并且detonate位于起始地址为1000的段的偏移地址500,在这个指令中涉及到的地址要调整为1500。

重定位和符号解析所要求的条件有些许不同。对于重定位,基址的数量相当小,也就是一个输入文件中的段的个数,不过目标文件格式允许对任何段中任何地址的引用进行重定位。对于符号解析,符号的数量远远大的多,但是大多数情况下链接器只需要对符号做一件事即将符号的值插入到程序的一个字大小的空间中。

很多链接器将段重定位和符号重定位统一对待,这是因为它们将段当作是一种值为段基址的“伪符号”。这使得和段相关的重定位就成了和符号相关的重定位的特例。即使在将两种重定位统一对待的链接器中,此二者仍有一个重要区别:一个符号引用包括两个加数,即符号所在段的基值和符号在段内的偏移地址(译者注:这里作者少说了半句话,即将段作为符号处理时,这个特殊符号只有段基址,没有段内偏移量)。有一些链接器在开始进入重定位阶段之前就会预先计算所有的符号地址,将段基址加到符号表中符号的值中。当每一项被重定位时会查找到段基址并相加。大多数情况下,并没有强制的理由要以这种或那种方法来进行这种操作。在少数链接器,尤其是那些针对实模式x86代码的链接器中,一个地址可以被重定位到和若干不同段相关的多个地址上,因此链接器只需要确定在上下文中一个特定引用的符号在特定段中的地址。

1.3链接时的重定位(一个例子进行说明)

wpsC6CD.tmp

当源代码“a.c”在被编译成目标文件时,编译器并不知道“shared”和“swap”的地址,因为它们定义在其他目标文件中。所以编译器就暂时把地址0看作是“shared”的地址,我们可以看到这条“mov”指令中,关于“shared”的地址部分为“0x00000000”。

wpsC6FD.tmp

另外一个是偏移为0x26的指令的一条调用指令,它其实就表示对swap函数的调用

wpsC72C.tmp

编译器把这两条指令的地址部分暂时用地址“0x00000000”和“0xFFFFFFFC”代替着,把真正的地址计算工作留给了链接器。我们通过前面的空间与地址分配可以得知,链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地位修正。我们用objdump来反汇编输出程序“ab”的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置:

wpsC74D.tmp

经过修正以后,“shared”和“swap”的地址分别为0x08049108和0x00000009(小端字节序)。关于“shared”很好理解,因为“shared”变量的地址的确是0x08049108。对于“swap”来说稍显晦涩。我们前面介绍过,这个“call”指令是一条近址相对位移调用指令,它后面跟的是调用指令的下一条指令的偏移量,“call”指令的下一条指令是“add”,它的地址是0x080480bf,所以“相对于

add指令偏移量为0x00000009”的地址为0x080480bf + 9 = 0x080480c8,即刚好是“swap ”函数的地址。

1.4指令修正

wpsC76D.tmp

绝对寻址修正 让我们先看a.o的第一个重定位入口,即偏移为0x18的这条mov指令的修正,它的修正方式是R_386_32,即绝对地址修正。对于这个重定位入口,它修正后的结果应该是S +A。

S是符号shared的实际地址,即0x3000。

A是被修正位置的值,即0x00000000。

所以最后这个重定位入口修正后地址为:0x3000 + 0x00000000 = 0x3000。

相对寻址修正 让我们再来看看a.o的第二个重定位入口,即偏移为0x26的这条call指令的修正,它的指令修正方式是R_386_PC32,即相对寻址修正。对于这个重定位入口,它修正后的结果应该是S + A – P。

S是符号swap的实际地址,即0x2000;

A是被修正位置的值,即0xFFFFFFFC(-4);

P为被修正的位置,当链接成可执行文件时,这个值应该是被修正位置的虚拟地址,即0x1000 + 0x27。所以最后这个重定位入口修正后地址为:0x2000 + (-4) – ( 0x1000 + 0x27) = 0xFD5。

从这两个例子可以看出来,绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。

1.5总结

静态链接方式,首先,获得了各段的地址和符号地址。然后,通过重定位对程序中的指令的地址进行修正。

2.装载时的重定位

2.1绝对装入方式只能将目标模块装入到内存中事先指定的位置。在多道程序环境下,编译程序不可能预知所编译的目标模块应放在内存的何处,因此,绝对装入方式只适用于单道程序环境。在多道程序环境下,所得到的目标模块的起始地址通常是从 0 开始的,程序中的其它地址也都是相对于起始地址计算的。此时应采用可重定位装入方式,根据内存的当前情况,将装入模块装入到内存的适当位置。  值得注意的是, 在采用可重定位装入程序将装入模块装入内存后, 会使装入模块中的所有逻辑地址与实际装入内存的物理地址不同,图 4-3示出了这一情况。例如,在用户程序的 1000 号单元处有一条指令 LOAD 1,2500,该指令的功能是将 2500 单元中的整数 365 取至寄存器 1。但若将该用户程序装入到内存的 10000~15000号单元而不进行地址变换, 则在执行 11000号单元中的指令时,它将仍从 2500 号单元中把数据取至寄存器 1而导致数据错误。由图 4-3可见,正确的方法应该是将取数指令中的地址 2500 修改成 12500,即把指令中的相对地址 2500 与本程序在内存中的起始地址 10000 相加,才得到正确的物理地址 12500。除了数据地址应修改外,指令地址也须做同样的修改,即将指令的相对地址 1000 与起始地址 10000 相加,得到绝对地址 11000。通常是把在装入时对目标程序中指令和数据的修改过程称为重定位。又因为地址变换通常是在装入时一次完成的,以后不再改变,故称为静态重定位。

3.PIC(地址无关代码)技术

载时重定位和链接时重定位比起来就颇为简单了。在链接时,不同的地址需要根据段的大小和位置重定位为不同的位置。在加载时,整个程序在重定位过程中会被认为是大的单一段,加载器只需要判断名义上的加载地址和实际加载地址的差异即可。但是,它可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。(共享对象映射到了虚拟内存,对于不同的进程它映射的位置是不一样的,根据指令的修正(使用SAP进行修正)),那么对于不同的进程,它的指令都是不同的,也就是说无法对共享对象的指令进行共享),望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。

对于现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式。这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样我们就得到了如图7-4中的4种情况。

第一种是模块内部的函数调用、跳转等。

第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。

第三种是模块外部的函数调用、跳转等。

第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

wpsC7AC.tmp

(1)模块内部调用或跳转

这4种情况中,第一种类型应该是最简单的,那就是模块内部调用。因为被调用的函数与调用者都处于同一个模块,它们之间的相对位置是固定的,所以这种情况比较简单。对于现代的系统来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。比如上面例子中foo对bar的调用可能产生如下代码:

wpsC7CC.tmp

wpsC7ED.tmp

这条指令中的后4个字节是目的地址相对于当前指令的下一条指令的偏移,即

0xFFFFFFE8(Little-endian)。0xFFFFFFE8是-24的补码形式,即bar的地址为0x804835c +(-24) = 0x8048344。那么只要bar和foo的相对位置不变,这条指令是地址无关的。即无论模块被装载到哪个位置,这条指令都是有效的。这种相对地址的方式对于jmp指令也有效。(数据段保存的值是相对寻址的指令修正的值。不用修改指令)

(2)模块内部数据访问

接着来看看第二种类型,模块内部的数据访问。很明显,指令中不能直接包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址(PC)的寻址方式,所以ELF用了一个很巧妙的办法来得到当前的PC值,然后再加上一个

偏移量就可以达到访问相应变量的目的了。得到PC值的方法很多,我们来看看最常用的一种,也是现在ELF的共享对象里面用的一种方法:

wpsC81C.tmp

wpsC83D.tmp

这是对上面的例子中的代码先编译成共享对象然后反汇编的结果。用粗体表示的是bar()函数中访问模块内部变量a的相应代码。从上面的指令中可以看到,它先调用了一个叫“__i686.get_pc_thunk.cx”的函数,这个函数的作用就是把返回地址的值放到ecx寄存器,即把call的下一条指令的地址放到ecx寄存器。

我们知道当处理器执行call指令以后,下一条指令的地址会被压到栈顶,而esp寄存器就是始终指向栈顶的,那么当“__i686.get_pc_thunk.cx”执行“mov (%esp),%ecx”的时候,返回地址就被赋值到ecx寄存器了。

接着执行一条add指令和一条mov指令,可以看到变量a的地址是add指令(保存在ecx寄存器)加上两个偏移量0x118c和0x28,即如果模块被装载到0x10000000这个地址的话,那么变量a的实际地址将是0x10000000 + 0x454 + 0x118c + 0x28 = 0x10001608,这个计算过程我们可以从图7-6中看到。

wpsC85D.tmp

(3)模块间数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,比如上面例子中的变量b,它被定义在其他模块中,并且该地址在装载时才能确定。我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,它的基本机制如图7-7所示。

wpsC86D.tmp

当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

我们来看看GOT如何做到指令的地址无关性。从第二中类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么我们也可以在编译时确定GOT对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的,比如第一个地址对应变量b,第二个对应变量c等。让我们再回顾刚才函数bar()的反汇编代码。为访问变量b,我们的程序首先计算出变量b的地址在GOT中的位置,即0x10000000 + 0x454 + 0x118c + (-8) = 0x100015d8(0xfffffff8为-8的补码表示),然后使用寄存器间接寻址方式给变量b赋值2。

我们也可以使用objdump来查看GOT的位置:

wpsC88E.tmp...

可以看到变量b的地址需要重定位,它位于0x15d8,也就是GOT中偏移8,相当于是GOT中的第三项(每四个字节一项)。从上面重定位项中看到,变量b的地址的偏移为0x15d8,正好对应了我们前面通过指令计算出来的偏移值,即0x100015d8 – 0x10000000 = 0x15d8。(对于模块外部引用的全局变量和全局函数,用 GOT 表的表项内容作为地址来间接寻址;对于本模块内的静态变量和静态函数,用 GOT 表的首地址作为一个基准,用相对于该基准的偏移量来引用,因为不论程序被加载到何种地址空间,模块内的静态变量和静态函数与 GOT 的距离是固定的,并且在链接阶段就可知晓其距离的大小。这样,PIC 使用 GOT 来引用变量和函数的绝对地址,把位置独立的引用重定向到绝对位置。)

(4)模块间调用、跳转

对于模块间调用和跳转,我们也可以采用上面类型四的方法来解决。与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转,基本的原理如图7-8所示。调用ext()函数的方法与上面访问变量b的方法基本类似,先得到当前指令地址PC,然后加上一个偏移得到函数地址在GOT中的偏移,然后一个间接调用:

wpsC8AE.tmp

wpsC8DE.tmp

原创粉丝点击