动态链接中PLT与GOT工作流程
动态链接中PLT与GOT工作流程
前言
在之前的《静态链接与动态链接》中,我们介绍了这两者的优缺点:动态链接的缺点主要就是动态链接的程序执行速度会比静态链接的程度略慢一些。
原因就在于动态链接的可执行程序对于模块间的变量以及函数访问,都需要通过GOT表进行间接跳转。如此一来,程序的运行速度肯定会有所减慢。
另一个很重要的原因就是动态链接的链接工作是在程序运行时来完成的,即程序开始执行前动态链接器会去寻找并且装载程序所需的动态共享对象,然后完成一系列的符号重定位操作。这部分动作肯定会减慢程序的启动速度
针对这种情况,一种称为“延迟绑定(Lazy Binding)”的解决办法出现了。延迟绑定的核心思想就是在程序启动时并不完成所有模块间函数调用的符号重定位操作,只有当目标程序需要调用某个模块外函数时才进行地址绑定(即符号查找、符号重定位)。
要实现以上的目标,ELF文件采用了**PLT(PProcedure Linkage Table)**的结构,这种结构内包含了一些很精妙的指令序列,这也是本文接下来所要讲解的内容。
大体逻辑思考
在讲解PLT具体细节之前,我们可以从自顶向下的角度来思考一下如何完成这一项工作。假设目标程序需要调用某个动态共享对象liba.so内的函数foo(),那么第一次调用该函数的时候,动态链接器就需要一个寻找foo函数地址的查找函数来完成绑定的工作。
那么这个查找函数需要哪些信息呢?首先要知道绑定行为发生在哪个模块内(目标程序主模块内),其次我们要知道具体要绑定哪个函数(foo()函数)。在Glibc中,这个查找函数的名字就叫做_dl_runtime_resolve()。把这个过程用伪代码描述出来,就如以下所示:
void DSOFunction@plt()
{
if (DSOFunction@got[index] != RELOCATED) {
//如果该函数是第一次调用,GOT表内还没有该函数的地址
让查找函数根据模块ID和被调用函数的ID来获取被调用函数的地址
并且填入GOT的对应表项之中
DSOFunction@got[index] = RELOCATED;
}
else{
//GOT表内已经有了该函数地址,直接跳转到该函数地址
jmp *DSOFunction@got[index];
}
}
这一段伪代码就是PLT结构之中的模块外函数的对应表项。将伪代码整理一下,我们就可以得到汇编语言级别的PLT表项的内容,如下所示:
foo@plt
jmp *(foo@got)
push n
push moduleID
jmp _dl_runtime_resolve
第一条指令就是跳转到foo()函数所对应的GOT表项,如果该GOT表项已经被绑定好了,那就可以直接跳转到正确的函数地址。如果是第一次调用该函数,其GOT表项内的内容是第二条指令“push n”的地址,这一步就实现伪代码中的if判断。
第二条指令就是将foo()函数所对应的函数ID压入栈内,这个ID是foo函数在重定位表中的下标。第三条指令就是将该模块的ID压入栈中,第四条指令就是跳转到我们上文所说的查找函数_dl_runtime_resolve()。_dl_runtime_resolve()进行一系列查找之后,会将foo()函数的绝对地址填入GOT的对应表项中,然后将控制流转到foo()函数上。
一旦foo()函数地址被成功绑定,之后再次调用foo在PLT的表项,就是直接通过GOT表项跳转到正确的地址上。以上就是GOT和PLT出现的大体逻辑。接下来讲解具体的工作流程。
具体工作流程
ELF文件将GOT分为两部分,分别是.GOT和.GOT.PLT,前者用于储存全局变量,后者用于保存DSO中的函数引用地址。这里要说明一点:PLT位于可执行程序的代码段,是可读不可写的;而GOT位于可执行程序的数据段,是可读可写的。另外.GOT.PLT还有一个特别之处在于它的前三项都是有特定含义的,含义分别如下所示:
- 第一项保存了.dynamic段的地址,这其中描述了本模块动态链接的相关信息
- 第二项保存本模块的ID
- 第三项保存了_dl_runtime_resolve的地址
以上几项的特殊含义就是链接器的精妙之处了:它将各个plt表项中的公共部分抽取出来,形成了一个可复用的公共指令序列。
因此实际上PLT的汇编代码结构是下面这样的:
plt[0]
push *got[1]
push *got[2]
···
foo@plt
jmp *(foo@got)
push n
jmp plt[0]
明白了大体逻辑之后,我们就来看看实际例子来看看。以下是一个简单的helloworld可执行文件的.PLT以及.GOT。
Disassembly of section .plt:
00000000004003f0 <.plt>:
4003f0: ff 35 12 0c 20 00 pushq 0x200c12(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
4003f6: ff 25 14 0c 20 00 jmpq *0x200c14(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
4003fc: 0f 1f 40 00 nopl 0x0(%rax)
0000000000400400 <puts@plt>:
400400: ff 25 12 0c 20 00 jmpq *0x200c12(%rip) # 601018 <puts@GLIBC_2.2.5>
400406: 68 00 00 00 00 pushq $0x0
40040b: e9 e0 ff ff ff jmpq 4003f0 <.plt>
0000000000400410 <__libc_start_main@plt>:
400410: ff 25 0a 0c 20 00 jmpq *0x200c0a(%rip) # 601020 <__libc_start_main@GLIBC_2.2.5>
400416: 68 01 00 00 00 pushq $0x1
40041b: e9 d0 ff ff ff jmpq 4003f0 <.plt>
...
Disassembly of section .got:
0000000000600ff8 <.got>:
...
Disassembly of section .got.plt:
0000000000601000 <_GLOBAL_OFFSET_TABLE_>:
...
参考以上反汇编得到的代码,我们不难发现.plt有三个表项:
- 也就是.plt[0],首先将GOT表的第二项,即.GOT.PLT[1]压栈,然后跳转到.GOT.PLT[2],最后一条nop指令啥也不干
- 也就是.plt[1],首先跳转到.GOT.PLT[3],然后将0x0压栈,最后跳转回到.plt[0]
- 也就是.plt[2],首先跳转到.GOT.PLT[4],然后将0x1压栈,最后跳转回到.plt[0]
可以看到,以上指令的逻辑和我们在上文所分析是完全一致的。相关函数执行完之后,会将被调用函数的地址填入GOT表内,并且将程序执行流转给被调用函数,使程序继续执行下去。
总结
最后我们来总结一下PLT以及GOT的具体流程:
- 首先可执行程度在编译时就会做“编译重定位”,将模块外的函数的引用指向其对应的PLT表项之中,并且用PLT表项第二条指令的地址来初始化其对应的GOT表项。
- 当程序被启动时,动态链接器会从内核接过权限,更新GOT表中本模块ID以及_dl_runtime_resolve的地址。
- 当模块外函数被调用时,程序就会跳转到其对应的PLT表项,PLT表项进一步跳转到GOT表项中
- 如果GOT表项是函数地址,那就是直接调用该函数,程序控制流转移给被调用函数
- 如果GOT表项是第一次被调用,那就跳转到PLT表项的第二条指令,然后将函数ID以及模块ID压栈,作为参数传给_dl_runtime_resolve,_dl_runtime_resolve执行完成之后,将调用函数的地址填入对应的GOT表项之中,然后将程序控制流转移给被调用函数
动态链接通过延迟绑定和PLT结构将程序的启动速度提升了一些,使得动态链接的可执行程序运行速度可以有所改善。以上就是PLT和GOT的大体逻辑以及工作流程,希望对大家的理解有所帮助!