位置无关(PIC)代码原理剖析

2019-04-15 12:03发布

共享库的一个关键目的是为了使多个进程能够共享内存中的同一份代码拷贝,已达到节约内存资源的目的。如何做到呢?一种方法是预先为每一个共享库指定好加载的地址范围,然后要求加载器总是将共享库加载至指定的位置。这种方法尽管很简单,但是会产生一些严重的问题。因为就算一个进程并没有用到某个库,相应的地址范围依然会被保留下来,这是一种效率很低的内存使用方式。另外,这种方法管理起来也很困难。我们必须保证预留的地址块之间没有重叠。每当一个库被修改后,我们还必须要保证它能被放回到修改前的位置,否则,我们还要为它重新找一个新的位置。当我们创建一个新的库时,我们还要为它寻找合适的空间,地址空间碎片化造成的大量无用的内存空洞。更糟糕的是,不同的系统为动态库分配内存的方式不尽相同,这使得管理起来更为困难。
一个更好的方法是将动态库编译成可以在任意位置加载而无需链接器进行修改。这样的代码被称作位置无关代码(PIC)。GNU编译系统可以通过指定-fPIC选项来生成PIC代码。
在IA32系统中,同一个模块中的过程调用无需特殊处理就是PIC的,因为其引用相对于PC地址的偏移量是已知的。但是,对外部过程的调用和对全局变量的引用一般却不是PIC的,因此需要在链接的时候进行重定位。


PIC数据引用
编译器对全局变量生成PIC引用是基于下面这个有趣的事实:无论目标模块(包括共享目标模块)被加载到内存中的什么位置,数据段总是紧跟着地址段的。因此,代码段中的任意指令与数据段中的任意变量之间的距离在运行时都是一个常量,而与代码和数据加载的绝对内存位置无关。
为了利用这一特点,编译器在数据段的开头创建了一个全局偏移表(GOT)。在GOT中,目标模块所引用的每个全局数据对象都对应一个表项。编译器同时为GOT中的每个表项生成了一个重定位记录。在加载时,动态链接器重定位GOT中的每个表项,使其包含正确的绝对地址。每个包含全局数据引用的目标模块都有其自己的GOT。
在运行时,每个全局变量通过GOT被间接引用,如下列代码所示:

call L1 L1: popl %ebx ebx包含着当前的PC addl $VAROFF, %ebx ebx指向var所对应的GOT表项 movl (%ebx), %eax 通过GOT间接引用 movl (%eax), %eax
在这段代码中,对L1的调用将返回地址(也就是popl指令所对应的地址)压入堆栈。接着popl指令将其弹出至%ebx。这两条指令的效果就相当于把PC值加载至寄存器%ebx。
addl指令将%ebx加上一个常数偏移量,使其指向对应的GOT表项,其中包含着引用数据的绝对地址。此时,全局变量可以通过包含在%ebx中的GOT表项被间接引用。在这个例子中,两条movl指令将全局变量的内容通过GOT间接地加载进寄存器%eax。
PIC代码具有性能上的缺陷。现在每次全局变量引用都需要5条指令而不是1条,同时GOT还需要占用额外的内存空间。并且,PIC代码需要使用额外的寄存器来保存GOT表项的地址。在寄存器较多的机器上,这不是什么大问题。但是在寄存器较少的IA32系统中,缺少哪怕一个寄存器都可能会触发将寄存器内容暂存在堆栈里。


PIC函数调用
可以使用相同的方法实现对外部函数调用的PIC代码:

call L1 L1: popl %ebx ebx保存当前的PC值 addl $PROCOFF, %ebx ebx指向调用函数的GOT表项地址 call *(%ebx) 通过GOT间接调用
但是这种方法对每次调用都需要3条额外的指令。于是,ELF编译系统使用了一种叫做延迟绑定(lazy binding)的技术将函数地址的绑定推迟到函数被首次调用时。这样,仅在第一次调用是会产生额外的时间开销,但在后面的调用中仅仅消耗一条额外指令和内存引用。
延迟绑定需要在两个数据结构之间进行密集而复杂的交互:GOT和过程连接表(procedure linkage table, PLT)。如果一个目标模块调用了共享库中的任意函数,那么它就有它自己的GOT和PLT。GOT是.data段的一部分。PLT是.text段的一部分。
GOT的前3项包含了一些特殊的值:GOT[0]中保存的是.dynamic段的地址,其中保存着动态链接器用来绑定过程地址所需要的信息,包括符号表的位置和重定位信息。GOT[1]中保存着当前模块的一些信息。GOT[2]是动态链接器延迟绑定代码的入口地址。每个被调用的动态对象中的函数在GOT中都有一个对应的表项,从GOT[3]开始。比如,定义在libc.so的printf,以及定义在libvector.so的addvec。
下面的代码展示了PLT的内容。PLT是16字节表项构成的数组。第一项PLT[0]是一个特殊项,为跳到动态链接器的入口。从PLT[1]开始,每一个被调用的函数在PLT中都有对应项,其中,PLT[1]对应printf,PLT[2]对应addvec.

PLT[0] 08048444: pushl $GOT[1] jmp to *GOT[2](linker) padding padding PLT[1] 8048454: jmp to *GOT[3] pushl $0x0 ID for printf jmp to PLT[0] PLT[2] 8048464: jmp to *GOT[4] pushl $0x8 ID for addvec jmp to PLT[0]
在程序被动态链接之后并开始执行时,函数printf和addvec被绑定到相应PLT项的第一条指令,比如,对addvec的调用为:

call 8048464
当addvec第一次被调用时,PLT[2]的第一条指令被执行,即间接跳转到GOT[4]保存的地址。GOT表项的初始值为PLT表项中的pushl指令的地址。因此,跳转后的执行指令重新回到了PLT表项的第二条指令。这条指令将addvec符号的ID压入栈中。然后下一条指令跳转到PLT[0],将另一个存放于GOT[1]中的标识信息字压入栈,然后通过GOT[2]间接跳转到动态链接器。动态链接器通过栈中的这两项数据来定位addvec,并用其覆盖GOT[4]的值,然后将控制权交给addvec。
当下次addvec被调用时,同之前一样,先是执行PLT[2]的第一条指令。但是这次将通过GOT[2]直接跳转到addvec。所增加的开销只是间接跳转所需的内存引用。
翻译自《深入理解计算机系统》第七章