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

Posted

技术标签:

【中文标题】为啥除了 GOT 之外还有 PLT,而不是仅仅使用 GOT?【英文标题】:Why does the PLT exist in addition to the GOT, instead of just using the GOT?为什么除了 GOT 之外还有 PLT,而不是仅仅使用 GOT? 【发布时间】:2017-08-20 07:26:01 【问题描述】:

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

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

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

我有什么遗漏吗?额外 PLT 的目的是什么?


更新: 正如 cmets 中所建议的,我创建了一些(伪)代码 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 的替代方案中,惰性绑定之前的情况如下所示:(我将“惰性绑定表”中的代码保留为类似于 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     |  |
                       |  | ...              |  |
                       |  +------------------+  |
                       +------------------------+

【问题讨论】:

【参考方案1】:

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

但是,即使有一个特殊的重定位类型表明该指令是 CALL,您仍然会遇到直接调用指令是 5 个字节长而间接调用指令是 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 目标上,-fno-plt 选项将被忽略,除非编译位置无关代码(-fpic-fpie)会导致发出call printf@GOT[ebx] 指令。除了产生不必要的间接跳转之外,这还有一个缺点,即需要为 GOT 指针分配一个寄存器,尽管大多数函数无论如何都需要分配它。

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

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

【讨论】:

IIRC,如果发现myfunc 可以直接链接到同一个库,则链接器可以将间接call myfunc@GOTPCREL[rip] 放松为call myfunc。 (并且 IIRC 它使用段覆盖前缀来填充 call rel32 以填充 6 字节插槽)。 IIRC,假设 -fPIE 或非 pie 可执行文件不会发生不在同一编译单元中的任何函数调用的间接调用,仅适用于 -fPIC。 (相关:有设置默认符号可见性的选项来控制是否必须假设符号插入。)【参考方案2】:

现在我想知道为什么有两个间接 (调用PLT,然后从GOT跳转到一个地址),

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

而不是仅仅保留 PLT 并直接从 GOT 调用地址。

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

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

【讨论】:

1. “direct”是指调用目标是静态的而不是从内存中读取的?这当然是对的,但是除了调用之外还有一个不必要的跳转(一调用一跳转)。在现代 x86 上,无条件跳转可能没什么大不了的,但这可能不适用于所有架构,而且绝对不利于代码缓存局部性。 2.我的“重新发明”的 PLT 与原来的 PLT 类似,因为它可能包含所有功能的绑定存根。但对我来说重要的区别在于,并非每个呼叫都必须从 PLT 转到 GOT(然后再返回一次)。相反,它直接转到 GOT,然后返回“重新发明”的 PLT 进行第一次调用。 @F30 “直接调用的意思是调用目标是静态的,而不是从内存中读取的”——不仅仅是这个意思,这是直接调用的定义。他们当然有自己的成本,但它比间接成本低得多,所以精确很重要。 @F30 "不是每个调用都必须从 PLT 转到 GOT" - 但在您的情况下,从 GOT 获取地址然后跳转后,代码必须再次跳转 在存根中,取决于地址是否被解析。请注意,第二次跳转将是有条件的间接跳转,它比直接跳转到 PLT 要重得多。因此,您的方法将 1 个直接跳转和 1 个间接跳转交换为 2 个间接跳转(1 个是有条件的)。如果您有其他想法,我建议您在问题中添加一个伪代码。 不,我不想在惰性绑定后使用存根。我用一些 ASCII 艺术图像更新了这个问题,以解释我的想法。

以上是关于为啥除了 GOT 之外还有 PLT,而不是仅仅使用 GOT?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 matplotlib 需要在 plt.scatter() 之前设置日志比例而不是 plt.plot()?

pwn基础学习日志

.got 和 .got.plt 部分有啥区别?

elf文件中的.plt .rel.dyn .rel.plt .got .got.plt的关系

为什么Linux在x64中使用两个GOT部分? .GOT与.got.plt

通过GDB学透PLT与GOT