assembly - 为什么除了 GOT 之外还有 PLT,而不是仅仅使用 GOT?

标签 assembly executable elf dynamic-linking

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

准确地说,在将 GOT 入口点延迟绑定(bind)回 PLT 之前,跳转到 GOT 之后的指令。这些指令通常会跳转到 PLT 的头部,从那里调用一些绑定(bind)例程,然后更新 GOT 条目。

现在我想知道为什么有两种间接方式(调用 PLT,然后从 GOT 跳转到一个地址),而不是仅仅保留 PLT 并直接从 GOT 调用地址。看起来这可以节省跳跃和完整的 PLT。当然,您仍然需要一些代码来调用绑定(bind)例程,但这可以在 PLT 之外。

有什么我想念的吗?额外 PLT 的目的是什么?

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

据我了解,这是在当前 PLT 方案中延迟绑定(bind)之前的情况:(PLT 和 printf 之间的一些间接关系由“...”表示。)

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

…在惰性绑定(bind)之后:
Program                PLT                       printf
+---------------+      +------------------+      +-----+
| ...           |      | push [0x603008]  |  +-->| ... |
| call j_printf |--+   | jmp [0x603010]   |  |   +-----+
| ...           |  |   | ...              |  |
+---------------+  +-->| jmp [printf@GOT] |--+
                       | push 0xf         |
                       | jmp 0x400da0     |
                       | ...              |
                       +------------------+

在我想象的没有 PLT 的替代方案中,惰性绑定(bind)之前的情况如下所示:(我将“惰性绑定(bind)表”中的代码与 PLT 中的代码相似。它也可能看起来不同,我不关心。)
Program                    Lazy Binding Table                printf
+-------------------+      +------------------+              +-----+
| ...               |      | push [0x603008]  |<-+       +-->| ... |
| call [printf@GOT] |--+   | jmp [0x603010]   |--+--...--+   +-----+
| ...               |  |   | ...              |  |
+-------------------+  +-->| push 0xf         |  |
                           | jmp 0x400da0     |--+
                           | ...              |
                           +------------------+

现在在惰性绑定(bind)之后,不再使用该表:
Program                   Lazy Binding Table        printf
+-------------------+     +------------------+      +-----+
| ...               |     | push [0x603008]  |  +-->| ... |
| call [printf@GOT] |--+  | jmp [0x603010]   |  |   +-----+
| ...               |  |  | ...              |  |
+-------------------+  |  | push 0xf         |  |
                       |  | jmp 0x400da0     |  |
                       |  | ...              |  |
                       |  +------------------+  |
                       +------------------------+

最佳答案

问题是替换 call printf@PLTcall [printf@GOTPLT]要求编译器知道函数 printf存在于共享库中而不是静态库中(甚至仅存在于普通对象文件中)。链接器可以更改 call printf进入 call printf@PLT , jmp printf进入 jmp printf@PLT甚至mov eax, printf进入 mov eax, printf@PLT因为它所做的只是根据符号 printf 更改重定位根据符号 printf@PLT 进行重定位.链接器无法更改 call printf进入 call [printf@GOTPLT]因为它从重定位中不知道它是 CALL 指令还是 JMP 指令或完全其他的东西。在不知道它是否是 CALL 指令的情况下,它不知道是否应该将操作码从直接 CALL 更改为间接 CALL。

但是,即使有一个特殊的重定位类型表明该指令是 CALL,您仍然会遇到直接调用指令是 5 个字节长而间接调用指令是 6 个字节长的问题。编译器必须发出 nop; call printf@CALL 之类的代码给链接器空间来插入所需的额外字节,并且它必须为对任何全局函数的所有调用执行此操作。由于所有额外且实际上不是必需的 NOP 指令,它可能最终会导致净性能损失。

另一个问题是在 32 位 x86 目标上,PLT 条目在运行时被重新定位。间接jmp [xxx@GOTPLT] PLT 中的指令不像直接 CALL 和 JMP 指令那样使用相对寻址,因为 xxx@GOTPLT 的地址取决于图像在内存中的加载位置,需要修复指令以使用正确的地址。通过将所有这些间接 JMP 指令组合成一个 .plt section 意味着需要修改的虚拟内存页面数量要少得多。每个被修改的 4K 页面都不能再与其他进程共享,当需要修改的指令分散在内存中时,它需要更大的部分图像不共享。

请注意,这个后面的问题只是共享库和在 32 位 x86 目标上定位独立可执行文件的问题。传统的可执行文件无法重定位,因此无需修复 @GOTPLT 引用,而在 64 位 x86 目标上,RIP 相对寻址用于访问 @GOTPLT 条目。

由于最后一点,新版本的 GCC(6.1 或更高版本)支持 -fno-plt旗帜。在 64 位 x86 目标上,此选项导致编译器生成 call printf@GOTPCREL[rip]指令而不是 call printf指示。但是,对于未在同一编译单元中定义的函数的任何调用,它似乎都会这样做。那就是它不确定的任何函数都没有在共享库中定义。这意味着间接跳转也将用于调用其他目标文件或静态库中定义的函数。在 32 位 x86 目标上,-fno-plt除非编译与位置无关的代码( -fpic-fpie ),否则该选项将被忽略,它会导致 call printf@GOT[ebx]正在发出的指令。除了产生不必要的间接跳转之外,这还有一个缺点,即需要为 GOT 指针分配一个寄存器,尽管大多数函数无论如何都需要分配它。

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

另请阅读 Drepper 的 How to write a shared library论文,它详细解释了这一点(对于Linux)。

关于assembly - 为什么除了 GOT 之外还有 PLT,而不是仅仅使用 GOT?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/56283367/

相关文章:

java - 如何为Mac制作可执行文件来运行java命令

ubuntu - LD_PRELOAD 没有可见效果

java - 高级语言(例如Java)和汇编语言之间的关系?

loops - 在汇编中编写while循环

parsing - 我想写一个编译器;你推荐什么程序集(x86)/后端?是否使用 BISON?

c++ - 创建大小小于 1MiB 的静态二进制文件,可以从 S3 或 GCS 下载文件

ruby - 为什么字符串索引返回一个整数值而不是一个字符?

c - 32位x86代码是否需要专门为共享库文件进行PIC编译?

c - 为什么 ELF 头和文本段一起加载到内存中?

c# - 获取 C# 程序的已编译 asm