PLT (Procedure Link Table) / GOT (Global Offset Table) / 延迟绑定

简而言之:引入这两个表是为了实现通过更改数据段而不是代码段来实现动态链接。

在介绍 PLT/GOT 之前,先以一个简单的例子引入,各位请看以下代码:

#include <stdio.h>

void print_banner(){
    printf("Welcome to World of PLT and GOT\n");
}

int main(void){
    print_banner();
    return 0;
}

经过编译与链接后,可执行文件中 print_banner 函数的汇编指令是怎样的呢?我猜应该与下面的汇编类似:

080483cc <print_banner>:
 80483cc:    push %ebp
 80483cd:    mov  %esp, %ebp
 80483cf:    sub  0x8,80483d2:sub0x8, %esp
 80483d2:    sub  0xc, %esp
 80483d5:    push 0x80484a880483da:call<printf函数的地址>80483df:add0x80484a8  
 80483da:    call <printf函数的地址>
 80483df:    add 0x10, %esp
 80483e2:    nop
 80483e3:    leave
 80483e4:    ret

print_banner 函数内调用了 printf 函数,而 printf 函数位于 glibc 动态库内,所以在编译链接阶段,链接器无法知知道进程运行起来之后 printf 函数的加载地址。故上述的 <printf函数的地址> 一项是无法填充的,只有进程运行后,printf 函数的地址才能确定。

在运行时如何进行重定位呢?一个简单的方法就是直接把 <printf函数的地址> 修改为这个函数真正的地址即可。

这个方案有以下两个缺点:

  • 现代操作系统不允许修改代码段,只能修改数据段,这是出于安全的考虑;
  • 如果 print_banner 函数是在一个动态库(.so 对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库,因为不同的进程中,printf 函数所在动态库要映射到的进程地址空间是不一样的,写死在这里会有正确性问题(应该写那个进程的地址呢?)。

因此,这个简单的方法,也就是直接更改代码段是不可行的,我们应当通过更改数据段来实现我们的目标

运行时重定位无法修改代码段,只能将 printf 重定位到数据段。那在编译阶段就已生成好的 call 指令,怎么感知这个已重定位好的数据段内容呢?

答案是:链接器生成一段额外的小代码片段,通过这段代码支获取 printf 函数地址,并完成对它的调用

链接器生成额外的伪代码如下:

.text // 这是代码段 .........
...
// 调用printf的call指令
call printf_stub
...
printf_stub:
    mov rax, [printf函数的储存地址] // 获取printf重定位之后的地址
    jmp rax // 跳过去执行printf函数

.data // 这是数据段 .........
...
printf函数的储存地址:
  这里储存printf函数重定位后的地址

我们通过将 <printf函数的地址> 替代为 <printf_stub函数的地址>,然后插入一段 printf_stub 的代码。其中插入的代码表项我们称其为程序链接表(PLT,Procedure Link Table),位于代码段中,而存放函数地址的数据表项我们称其为全局偏移表(GOT, Global Offset Table),位于数据段中。之所以是表,可能是因为我们可能有多个符号都需要动态链接,这两个表都是为了动态链接而存在的。

全局偏移表 GOT 里的 printf 函数的运行时真正的地址是怎么被确认的呢?答案是在运行时才被确认。当 .so 在运行时被加载进来之后,它可能被映射在任何一块区域。现代系统默认使用延迟绑定的方式:

  1. 第一次调用函数:当你的代码第一次调用 printf 时,你实际上并没有直接调用 printf。你调用的是 printf@plt(即 printf 在 PLT 中的桩代码);
  2. 跳转到 GOT 中存储的地址。在第一次调用时,这个地址并不指向真正的 printf,而是指向 PLT 桩代码中的下一条指令;
  3. 将函数的标识符(一个数字)压入栈。
  4. 跳转到一个负责解析地址的公共 PLT 条目。
  5. 这个公共的 PLT 条目会调用动态链接器(ld-linux.so)。调用的这一步因为本身 ld-linux.so 也是一个动态库,它的真实地址也没有被映射到 GOT 中,那么如何调用呢?答案是 Program Interpreter^,或者搜 PT_INTERP。简而言之:动态链接器(ld-linux.so)的地址是在程序 load 前,由操作系统内核直接确定的(并不是一个固定值,不同程序可能不一样),不依赖于传统的动态链接机制。然后 OS 可能会修改这个 PLT 条目,让它调用到正确的动态链接库的地址
  6. 根据函数标识符,在已加载的动态库中查找 printf 的真正地址。
  7. 找到后,将这个真实地址写回到该函数对应的 GOT 条目中。
  8. 出栈、因为栈顶是我们压入的函数标识符,所以出栈后可以回到执行点并跳转到真正的 printf 函数并执行。
  9. 之后,任何对 printf 的调用,都会再次通过 printf@plt
  10. printf@plt 的第一条指令仍然是跳转到 GOT。但此时,GOT 中存储的已经是真正的 printf 函数地址了。所以指令会直接跳转到真正的 printf,无需再经过动态链接器。这个过程非常快。

当确定了 printf 函数真正的地址之后,如何跳转过去呢?因为在运行过程中 printf 函数是一个外部函数,并没有位于我们进程当中的代码段中。答案是每一个进程都维护了一个共享库的内存映射区域,大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的区域,这部分区域主要与链接过程相关。

延迟绑定是通过一个变量 LD_BIND_NOW(环境变量?)来控制开启和关闭的,ELF Spec 中有此记载:

![[ELF.pdf#page=77&rect=105,276,536,418&color=annotate ELF, p.77]]

仅仅对于动态库的函数调用需要 PLT 和 GOT 来协作,对于动态库的变量引用不需要 PLT,仅仅需要 GOT。

GOT 的核心思想:A program references its GOT using position-independent addressing and extracts absolute values, thus redirecting position-independent references to absolute locations.

GOT 是被一些 relocation entries 所指向的。动态链接器在扫描到 relocation entries 之后会解析出来对应的 symbol,计算出来这个 symbol 的绝对内存地址,然后把这个地址更新到 GOT 对应表项中去。

注:.got.plt 段是 GOT 段、.plt.got 段是 PLT 段,以前面的名字为准。

从汇编代码角度查看函数调用的动态链接过程

初始状态(GOT 表虽然名字里面带 Global,但是其实也不是全局的,也是一个 object file 有一个,因为 executable file 和 shared object file 里的代码都可能会访问到这个全局符号,也都通过位置无关的方式跳转到各自的 GOT 表,因此每一个 GOT 表里的这个符号都要进行更新):

GOT[0] = ...
// 动态链接器初始化时设置 GOT 的特殊条目
GOT[1] = 标识信息 (指向动态链接器数据结构)
GOT[2] = 动态链接器解析函数地址 (_dl_runtime_resolve)
...
// name1 对应的 GOT
//  - 初始值:          address of PLT1 的 pushl 指令
//  - 跳转过一次后的值:real virual address of the corresponding symbol
GOT[i] = 

// %ebx
%ebx 指向 GOT 的基地址,这个需要 calling function 来在 call PLT 之前提前来设置

PLT 表(PLT 表不是一个全局的,而是一个 shared object 都有一个自己的 PLT 表,并且只允许这个 shared object 里的符号跳转到这个 PLT 表):

; PLT0 的作用主要是为动态链接器准备其所需要的信息,比如标识,以及对应 relocation symbol 的 offset
.PLT0:                          ; PLT 表头
    pushl 4(%ebx)               ; 将 GOT[1] 压栈 (标识信息)
    jmp *8(%ebx)                ; 跳转到 GOT[2] (动态链接器)
    nop                         ; 对齐填充
    nop

.PLT1:                          ; name1 的 PLT 条目
    jmp *name1@GOT(%ebx)        ; 第一次跳转到 GOT 条目,里面存的就是下一个 pushl 指令
    pushl offset1;重定位偏移量jmp.PLT0;跳转到PLT表头.PLT2:;name2PLT条目jmpname2@GOT(pushloffset1              ; 重定位偏移量
    jmp .PLT0                   ; 跳转到 PLT 表头

.PLT2:                          ; name2 的 PLT 条目  
    jmp *name2@GOT(%ebx)
    pushl offset2
    jmp .PLT0
; 调用者代码
call name1@PLT                  ; 调用 PLT 条目

; 进入 .PLT1
.PLT1:
    jmp *name1@GOT(%ebx)        ; 第一次:GOT 条目指向下条指令
    ; ↓ 实际执行到这里
    pushl $offset1              ; 将重定位偏移量压栈,offset1 对应重定位表中 R_386_JMP_SLOT 类型的条目
    jmp .PLT0                   ; 跳转到 PLT 表头

; 进入 .PLT0  
.PLT0:
    pushl 4(%ebx)               ; 将 GOT[1] (标识信息) 压栈
    jmp *8(%ebx)                ; 跳转到 GOT[2] (动态链接器)

; 动态链接器工作
; 1. 从栈中读取 offset1 和标识信息,有了 offset1,动态链接器能够找到重定位表中对应的我们要跳转到的函数符号条目
; 2. 根据 offset1 在重定位表中找到对应的 R_386_JMP_SLOT 条目
; 3. 通过符号表索引找到 name1 符号
; 4. 加载 name1 的实际地址
; 5. 将 name1 的实际地址写入 GOT 中对应的条目
; 6. 跳转到 name1 函数执行

附 PLT 里的内容。 上面是可执行文件的 PLT 格式、下面是共享对象文件的 PLT 格式。可以看到他们之间最大的区别就是共享对象文件需要 ebx 而绝对的不需要,这是因为共享对象文件需要保证是 PIC 的,因此不能有绝对的内存跳转值,因为共享对象可能会被 map 到任何一块内存区域,不过这无伤大雅:

![[ELF.pdf#page=100&rect=67,101,545,496&color=annotate ELF, p.100]]

延迟绑定的好处与坏处

好处:

  • 如果这个程序压根没有执行到一些符号,那么这个符号就不会被解析,因此性能会更高;

坏处:

  • 延迟不稳定:第一次的访问比后续访问事件要长太多;
  • 如果动态链接器没有办法解析符号,那么程序会被关闭。延迟绑定有可能让这个在程序运行时而不是加载时发生。
![[ELF.pdf#page=102&rect=135,532,516,707&color=annotate ELF, p.102]]

为什么动态连接编译还要带上 so 文件?

假如说 Program1.cProgram2.c 两个文件都引用了动态链接库 Lib.so 中的文件,那么为什么在动态链接的时候还要指定上 Lib.so 呢?

因为虽然是动态链接,但是编译的时候总要告诉 Program,foobar 这个函数是动态链接吧。而 footbar 的符号信息就在 Lib.so 中,所以这里编译时还是要带上 Lib.so。这样,当编译器找不到 Program1.c, Program2.cfoo() 函数的实现时,再对比 Lib.so 中的符号表,那么就可以轻而易举地得出这个符号是需要动态链接的了。

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

Reference