为何PLT存根开头的`jmp`指令是必需的?
标准PLT实现(SystemV ABI规定)
# 代码段中的调用指向PLT插槽 # 实际是x64的RIP相对调用,并非直接call 0x500: call 0x1000 ... 0x1000: .PLT1: jmp [0x2000] # 跳转到对应函数的GOT槽位 pushq $index_f jmp .PLT0 ... 0x2000: # 初始状态下,GOT槽位指向PLT中push指令的地址(0x1005),触发延迟绑定 .GOT1: 0x1005 # 延迟绑定完成后,GOT槽位被更新为函数f的真实地址 0x3000 # 函数f的实际入口 ... 0x3000: f: ....
疑问:能否去掉PLT1的第一条jmp,改用直接间接调用GOT?
你提出的修改方案如下:
0x500: call [0x2000] ... 0x1000: .PLT1: pushq $index_f jmp .PLT0 ... 0x2000: # 初始指向PLT的push指令地址 .GOT1: 0x1005 # 绑定后指向函数f的真实地址 0x3000 # 函数f的实际入口 ... 0x3000: f: ....
从表面流程看,这个修改似乎能正常工作:第一次调用时,call [0x2000]会跳转到PLT1的pushq指令触发延迟绑定;绑定完成后,后续调用会直接跳到函数f的真实地址,还能节省一条jmp指令的开销。但现有实现不这么做,甚至社区为适配Intel IBT(间接分支追踪)新增.plt.sec间接层也没采用该方案,核心原因有以下几点:
1. ABI兼容性与生态依赖
SystemV ABI明确规定了PLT的结构,现有动态链接器、调试器、性能分析工具等全栈生态都基于这个标准实现。如果改为直接调用GOT,所有依赖PLT插槽结构的工具都会失效——比如调试器无法识别PLT调用栈、动态链接器的延迟绑定逻辑需要重写,会导致大量现有软件和工具无法兼容。
2. 延迟绑定的栈帧约定
动态链接器的延迟绑定例程(_dl_runtime_resolve)依赖固定的栈帧结构:原PLT流程中,call 0x1000会将调用处的返回地址压入栈,随后jmp [GOT]跳回PLT1的pushq指令,此时栈上的结构是「调用处返回地址 → 函数索引」,这是_dl_runtime_resolve预期的输入格式。虽然你的修改方案栈帧结构看似一致,但现有动态链接器的实现是基于原PLT的代码位置设计的,贸然修改可能触发未定义行为。
3. 代码重定位与PIE兼容性
在位置无关可执行(PIE)模式下,PLT代码本身是位置无关的,所有寻址都通过RIP相对偏移实现。原PLT的设计让PLT插槽成为调用的统一入口,避免了在代码段中直接生成大量指向GOT的重定位项,减少了链接器的工作量,同时提升了加载效率。如果改为直接调用GOT,代码段中每个调用点都需要生成独立的重定位项,会增加二进制体积和加载时间。
4. 调试与栈回溯的可读性
原PLT插槽是明确的中间层,调试器和性能分析工具可以识别PLT调用,在栈回溯时显示「调用处 → PLT插槽 → 目标函数」的完整流程,方便开发者定位动态链接函数的调用问题。如果直接调用GOT,栈回溯会直接从调用处跳到目标函数,丢失了PLT这个关键的中间信息,增加了调试难度。
关于Intel IBT适配的背景
近期社区尝试在16字节的PLT插槽中挤出空间添加endbr64指令(IBT要求的间接分支前置指令),但原PLT结构的指令长度总和已经接近16字节,无法兼容对齐和指令长度限制,最终只能新增.plt.sec间接层——这本质上是在兼容现有ABI的前提下做的妥协,而非想不到直接调用GOT的方案,核心还是要维护生态的兼容性。
内容的提问来源于stack exchange,提问作者Ofek Shilon

