程序的动态链接(2):地址无关代码

概述

动态库的一个主要目的就是允许多个正在运行的进程共享内存中的库代码,以节约内存资源。现代系统使用了一种称为地址无关代码(Position-Indepent Code, PIC)的技术来编译动态库,使用这种技术,可以将动态库加载到内存的任何位置而无需链接修改,所有进程都可以共享动态库中代码的单一副本。

地址无关代码

PIC的基本思想是将指令中那些需要进行重定位的部分剥离出来和数据部分放在一起,这样指令部分就可以保持不变,而数据部分在每个进程中都可以拥有一个副本。为了实现PIC,关键在于如何处理动态库中的各种符号引用。下列代码中涵盖了在动态库中会遇到到的各种符号引用类型:
程序的动态链接(2):地址无关代码
通过对动态库中的符号引用进行分类,主要可分为四种情况:

  • 类型一:模块内部的数据访问
  • 类型二:模块内部的函数调用
  • 类型三:模块外部的数据访问
  • 类型四:模块外部的函数调用

通常,对于模块内部符号的引用,可以利用PC相对寻址,然后由静态链接在构造目标文件时进行重定位,就可以使之成为PIC;然而对动态库定义的外部函数以及对全局变量的引用,还需要编译器进行一些特殊的处理。现在围绕这些类型的引用,我们可以看看现代编译系统是如何为其生成PIC的代码。

模块内部的数据访问

动态库在被加载到内存时,库文件中任何一条指令与其要访问的模块内部数据之间的相对位置是固定的,因此使用当前指令地址加上固定的偏移量就能是想访问模块内部数据。x86_64体系结构下,数据寻址已经支持相对当前PC指针寄存器的寻址方式:
程序的动态链接(2):地址无关代码

模块内部的函数调用

由于被调用的函数与调用者都处于同一个模块中,它们之间的相对位置是固定的,因此使用相对地址调用就可以解决问题。在现代系统中,模块内的函数调用都是相对地址调用或基于寄存器的相对调用,该种指令无需重定位:
程序的动态链接(2):地址无关代码

模块外部的数据访问

由于模块间的数据访问目标地址要等到装载时才能决定,为了实现PIC,编译系统使用了一些新的数据结构:全局偏移表(Global Offset Table,GOT)。GOT放置在数据段中,因此对于每个进程都有独立的副本,表中存放了所有外部变量的地址,由动态链接器在加载模块的时候,通过查找外部变量的地址进行填充;当指令需要引用外部变量时,可以通过GOT中相对应的项间接引用。
程序的动态链接(2):地址无关代码

GOT实现指令地址无关性的关键在于,模块在编译时,GOT相对于当前指令的偏移是固定的,并且每个外部变量在GOT中的偏移也是可以确定的。这里

模块外部的函数调用

模块间的函数调用原理可以使用与数据访问类似的实现,只需要在GOT表中存放目标函数的地址即可。

全局符号介入

一个共享目标文件里面的全局符号被另一个共享目标文件的同名全局符号覆盖的现象,称作共享对象全局符号介入。Linux下动态链接器处理全局符号介入问题的规则是这样的:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

全局符号介入对PIC的影响

共享对象全局符号介入引入了这样的一个问题:如果在共享对象A中定义了一个全局符号global,共享对象B同样定义了global全局符号,并且在本模块中也使用了这个全局符号,那么在最后可执行文件依序加载A和B的时候,B中的相同符号就会被A覆盖掉,那么共享对象B和外部模块在访问符号时就会出现不一致。相同问题对于函数引用也有可能出现。

为了解决这个问题,一个思路就是将共享对象内定义的需要被外部模块引用的符号作为模块外部符号进行处理,包括全局变量和函数。如果使用更直白的表述就是,只有对于模块内使用static关键字进行修饰的符号(即文件内作用域)才视为模块内的符号进行处理,即类型一和类型二中使用的处理;而非static修饰的符号使用类型三和类型四中的情况进行处理。

共享模块的全局变量问题

当一个可执行文件引用了一个定义在共享对象的全局变量的时候,由于可执行文件不使用PIC技术,它会以访问普通数据的方式来引用这个全局变量。于是,在链接生成可执行文件的时候,就需要进行重定位工作。为了使链接过程可以正常执行,链接器会在创建可执行文件时,在其.bss段创建该变量的副本,并使用该副本的地址进行重定位。

ELF共享库在编译时,默认将定义模块内部的全局变量当作定义在其它模块的全局变量,使用GOT来实现变量访问。当共享库被装载时,某个全局变量在可执行文件中存在副本,则动态链接器会使用该副本的地址来填充GOT的对应表项。

相关参考

  • 《程序员的自我修养——链接、装载与库》
  • 《深入理解计算机系统》