据我所知,在典型的ELF二进制文件中,函数通过过程链接表(PLT)调用。函数的PLT条目通常包含跳转到全局偏移表(GOT)条目。此条目将首先引用一些代码将实际的函数地址加载到GOT中,并在第一次调用(lazy binding)后包含实际的函数地址。
准确地说,在延迟绑定之前,GOT入口指向PLT,跳转到GOT之后的指令。这些指令通常会跳转到PLT的头部,从那里调用一些绑定例程,然后更新GOT条目。
现在我想知道为什么有两个间接(调用PLT然后从GOT跳转到一个地址),而不是仅仅保留PLT并直接从GOT调用地址。看起来这可以节省跳跃和完整的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的假想替代方案中,延迟绑定之前的情况看起来像这样:(我将代码保留在“懒惰绑定表”中,类似于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 | |
| | ... | |
| +------------------+ |
+------------------------+
答案 0 :(得分:10)
问题是用call printf@PLT
替换call [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条目在运行时重新定位。 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)。
答案 1 :(得分:1)
现在我想知道为什么有两个间接 (调用PLT然后跳转到GOT的地址),
首先,有两个调用,但只有一个间接(调用PLT存根是直接)。
而不仅仅是保留PLT并直接从GOT调用地址。
如果您不需要延迟绑定,可以使用绕过PLT的-fno-plt
。
但是如果你想保留它,你需要一些存根代码来查看符号是否已经解析并相应地进行分支。现在,为了便于分支预测,必须为每个被调用的符号和 voila 复制此存根代码,您重新发明了PLT。