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

Why does the PLT exist in addition to the GOT, instead of just using the GOT?

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

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

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

有什么我遗漏的吗? is/was 额外 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     |--+
                           | ...              |
                           +------------------+

现在在惰性绑定之后,人们不会再使用 table:

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

Now I'm wondering why there are two indirections (calling into the PLT and then jumping to an address from the GOT),

首先有两个调用,但只有一个间接调用(对PLT存根的调用是直接)。

instead of just sparing the PLT and calling the address from the GOT directly.

如果不需要惰性绑定,可以使用 -fno-plt 绕过 PLT。

但是如果你想保留它,你需要一些存根代码来查看符号是否已经解析并相应地分支。现在,为了便于分支预测,必须为每个调用的符号复制此存根代码,voila,你重新发明了 PLT。

问题是用 call [printf@GOTPLT] 替换 call printf@PLT 需要编译器知道函数 printf 存在于共享库中而不是静态库中(或者甚至只是一个普通目标文件)。 linker 可以将 call printf 更改为 call printf@PLT,将 jmp printf 更改为 jmp printf@PLT,甚至可以将 mov eax, printf 更改为 mov eax, printf@PLT,因为它正在这样做将基于符号 printf 的重定位更改为基于符号 printf@PLT 的重定位。 linker 无法将 call printf 更改为 call [printf@GOTPLT],因为它无法从重定位中得知它是 CALL 指令还是 JMP 指令,或者完全是其他指令。在不知道是不是CALL指令的情况下,不知道是否应该将操作码从直接CALL改为间接CALL。

然而,即使有一个特殊的重定位类型表明指令是一个CALL,你仍然会遇到直接调用指令是5字节长而间接调用指令是6字节长的问题。编译器必须发出像 nop; call printf@CALL 这样的代码来给 link er 空间来插入所需的额外字节,并且它必须对所有对任何全局函数的调用都这样做。由于所有额外的和实际上不必要的 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 目标上,忽略 -fno-plt 选项,除非编译位置无关代码(-fpic-fpie),它会导致发出 call printf@GOT[ebx] 指令。除了生成不必要的间接跳转之外,这还有一个缺点,即需要为 GOT 指针分配一个寄存器,尽管大多数函数无论如何都需要分配它。

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

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