地址无关码

1. 固定装载地址的困扰

通过上一节的介绍我们已经基本了解了动态链接的概念,同时我们也得到了一个问题,那就是:共享对象在被装载时,如何确定它在进程虚拟地址空间中的位置?为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题。

让我们先来回顾一下第2章提到的,程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件的时候,就要假设模块被装载的目标地址。

很明显,在动态链接的情况下,如果不同的模块目标装载地址都一样是不行的。而对于单个程序来讲,我们可以手工指定各个模块的地址,比如把0x100到0x2000分配给模块A,把地址ox2000到0x3000分配给模块B。但是,如果某个模块被多个程序使用,甚至多个模块被多个程序使用,那么管理这些模块的地址将是一件无比繁琐的事情。比如一个很简单的情况,一个人制作了一个程序,该程序需要用到模块B,但是不需要用到模块A,所以他以为地址0x1000到0x2000是空闲的,于是分配给了另外一个模块C。这样C和原先的模块A的目标地址就冲突了,任何人以后将不能在同一个程序里面使用模块A和C。想象一个有着成千上万个并且由不同公司和个人开发的共享对象的系统中,采用这种手工分配的方式几乎是不可行的;

不幸的是,早期的确有些系统采用了这样的做法,这种做法叫做静态共享库( Static Shared Library),请注意,它跟静态库( Static Library)有很明显的区别。静态共享库的做法就是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。

静态共享库的目标地址导致了很多问题,除了上面提到的地址冲突的问题,静态共享库的升级也很成问题,因为升级后的共享库必须保持共享库中全局函数和变量地址的不变,如果应用程序在链接时已经绑定了这些地址,一且更改,就必须重新链接应用程序,否则会引起应用程序的崩溃。即使升级静态共享库后保持原来的函数和变量地址不变,只是增加了一些全局函数或变量,也会受到限制,因为静态共享库被分配到的虚拟地址空间有限,不能增长太多,否则可能会超出被分配的空间。种种限制和弊端导致了静态共享库的方式在现在的支持动态链接的系统中已经很少见,而彻底被动态链接取代。我们只有在些不支持动态链接的旧系统中还能看到静态共享库的踪影。目前知道的使用静态共享库的旧系统有:

  • Unix System V Release 3.2
  • 旧的Linux Systems(a.out format)
  • BSD/OS derivative of 4.4 BSD(a.out and ELF formats)

为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题的另一种表述就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己是在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定的空闲地址,比如linux 下一般是0x080400000,windows下一般是0x0040000;

2. 装载时重定位

为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。假设函数 foobar相对于代码段的起始地址是0x100,当模块被装载到0x1000000时,我们假设代码段位于模块的最开始,即代码段的装载地址也是0x1000000,那么我们就可以确定 foobar的地址为oκ10000100。这时候,系统遍历模块中的重定位表,把所有对 foobar的地址引用都重定位至0x10000100

事实上,类似的方法在很早以前就存在。早在没有虚拟存储概念的情况下,程序是直接被装载进物理内存的。当同时又多个程序运行的时候,操作系统根据当时内存空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。但这种重定位比前面提到过的金静态链接中的重定位要简单得多,因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会改变的。比如一个程序在编译时假设被装载的目标地址为0x1000,但是在装载时操作系统发现0x1000这个地址呗别的程序占用了,从0x4000开始有一块足够大的空间容纳该程序,那么该程序就可以被装载自0x4000,程序指令或数据中的所有绝对引用只要加上0x3000的偏移量就可以了;

我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位( Link Time Relocation),而现在这种情况经常被称为装载时重定位( Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置( Rebasing),我们在后面将会有专门章节分析基址重置。

这种情况与我们碰到的问题很相似,都是程序模块在编译时目标地址不确定而需要在装载时将模块重定位。但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题。可以想象,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的。当然,动态连接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数“shared”和“-fPIC”,如果只使用“- shared”,那么输出的共享对象就是使用装载时重定位的方法。

3. 地址无关码

那么什么是“-fPIC”呢?使用这个参数会有什么效果呢?

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PC, Position-independent Code)的技术。

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

  • 第一种是模块内部的函数调用、跳转等;
  • 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
  • 第三种是模块外部的函数调用、跳转等。
  • 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

类型一 模块内部调用或跳转等

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

foo中对bar的调用的那条指令实际上是一条相对地址的调用指令,我们在第2部分已经介绍过相对位移调用指令的指令格式,相对偏移调用指令如图7-5所示:

这条指令中的后4个字节是目的地址相对于当前指令的下一条指令的偏移,即0xFFFFFFE8( Little-endian)。0 xFFFFFFE8是24的补码形式,即bar的地址为0x804835c+(-24)=0x8048344。那么只要bar和foo的相对位置不变,这条指令是地址无关的。即无论模块被装载到哪个位置,这条指令都是有效的。这种相对地址的方式对于jmp指令也有效。

这样看起来第—个模块内部调用或跳转很容易解决,但实际上这种方式还有一定的问题,这里存在一个叫做共享对象全局符号介入( Global Symbol Interposition)问题,这个问题在后面关于“动态链接的实现”中还会详细介绍。但在这里,可以简单地把它当作相对地址调用/跳转。

类型二 模块内部数据访问

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

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

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

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

类型三 模块间数据访问

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

GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的比如第一个地址对应变量b,第二个对应变量c等。

让我们再回顾刚才函数bar(的反汇编代码。为访问变量b,我们的程序首先计算出变量b的地址在GOT中的位置,即0x10000000x454+0x118c+(-8)=0x100015d8(0xtffffff8为-8的补码表示),然后使用寄存器间接寻址方式给变量b赋值2。

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

可以看到GOT在文件中的偏移是0x15d0,我们再来看看pic.so的需要在动态链接时重定位项:

可以看到变量b的地址需要重定位,它位于0x15d8,也就是GOT中偏移8,相当于是GOT中的第三项(每四个字节一项)。从上面重定位项中看到,变量b的地址的偏移为0x15d8正好对应了我们前面通过指令计算出来的偏移值,即0x100015d8-0x10000000=0x15d8。

类型四 模块间调用、跳转

对于模块间调用和跳转,我们也可以采用上面类型四的方法来解决。与上面的类型有所不同的是,GOT中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转,基本的原理如图7-8所示。

这种方法很简单,但是存在一些性能问题,实际上ELF采用了一种更加复杂和精巧的方法,我们将在后面关于动态链接优化的章节中进行更为具体的介绍。

地址无关代码小结

历经磨难,终于功德圆满。4种地址引用方式在理论上都实现了地址无关性,我们将它们总结一下,如表7-1所示。

-fpic和-fPIC

使用GCC产生地址无关代码很简单,我们只需要使用“-fPIC”参数即可。实际上GCC还提供了另外一个类似的参数叫做“-fpic”,即“-fPIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快。那么我们为什么不使用“-fpic”而要使用“-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“fic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而"-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下我们都使用“-fPlC”参数来产生地址无关代码。

如何区分一个DSO是否为PIC

readelf -d foo.so | grep TEXTREL

4. 共享模块的全局变量问题

地址无关性问题就这么解决了吗?看起来好像是的。如果你还没看出来一个小问题的话,最好回头再仔细看看前面的4种地址引用方式的分类。发现了吗?我们上面的情况中没有包含定义在模块内部的全局变量的情况。可能你的第一反应就是,这不是很简单吗?跟模块内部的静态变量一样处理不就可以了吗?的确,粗略一看模块内部的全局变量和静态变量的地址都可以通过上面所列出的类型两种方法来解决。但是有一种情况很特殊,我们来看看 会产生什么问题。

有一种很特殊的情况是,当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量 global,而模块 module c中是这么引用的:

extern int global;
int foo() {
    global = 1;
}

当编译器编译 module.c时,它无法根据这个上下文判断 global是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。假设 module c是程序可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,也就是说代码不会使用这种类似于PC的机制,它引用这个全局变量的方式跟普通数据访问方式一样,编译器会产生这样的代码:

movl $0x1, XXXXXXXX

XXXXXXXX就是 global的地址。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“bss”段创建一个 global变量的副本。那么问题就很明显了,现在global变量定义在原先的共享对象中,而在可执行文件的“bs”段还有一个副本。如果同个变量同时存在于多个位置中,这在程序实际运行过程中肯定是不可行的。

于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。

Q&A

Q:如果一个共享对象 lib.so中定义了一个全局变量G,而进程A和进程B都使用了lib.so,那么当进程A改变这个全局变量G的值时,进程B中的G会受到影响吗?

A:不会。因为当 lib.so被两个进程加载时,它的数据段部分在每个进程中都有独立的副本,从这个角度看,共享对象中的全局变量实际上和定义在程序内部的全局变量没什么区别。任何一个进程访问的只是自己的那个副本,而不会影响其他进程。那么,如果我们把这个问题的条件改成同一个进程中的线程A和线程B,它们是否看得到对方对lib.so中的全局变量G的修改呢?对于同一个进程的两个线程来说,它们访问的是同个进程地址空间,也就是同一个lib.so的副本,所以它们对G的修改,对方都是看得到的。

那么我们可不可以做到跟前面答案相反的情况呢?比如要求两个进程共享一个共享对象的副本或要求两个线程访问全局变量的不同副本,这两种需求都是存在的,比如多个进程可以共享同一个全局变量就可以用来实现进程间通信;而多个线程访问全局变量的不同副本可以防止不同线程之间对全局变量的干扰,比如C语言运行库的eron全局变量。实际上这两种需求都是有相应的解决方法的,多进程共享全局变量又被叫做“共享数据段”,在介绍 Windows DLL的时候会碰到它。而多个线程访问不同的全局变量副本又被叫做“线程私有存储”( Thread Local Storage),我们在后面还会详细介绍。

5. 数据段地址无关性

通过上面的方法,我们能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?让我们来看看这样一段代码:

static int a static int*p=&ai

如果某个共享对象里面有这样一段代码的话,那么指针p的地址就是一个绝对地址,它指向变量a,而变量a的地址会随着共享对象的装载地址改变而改变。那么有什么办法解决这个问题呢?

对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变。从这 点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对 象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个 重定位表里面包含了“R386 RELATIVE”类型的重定位入口,用于解决上述问题。当动态 链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对 该共享对象进行重定位。

实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。从前面的例子中我们看到,我们在编译共享对象时使用了“PIC”参数,这个参数表示产生地址无关的代码段。如果我们不使用这个参数来产生共享对象又会怎么样呢?

$gcc -shared pic. c -o pic. so

上面这个命令就会产生一个不使用地址无关代码而使用装载时重定位的共享对象。但正如我们前面分析过的一样,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可执行文件中存在“got”这样的段。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏博岩Java大讲堂

Java虚拟机--类加载器如何加载一个Class文件

18750
来自专栏青玉伏案

PHP关于web页面交互内容

1. 学php学了有一段时间了总结总结给大家分享一下 2. PHP中的引用 第一段程序: <?php $first_name="fir...

29080
来自专栏抠抠空间

Flask路由系统与模板系统

Flask中自定义模板方法的方式和Bottle相似,创建一个函数并通过参数的形式传入render_template,如:

10620
来自专栏Rgc

scrapy回调函数传递参数

scrapy.Request 的callback传参的两种方式 1.使用 lambda方式传递参数 def parse(self, response): ...

28830
来自专栏程序员的知识天地

Python使用os模块、Try语句、pathlib模块判断文件是否存在

通常在读写文件之前,需要判断文件或目录是否存在,不然某些处理方法可能会使程序出错。所以最好在做任何操作之前,先判断文件是否存在。

18120
来自专栏技术墨客

Nginx域名访问处理过程 原

在实际应用中,我们可以将多个域名指向一个IP 地址,或者使用范IP解析功能。当多个域名执行一个 IP 地址时,Nginx 可以根据域名来分配不同的虚拟服务器,如...

26830
来自专栏深度学习之tensorflow实战篇

python文件打开方式详解——a、a+、r+、w+区别

第一步 排除文件打开方式错误: r只读,r+读写,不创建 w新建只写,w+新建读写,二者都会将文件内容清零 (以w方式打开,不能读出。w+可读写) **w+与r...

57470
来自专栏前端大白专栏

angular使用管道实现搜索功能

49360
来自专栏小二的折腾日记

day5(面向对象2)

wait notify notifyAll 都使用在同步中,因为要对持有监视器(锁)的线程操作。所以要使用在同步中,以为只有同步才具有锁。 为什么这些操作线程...

5910
来自专栏大内老A

ASP.NET Core管道深度剖析(4):管道是如何建立起来的?

在《管道是如何处理HTTP请求的?》中,我们对ASP.NET Core的请求处理管道的构成以及它对请求的处理流程进行了详细介绍,接下来我们需要了解的是这样一个管...

19460

扫码关注云+社区

领取腾讯云代金券