链接器:第一视角深入理解 PLT 和 GOT
前言
最近复习汇编,看到 PLT 和 GOT 又忘了是什么原理了。所以记个笔记。我们会从例子入手,从编译器和操作系统内核的视角来观察整个过程。
本文并不是简中网上烂大街的 PLT
和 GOT
科普文,一个典型区别就是本文会详细介绍每个 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
即上面提到的字符串,第二个参数esi
是eax
即shared_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>
可以看到,每个函数(如 printf
和 shared_function
)在 .plt
中有一个入口,并由三部分组成:
-
一个 jmp 指令。目标指向 GOT ,即尝试直接查表并跳转到真实符号。
-
一个 push 指令。这个指令和后一个指令是一起的,push 负责传参(偷偷告诉你叫做
reloc_arg
)。 -
第二个 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_map
和 reloc_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
处。 -
之后的调用将直接使用这个地址,避免再次解析。
-
link_map 是什么东西?怎么工作的?
它是一个双链表,存放了已加载的共享库(共享对象,shared objects)的状态和属性。例如库名、库基地址等等。详细的不展开,这里说一下大致原理:
-
遍历
link_map
链表:动态链接器遍历link_map
链表,查找包含目标函数符号的共享库。 -
查找符号表:每个共享库中,动态链接器会查找符号表(
.dynsym
section)匹配目标函数符号。 -
找到符号地址:一旦找到符号,动态链接器会根据符号表条目中的偏移量和共享库的基地址计算出符号的真实地址。
-
更新重定位表:把地址写道 got 中,下次就不用再查找了。