为什么除了使用GOT之外,还需要存在PLT?

45

我知道在典型的ELF二进制文件中,函数通过过程链接表(PLT)被调用。函数的PLT条目通常包含一个跳转到全局偏移表(GOT)条目的指令。此条目将首先引用一些代码将实际函数地址加载到GOT中,并在第一次调用后包含实际函数地址(延迟绑定)。

确切地说,在进行延迟绑定之前,GOT条目指向PLT中的指令,跳转到GOT中的地址后。这些指令通常会跳转到PLT的开头,从那里调用一些绑定例程,然后更新GOT条目。

现在,我想知道为什么有两个间接引用(进入PLT,然后跳转到GOT中的地址),而不是直接从GOT中调用地址并省略PLT。看起来,这可以节省一个跳转和整个PLT。当然,你仍然需要一些调用绑定例程的代码,但这可以在PLT之外。

我是否遗漏了什么?额外的PLT的目的是什么?


更新: 如评论中所建议的,我创建了一些(伪)代码ASCII艺术作进一步解释。:

这就是我所理解的当前PLT方案在延迟绑定之前的情况:(PLT和printf之间的一些间接引用由“...”表示。)

Program                PLT                                 printf
+---------------+      +------------------+                +-----+
| ...           |      | push [0x603008]  |<---+       +-->| ... |
| call j_printf |--+   | jmp [0x603010]   |----+--...--+   +-----+
| ...           |  |   | ...              |    |
+---------------+  +-->| jmp [printf@GOT] |-+  |
                       | push 0xf         |<+  |
                       | jmp 0x400da0     |----+
                       | ...              |
                       +------------------+

...并且在进行惰性绑定之后:

Program                PLT                       printf
+---------------+      +------------------+      +-----+
| ...           |      | push [0x603008]  |  +-->| ... |
| call j_printf |--+   | jmp [0x603010]   |  |   +-----+
| ...           |  |   | ...              |  |
+---------------+  +-->| jmp [printf@GOT] |--+
                       | push 0xf         |
                       | jmp 0x400da0     |
                       | ...              |
                       +------------------+

在我想象中的没有PLT的替代方案中,在延迟绑定之前的情况如下所示:(我将代码保持在“Lazy Binding Table”中,与PLT中的代码类似。它也可能看起来不同,但我不在意。)

Program                    Lazy Binding Table                printf
+-------------------+      +------------------+              +-----+
| ...               |      | push [0x603008]  |<-+       +-->| ... |
| call [printf@GOT] |--+   | jmp [0x603010]   |--+--...--+   +-----+
| ...               |  |   | ...              |  |
+-------------------+  +-->| push 0xf         |  |
                           | jmp 0x400da0     |--+
                           | ...              |
                           +------------------+

现在进行了延迟绑定之后,将不再使用该表:

Program                   Lazy Binding Table        printf
+-------------------+     +------------------+      +-----+
| ...               |     | push [0x603008]  |  +-->| ... |
| call [printf@GOT] |--+  | jmp [0x603010]   |  |   +-----+
| ...               |  |  | ...              |  |
+-------------------+  |  | push 0xf         |  |
                       |  | jmp 0x400da0     |  |
                       |  | ...              |  |
                       |  +------------------+  |
                       +------------------------+
2个回答

36
问题在于将call printf@PLT替换为call [printf@GOTPLT]需要编译器知道函数printf存在于共享库而不是静态库(甚至只是一个纯对象文件)。链接器可以将call printf更改为call printf@PLTjmp printf更改为jmp printf@PLT或者是将mov eax, printf更改为mov eax, printf@PLT,因为它所做的只是基于符号printf的重定位而进行一些变化。但是,链接器无法将call printf更改为call [printf@GOTPLT],因为它无法从重定位中了解到它是CALL还是JMP指令,或者完全是其他什么指令。如果不知道它是否为CALL指令,则无法确定是否应将操作码从直接CALL更改为间接CALL。
但是,即使有一种特殊的重定位类型表明指令是CALL,您仍然会面临这样的问题:直接CALL指令长度为5个字节,而间接CALL指令长度为6个字节。编译器必须像这样发出代码:nop; call printf@CALL,以便为链接器腾出所需的额外一个字节,并且必须对所有调用任何全局函数的调用都这样做。由于所有额外且实际上没有必要的NOP指令,它可能最终成为净性能损失。另一个问题是,在32位x86目标上,在运行时会重新定位PLT条目。 PLT中的间接jmp [xxx@GOTPLT]指令不像直接CALL和JMP指令一样使用相对寻址,因此由于 xxx @ GOTPLT 的地址取决于镜像在内存中的加载位置,该指令需要修正以使用正确的地址。将所有这些间接JMP指令分组到一个 .plt 部分中意味着需要修改更少数量的虚拟内存页面。修改的每个4K页面都不能再与其他进程共享,当需要修改的指令散布在整个内存中时,需要取消共享图像的一个更大部分。
请注意,这个较晚的问题只适用于32位x86目标上的共享库和位置无关可执行文件。传统的可执行文件无法被重新定位,因此没有必要修复@GOTPLT引用,而在64位x86目标上,使用RIP相对寻址来访问@GOTPLT条目。
由于最后一点,GCC的新版本(6.1或更高版本)支持 -fno-plt 标志。对于64位x86目标,在使用此选项时,编译器会生成 call printf@GOTPCREL [rip] 指令,而不是 call printf 指令。但是,它似乎会针对任何调用不在同一编译单元中定义的函数执行此操作。也就是说,任何它不能确定是否在共享库中定义的函数都将使用间接跳转。在32位x86目标上,除非编译位置无关代码( -fpic -fpie ),否则会忽略 -fno-plt 选项,其中会发出 call printf@GOT [ebx] 指令。除了生成不必要的间接跳转之外,这还有一个缺点,需要为GOT指针分配寄存器,尽管大多数函数都需要分配它。

最后,Windows可以通过在头文件中声明带有“dllimport”属性的符号来实现您建议的功能,表示它们存在于DLL中。这样编译器就知道在调用函数时是否生成直接或间接的调用指令。缺点是符号必须存在于DLL中,因此如果使用此属性,则无法在编译后决定链接到静态库。

还应阅读Drepper的如何编写共享库文章,它详细解释了这一点(适用于Linux)。


1
如果我没记错的话,连接程序可以将间接调用call myfunc @ GOTPCREL [rip]变成call myfunc,如果它确实可以直接链接到相同的库中。 (如果我没记错,它使用段覆盖前缀来填充call rel32以填充6字节插槽)。 - Peter Cordes
据我所知,假设在同一编译单元内不存在任何函数调用间接发生,那么对于-fPIE或非 PIE 可执行文件来说并不会发生,只有使用-fPIC时才会发生。(相关:有选项可设置默认符号可见性来控制是否需要假定符号重定位。) - Peter Cordes

4
现在我在想为什么会有两次间接调用(先调用PLT然后跳转到GOT地址)。
首先,有两个调用,但只有一个间接调用(对于PLT存根的调用是直接的)。
与其只是跳过PLT并直接从GOT调用地址,不如保留它。如果您不需要延迟绑定,可以使用-fno-plt,该选项将绕过PLT。但是,如果您想要保留它,则需要一些存根代码来查看符号是否已解析并相应地进行分支。现在,为了方便分支预测,必须为每个被调用的符号复制此存根代码,这样你就重新发明了PLT。

“_direct_” 的意思是调用目标是静态的,而不是从内存中读取的?这当然是正确的,但除了调用之外还有一个不必要的跳转(总共一次调用和一次跳转)。在现代 x86 上,无条件跳转可能并不是什么大问题,但对于所有架构来说都不一定如此,并且对于代码缓存局部性来说肯定没有好处。 - F30
我的“重新发明”的PLT与原始版本相似,因为它可能包含所有函数的绑定存根。但对我来说,重要的区别在于,并非每个调用都必须从PLT到GOT(再返回一次)。相反,它直接到达GOT并返回到“重新发明”的PLT进行第一次调用。 - F30
1
“通过直接调用,您的意思是调用目标是静态的,而不是从内存中读取” - 这不仅仅是意思,这就是直接调用的定义。它们肯定有成本,但比间接调用低得多,因此准确性非常重要。 - yugr
@F30 “不是每个调用都必须从PLT到GOT” - 但在您的情况下,在从GOT获取地址然后跳转之后,代码必须在存根中再次跳转,具体取决于地址是否已解析。请注意,第二次跳转将是条件间接跳转,比直接跳转到PLT要重得多。因此,您的方法将1个直接跳转和1个间接跳转交换为2个间接跳转(其中1个是条件跳转)。如果您有其他想法,请建议在问题中添加伪代码。 - yugr
不,我不想在延迟绑定后使用存根。我更新了问题,并添加了一些ASCII艺术图像来解释我的想法。 - F30
@F30 很好的发现,但如果初始GOT地址存储在寄存器中,则无法工作(例如,当从长时间运行的循环中首次调用某些数学函数时)- 应用程序将继续使用缓慢的路径,即使在解决后也是如此。 - yugr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接