注意:
以下所用的连接器是指, ld ,
而加载器是指 ld-linux.so;
1, GOT 表;
GOT ( Global Offset Table )表中每一项都是本运行模块要引用的一个全局变量或函数的地址。可以用 GOT 表来间接引用全局变量、函数,也可以把 GOT 表的首地址作为一个基 准,用相对于该基准的偏移量来引用静态变量、静态函数。由于加载器不会把运行模块加载到固定地址,在不同进程的地址空间中,各运行模块的绝对地址、相对位 置都不同。这种不同反映到 GOT 表上,就是每个进程的每个运行模块都有独立的 GOT 表,所以进程间不能共享 GOT 表。
在 x86 体系结构 上,本运行模块的 GOT 表首地址始终保存在 %ebx 寄存器中。编译器在每个函数入口处都生成一小段代码,用来初始化 %ebx 寄存器。这一步是必要的,否 则,如果对该函数的调用来自另一运行模块, %ebx 中就是调用者模块的 GOT 表地址;不重新初始化 %ebx 就用来引用全局变量和函数,当然出错。
这两段话的意思是说, GOT 是一个映射表,这里的内容是此段代码里面引用到的外部符号的地址映射,比如你用用到了一个 printf 函数,在这里就会有一项假设是 1000 ,则就像这样的:
.Got
符号 地址
Printf 1000
………
这样的话程序在运行到 printf 的时候就寻找到这个地址 1000 从而走到其实际的代码中的地方去。
但是这里存在一个问题,因为 printf 是在共享库里面的,而共享库在加载的时候是没有固定地址的,所以你不知道它的地址是 1000 还是 2000 ?怎么办呢?
于是引入了下面的表 plt ,这个表的内容是什么呢?请看下面:
2, PLT 表;
PLT ( Procedure Linkage Table )表每一项都是一小段代码,对应于本运行模块要引用的一个全局函数。以对函数 fun 的调用为例, PLT 中代码片断如下:
.PLTfun: jmp *fun@GOT(%ebx)
pushl $offset
jmp .PLT0@PC
其中引用的 GOT 表项被加载器初始化为下一条指令( pushl) 的地址,那么该 jmp 指令相当于 nop 空指令。
用户程序中对 fun 的直接调用经编译连接后生成一条 call [email]fun@PLT 指令,这是一条相对跳转指令 ( 满足浮动代码的要求! ) ,跳到 .PLTfun 。如果这是本运行模块中第一次调用该函数,此处的 jmp 等于一个空指令,继续往下执行,接着就跳到 PLT[email]0 。该 PLT 项保留给编译器生成的 额外代码,会把程序流程引入到加载器中去。加载器计算 fun 的实际入口地址,填入 fun@GOT 表项。图示如下:
user program
--------------
call fun@PLT
|
v
DLL PLT table loader
-------------- -------------- -----------------------
fun: <-- jmp*fun@GOT --> change GOT entry from
| $loader to $fun,
v then jump to there
GOT table
--------------
fun@GOT
loader
第 一次调用以后, GOT 表项已指向函数的正确入口。以后再有对该函数的调用,跳到 PLT 表后,不再进入加载器,直接跳进函数正确入口了。从性能上分析,只有第一次调用才要加载器作一些额外处理,这是完全可以容忍的。还可以看出,加载时不用对相对跳转的代码进行修补,所以整个代码段都能在进程间共享。
上面的话是什么意思呢?
拿我们上面举的例子, printf 在 got 表里面对应的地址是 1000 ,而这个 1000 到底以为着什么呢?
PLTfun: jmp *fun@GOT(%ebx)
1000 : pushl $offset
jmp .PLT0@PC
你可以看到所谓 1000 就是它下面的这个地址,也就是说在外部函数还没有实现连接的时候, got 表里面的内容其实是指向下一条指令的,于是开始执行了 plt 表里面的内容,于是这个段里面的内容肯定包括计算当前这个函数的实际地址的内容,于是求得实际地址添入 got 表,假设地址为 0x800989898
于是 got 表里面的内容就应该这样的:
Printf 0x800989898
………………..
这样当下一次调用这个 printf 的时候就不需要再去 plt 表里面走一遭了。
这里需要提一下的是,查找 printf 的地址实际上就是递归查找当前执行的程序所依赖的库,在她们 export 的符号表里面寻找,如果找到就返回,否则,报错,就是我们经常看到的 undefined referenc to XXXXX.
3, 代码段重定位前提。
代码段本身是存在于只读区域的,所以理论上它是不可能在运行的时候重新修改的,但是这就涉及一个问题,如何保证 Got 表的正确使用,因为每一个进程都有自己的 got 表,而共享库完全同时被许多个进程使用的,于是在每个函数的入口都有这样的语句:
call L1
L1: popl %ebx
addl $GOT+[.-.L1], %ebx
.o: R_386_GOTPC
.so: NULL
上述过程是编译、连接相合作的结果。编译器生成目标文件时,因为此时还不存在 GOT 表(每个运行模块有一个 GOT 表,一个 PLT 表,由连接器生成),所以暂时不能计算 GOT 表与当前 IP 间的差值,仅在第三句处设上一个 R_386_GOTPC 重定位标记而已。然后进行连接。连接器注意到 GOTPC 重定位项,于是计算 GOT 与此处 IP 的差值,作为 addl 指令的立即寻址方式操作数。以后再也不需要重定位了。
这样做的好处是目的是什么呢?
就是在函数内部引用外部符号的时候能够正确的转到适当的地方去。
4, 变量、函数引用
当引用的是静态变量、静态函数或字符串常量时,使用 R_386_GOTOFF 重定位方式。它与 GOTPC 重定位方式很相似,同样首先由编译器在目标文件中设上重定位标记,然后连接器计算 GOT 表与被引用元素首地址的差值,作为 leal 指令的变址寻址方式操作数。代码片断如下:
leal .LC1@GOTOFF(%ebx), %eax
.o: R_386_GOTOFF
.so: NULL
当引用的是全局变量、全局函数时,编译器会在目标文件中设上一个 R_386_GOT32 重定位标记。连接器会在 GOT 表中保留一项,注上 R_386_GLOB_DAT 重定位标记,用于加载器填写被引用元素的实际地址 。连接器还要计算该保留项在 GOT 表中的偏移,作为 movl 指令的变址寻址 方式操作数。代码片断如下:
movl x@GOT(%ebx), %eax
.o: R_386_GOT32
.so: R_386_GLOB_DAT
需要指出,引用全局函数时,由 GOT 表读出不是全局函数的实际入口地址,而是该函数在 PLT 表中的入口 .PLTfun 。这样,无论直接调用,还是先取得函数地址再间接调用,程序流程都会转入 PLT 表,进而把控制权转移给加载器。加载器就是利用这个机会进行动态连接的。
注意:这里讨论的是变量函数的引用,不是函数的直接调用,而是函数,变量的地址的取得,如果是函数的话,取得的实际上是 plt 里面的地址,于是最终还是没能逃过加载器的协助。
5,
直接调用函数
如前所述,浮动代码中的函数调用语句会编译成相对跳转指令。首先编译器会在目标文件中设上一个 R_386_PLT32 重定位标记,然后视静态函数、全局函数不同而连接过程也有所不同。
如果是静态函数,调用一定来自同一运行模块,调用点相对于函数入口点的偏移量在连接时就可计算出来,作为 call 指令的相对当前 IP 偏移跳转操作数,由此直接进入函数入口,不用加载器操心 。相关代码片断如下:
call f@PLT
.o: R_386_PLT32
.so: NULL
如果是全局函数,连接器将生成到 .PLTfun 的相对跳转指令,之后就如前面所述,对全局函数的第一次调用会把程序流程转到加载器中去,然后计算函数的入口地址,填充 fun@GOT 表项。这称为 R_386_JMP_SLOT 重定位方式。相关代码片断如下:
call f@PLT
.o: R_386_PLT32
.so: R_386_JMP_SLOT
如此一来,一个全局函数可能有多至两个重定位项。一个是必需 JMP_SLOT 重定位项,加载器把它指向真正的函数入口;另一个是 GLOB_DAT 重定位 项,加载器把它指向 PLT 表中的代码片断。取函数地址时,取得的总是 GLOB_DAT 重定位项的值,也就是指向 .PLTfun ,而不是真正的函数入口。
进一步考虑这样一个问题:两个动态连接库,取同一个全局函数的地址,两个结果进行比较。由前面的讨论可知,两个结果都没有指向函数的真正入口,而是分别指向两个不同的 PLT 表。简单进行比较,会得出 " 不相等 " 的结论,显然不正确,所以要特殊处理。
注意:
一个是必需 JMP_SLOT 重定位项,这里指的就是直接调用函数的情况;
另一个是 GLOB_DAT 重定位 项,这里指函数地址引用的情况;
6, 数据段的重定位
在数据段中的重定位是指对指针类型的静态变量、全局变量进行初始化。它与代码段中的重定位比较起来至少有以下明显不 同:一、在用户程序获得控制权( main 函数开始执行)之前就要全部完成;二、不经过 GOT 表间接寻址,这是因为此时 %ebx 中还没有正确的 GOT 表首地 址;三、直接修改数据段,而代码段重定位时不能修改代码段。
如果引用的是静态变量、函数、串常量,编译器会在目标文件中设上 R_386_32 重定位标记,并计算被引用变量、函数相对于所在段首地址的偏移量。连接器把它改成 R_386_RELATIVE 重定位标记,计算它相对于动态连接库首地址(通常为零)的偏移量。加载器会把运行模块真正的首地址(不为零)与该偏移量相加,结果用来初始化指针变量 。代码片断如下:
.section .rodata
.LC0: .string "Ok/n"
.data
p: .long .LC0
.o: R_386_32 w/ section
.so: R_386_RELATIVE
如果引用的是全局变量、函数,编译器同样设上 R_386_32 重定位标记,并且记录引用的符号名字。连接器不必动作。最后加载器查找被引用符号,结果用来初始化指针变量。对于全局函数,查找的结果仍然是函数在 PLT 表中的代码片断,而不是实际入口。这与前面引用全局函数的讨论相同。代码片断如下:
.data
p: .long printf
.o: R_386_32 w/ symbol
.so: R_386_32 w/ symbol
7, 总结:
下表给出了前面讨论得到的全部结果:
.o .so
--------------------------------------------------------------------------
| 装载 GOT 表首地址 R_386_GOTPC NULL
代码段 |-----------------------------------------------------
重定位 | 引用变量函数地址 静态 R_386_GOTOFF NULL
| 全局 R_386_GOT32 R_386_GLOB_DAT
|-----------------------------------------------------
| 直接调用函数 静态 R_386_PLT32 NULL
| 全局 R_386_PLT32 R_386_JMP_SLOT
------|-----------------------------------------------------
数据段 | 引用变量函数地址 静态 R_386_32 w/sec R_386_RELATIVE
重定位 | 全局 R_386_32 w/sym R_386_32 w/sym