链接器:第一视角深入理解 PLT 和 GOT

前言

最近复习汇编,看到 PLT 和 GOT 又忘了是什么原理了。所以记个笔记。我们会从例子入手,从编译器和操作系统内核的视角来观察整个过程。

本文并不是简中网上烂大街的 PLTGOT 科普文,一个典型区别就是本文会详细介绍每个 push 的参数含义和 jmp 的目标的含义。大多数文章都没有说清楚这一点。

所有的汇编代码已经简化,删掉了无关的部分。

源代码

我们有以下两个C文件:

main.c

1#include <stdio.h>
2
3int shared_function();
4
5int main() {
6    printf("Calling shared function, got %d\n", shared_function());
7    return 0;
8}

shared.c

1#include <stdio.h>
2
3int shared_function() {
4    return 42;
5}

编译和链接

1. 编译共享库

我们首先编译 shared.c 成一个共享库 libshared.so

1gcc -O0 -fPIC -shared -o libshared.so shared.c
  • -fPIC:生成位置无关代码。

  • -shared:生成共享库。

2. 编译可执行文件并链接共享库

1gcc -O0 -o main main.c -L. -lshared
  • -L.:指定库搜索路径为当前目录。

  • -lshared:链接共享库 libshared.so

运行看看:

$ LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH ./main 
Calling shared function:
Hello from shared library!

生成 GOT 和 PLT

编译器生成的汇编代码(main.c)

objdump -sd main > main.s

小知识:如果你更习惯 Intel 汇编,加上参数 -M intel

编译器会将 main.c 转换为汇编代码。我们来看看关键部分,特别是 shared_function 调用的部分。

 1Contents of section .rodata:
 2 2000 01000200 00000000 43616c6c 696e6720  ........Calling 
 3 2010 73686172 65642066 756e6374 696f6e2c  shared function,
 4 2020 20676f74 2025640a 00                  got %d..       
 5
 60000000000001149 <main>:
 7    1149:	55                   	push   %rbp
 8    114a:	48 89 e5             	mov    %rsp,%rbp
 9    114d:	b8 00 00 00 00       	mov    $0x0,%eax
10    1152:	e8 e9 fe ff ff       	call   1040 <shared_function@plt>
11    1157:	89 c6                	mov    %eax,%esi
12    1159:	48 8d 05 a8 0e 00 00 	lea    0xea8(%rip),%rax        # 2008 <_IO_stdin_used+0x8>
13    1160:	48 89 c7             	mov    %rax,%rdi
14    1163:	b8 00 00 00 00       	mov    $0x0,%eax
15    1168:	e8 c3 fe ff ff       	call   1030 <printf@plt>
16    116d:	b8 00 00 00 00       	mov    $0x0,%eax
17    1172:	5d                   	pop    %rbp
18    1173:	c3                   	ret
  • call 1040 <shared_function@plt> 通过 plt 调用 shared_function

  • lea 0xea8(%rip),%rax 计算 rip - 0xea8,将结果放到 rax。这其实就是 2008,即字符串 Calling...

  • 关键部分:call 1030 <printf@plt> 通过 plt 调用 printf,第一个参数是 rdi 即上面提到的字符串,第二个参数 esieaxshared_function 的返回值。

生成GOT和PLT的过程

PLT

PLT(Procedure Linkage Table)表用于延迟绑定函数地址,这一个 section 的映射区域是 readonly。在 main 可执行文件中,PLT表如下所示:

 1Disassembly of section .plt:
 2
 30000000000001020 <printf@plt-0x10>:
 4    1020:	ff 35 ca 2f 00 00    	push   0x2fca(%rip)        # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
 5    1026:	ff 25 cc 2f 00 00    	jmp    *0x2fcc(%rip)        # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
 6    102c:	0f 1f 40 00          	nopl   0x0(%rax)
 7
 80000000000001030 <printf@plt>:
 9    1030:	ff 25 ca 2f 00 00    	jmp    *0x2fca(%rip)        # 4000 <printf@GLIBC_2.2.5>
10    1036:	68 00 00 00 00       	push   $0x0
11    103b:	e9 e0 ff ff ff       	jmp    1020 <_init+0x20>
12
130000000000001040 <shared_function@plt>:
14    1040:	ff 25 c2 2f 00 00    	jmp    *0x2fc2(%rip)        # 4008 <shared_function@Base>
15    1046:	68 01 00 00 00       	push   $0x1
16    104b:	e9 d0 ff ff ff       	jmp    1020 <_init+0x20>

可以看到,每个函数(如 printfshared_function)在 .plt 中有一个入口,并由三部分组成:

  1. 一个 jmp 指令。目标指向 GOT ,即尝试直接查表并跳转到真实符号。

  2. 一个 push 指令。这个指令和后一个指令是一起的,push 负责传参(偷偷告诉你叫做 reloc_arg)。

  3. 第二个 jmp 指令。这个指令负责跳转到 lazy binding 逻辑。即 printf@plt-0x10 这里。

1020 处有一个 printf@plt-0x10,实际上它并不是 printf 专用的,而是所有 .plt 表项共用的一串代码。我们分析它的逻辑:

  • push 0x2fca(%rip),这是 link_map 的地址。

  • jmp *0x2fcc(%rip),跳转到 _dl_runtime_resolve(这是 ld.so 提供的函数)

我们回头发现,_dl_runtime_resolve 实际上被赋予了两个参数:link_mapreloc_arg。根据这些信息,能够完成动态链接。有关这背后的原理,参阅 Understanding _dl_runtime_resolve() - Peilin Ye’s blog (ypl.coffee)

GOT 和 GOT for PLT

Contents of section .got:
 3fc0 00000000 00000000 00000000 00000000  ................
 3fd0 00000000 00000000 00000000 00000000  ................
 3fe0 00000000 00000000                    ........        
Contents of section .got.plt:
 3fe8 e03d0000 00000000 00000000 00000000  .=..............
 3ff8 00000000 00000000 36100000 00000000  ........6.......

小知识:.got 存动态链接全局变量地址,.got.plt 存动态链接全局函数地址

其中 .got.plt 的前三项是:

  • .dynamic section pointer

  • link_map pointer

  • _dl_runtime_resolve pointer

后面的项则是各个函数的 GOT 表项。当尚未解析时,指回来源的 plt 的下个指令。 比如:36100000 00000000,实际上是从 jmp *0x2fca(%rip) 跳到这里的,然后读到这个地址(36...),跳回了 1036: 68 00 00 00 00 push $0x0(请读者 CtrlF 一下)。

晕了不?got plt.got plt got.plt

别问 ChatGPT,试了一下基本说不对

  • .got 就是正儿八经的 Global Offset Table,是一个指针数组。每个 item 都对应一个全局符号。

  • 一开始是空,会被动态链接/加载器(比如 ld.so)在函数首次调用时更新。

  • .plt 用于延迟绑定。分为变量部分和函数部分。

    • 函数部分特意叫做 .got.plt 专门用于存储与过程链接表(PLT)相关的函数地址。

    • 它主要用于动态链接库函数的延迟绑定(lazy binding)。即,函数地址在第一次调用时才会被解析并填充到 .got.plt 表中。

  • .plt.got section 会直接跳转到真实目标。它不 lazy。

为什么不全部在一开始就完成解析?

因为程序只使用所有符号的一个子集,全部解析没必要。

总结

0000000000001020 <printf@plt-0x10>:
    1020:	ff 35 ca 2f 00 00    	push   0x2fca(%rip)        # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
    1026:	ff 25 cc 2f 00 00    	jmp    *0x2fcc(%rip)        # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
    102c:	0f 1f 40 00          	nopl   0x0(%rax)

0000000000001030 <printf@plt>:
    1030:	ff 25 ca 2f 00 00    	jmp    *0x2fca(%rip)        # 4000 <printf@GLIBC_2.2.5>
    1036:	68 00 00 00 00       	push   $0x0
    103b:	e9 e0 ff ff ff       	jmp    1020 <_init+0x20>
  • 当程序第一次调用 printf 时,会首先执行 printf@plt(地址 1030)。

    • *0x2fca(%rip) 指向的地方,是GOT表中存储的实际函数地址,第一次并不是真正的函数地址,而是指向下条指令(1036)。

    • 因此程序继续执行 push,这是重定位索引参数。

    • 然后跳转到 printf@plt-0x10(地址 1020)。

  • 于是我们执行到 printf@plt-0x10,这部分不是 printf 特有的逻辑,而是各个 @plt 函数共享的逻辑,用来把 link_map 参数压栈。

    • push 0x2fca(%rip) 将 GOT 表中的 link_map 地址压入栈中。

    • jmp *0x2fcc(%rip) 跳转到动态链接器,动态链接器(例如_dl_runtime_resolve)解析函数地址

    • 解析函数会处理堆栈上的信息(包括之前 push 的地址),确定需要解析的函数(如 printf)。

    • 动态链接器查找实际的 printf 函数地址。

    • 动态链接器将实际的 printf 函数地址写入 _GLOBAL_OFFSET_TABLE_ 中 0x3ff8 处。

    • 之后的调用将直接使用这个地址,避免再次解析。

它是一个双链表,存放了已加载的共享库(共享对象,shared objects)的状态和属性。例如库名、库基地址等等。详细的不展开,这里说一下大致原理:

  1. 遍历 link_map 链表:动态链接器遍历 link_map 链表,查找包含目标函数符号的共享库。

  2. 查找符号表:每个共享库中,动态链接器会查找符号表( .dynsym section)匹配目标函数符号。

  3. 找到符号地址:一旦找到符号,动态链接器会根据符号表条目中的偏移量和共享库的基地址计算出符号的真实地址。

  4. 更新重定位表:把地址写道 got 中,下次就不用再查找了。

参考