我做了一些实验,在这些实验中,我创建了一个指针类型为指向printf
的函数的局部变量。然后我定期调用printf
并按如下方式使用该变量:
#include<stdio.h>
typedef int (*func)(const char*,...);
int main()
{
func x=printf;
printf("%p\n", x);
x("%p\n", x);
return 0;
}
我已经编译了它,并使用gdb查看了main的反汇编,并得到了:
0x000000000000063a <+0>: push %rbp
0x000000000000063b <+1>: mov %rsp,%rbp
0x000000000000063e <+4>: sub $0x10,%rsp
0x0000000000000642 <+8>: mov 0x20098f(%rip),%rax # 0x200fd8
0x0000000000000649 <+15>: mov %rax,-0x8(%rbp)
0x000000000000064d <+19>: mov -0x8(%rbp),%rax
0x0000000000000651 <+23>: mov %rax,%rsi
0x0000000000000654 <+26>: lea 0xb9(%rip),%rdi # 0x714
0x000000000000065b <+33>: mov $0x0,%eax
0x0000000000000660 <+38>: callq 0x520 <printf@plt>
0x0000000000000665 <+43>: mov -0x8(%rbp),%rax
0x0000000000000669 <+47>: mov -0x8(%rbp),%rdx
0x000000000000066d <+51>: mov %rax,%rsi
0x0000000000000670 <+54>: lea 0x9d(%rip),%rdi # 0x714
0x0000000000000677 <+61>: mov $0x0,%eax
0x000000000000067c <+66>: callq *%rdx
0x000000000000067e <+68>: mov $0x0,%eax
0x0000000000000683 <+73>: leaveq
0x0000000000000684 <+74>: retq
对我来说很奇怪的是,调用printf
直接使用plt(如预期的那样),但是使用局部变量调用它使用的是一个完全不同的地址(如您所见)程序集的第4行中,存储在局部变量x中的值不是plt条目的地址。
那怎么可能?并非所有对可执行文件中未定义函数的调用都首先通过plt以获得更好的性能和图片代码?
答案 0 :(得分:3)
(您可以在程序集的第4行中看到,存储在局部变量x中的值不是plt条目的地址)
嗯? 值在反汇编中不可见,仅在其加载位置可见。 (实际上,它不会加载指向PLT条目的指针,但是程序集的第4行不会告诉您 1 。)使用objdump -dR
查看动态重定位。
这是使用相对RIP寻址模式的内存负载。在这种情况下,它将在libc中加载一个指向真实printf
地址的指针。该指针存储在全局偏移表(GOT)中。
为实现此目的,printf
符号将获得“早期绑定”,而不是惰性动态链接,从而避免了PLT开销,以供以后使用该函数指针。
注释1:尽管您可能是基于这样的事实,即负载是负载,而不是相对于RIP的LEA。确实可以告诉您这不是PLT条目; PLT要点的一部分是拥有一个地址,该地址是call rel32
的链接时间常数,这也使LEA具有RIP + rel32寻址模式。如果编译器想要寄存器中的PLT地址,则将使用该地址。
顺便说一句,PLT存根本身也将GOT条目用于其内存间接跳转;对于仅用作函数调用目标的符号,GOT条目保留指向PLT存根,指向push
/ jmp
指令的指针,该指针调用惰性动态链接器以解析该PLT条目。即更新GOT条目。
并非所有对可执行文件中未定义函数的调用都首先通过plt获得更好的性能
否,PLT通过为每个调用添加额外的间接级别来提高运行时间性能。 gcc -fno-plt
使用早期绑定而不是等待第一个呼叫,因此它可以通过GOT将间接call
内联到每个呼叫站点中。
PLT的存在是为了避免在动态链接期间运行时修正call rel32
偏移量。在64位系统上,允许到达2GB以上的地址。并且还支持符号插入。参见https://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/(写于-fno-plt
之前;基本上就像他所建议的想法之一一样。)
与早期绑定相比,PLT的延迟绑定可以提高启动性能,但是在高速缓存命中非常重要的现代系统上,在启动过程中一次完成所有符号扫描工作非常好。
>还有图片代码?
您的代码 是PIC,或实际上是PIE(与位置无关的可执行文件),大多数发行版都将GCC配置为默认执行。
我希望
x
指向printf
的PLT条目的地址
如果您使用-fno-pie
,则PLT条目的地址是链接时常量,并且在编译时,编译器不知道您是否要链接libc静态或动态。因此,它使用mov $printf, %eax
将功能指针的地址保存到寄存器中,并且在链接时只能转换为mov $printf@plt, %eax
。
See it on Godbolt。(Godbolt默认值为-fno-pie
,与当前大多数Linux发行版不同。)
# gcc9.2 -O3 -fpie for your first block
movq printf@GOTPCREL(%rip), %rbp
leaq .LC0(%rip), %rdi
xorl %eax, %eax
movq %rbp, %rsi # saved for later in rbp
call printf@PLT
vs。
# gcc9.2 -O3 -fno-pie
movl $printf, %esi # linker converts this symbol reference to printf@plt
movl $.LC0, %edi
xorl %eax, %eax
call printf # will convert at link-time to printf@plt
# next use also just uses mov-immediate to rematerialize, instead of saving a load result in a register.
因此,对于重复使用指向标准库中函数的函数指针而言,PIE可执行文件实际上具有更好的效率:指针是最终地址,而不仅仅是PLT条目。
-fno-plt -fno-pie
的工作方式类似于PIE模式,用于获取函数指针。除了它仍然可以使用$foo
32位立即数作为同一文件中符号的地址,而不是相对于RIP的LEA。
# gcc9.2 -O3 -fno-plt -fno-pie
movq printf@GOTPCREL(%rip), %rbp # saved for later in RBP
movl $.LC0, %edi
xorl %eax, %eax
movq %rbp, %rsi
call *printf@GOTPCREL(%rip)
# pointers to static functions can use mov $foo, %esi
似乎您需要int foo(const char*,...) __attribute__((visibility("hidden")));
来告诉编译器,使用pie
或-fno-plt
绝对不需要对该符号进行GOT验证。
将其保留到链接器将链接symbol
转换为symbol@plt
的链接时为止,以便编译器始终使用有效的32位绝对立即数或RIP相对寻址,并且仅以PLT结尾间接存在于共享库中的函数。但是随后您将获得指向PLT条目的指针,而不是指向最终地址的指针。
如果您使用的是Intel语法,那么在查看asm而不是反汇编时,它将在GCC的输出中为mov rbp, QWORD PTR printf@GOTPCREL[rip]
。
查看编译器输出可为您提供更多的信息,这些信息仅是纯objdump
输出中RIP的数字偏移量。 -r
显示重定位符号会有所帮助,但是编译器输出通常更好。 (除非您看不到printf
被重写为printf@plt
)
答案 1 :(得分:2)
反汇编的第四行和第五行与代码中的func x=printf;
语句相对应。 printf
的地址存储在地址为0x200fd8
的内存中,可使用相对于rip
的相对地址(0x20098f(%rip)
)进行访问。然后将其存储在局部变量中(相对于ebp
,位于地址-0x8(%rbp)
)。
运行时需要对存储在0x200fd8
上的值进行任何调整。
答案 2 :(得分:0)
一个函数在整个程序中都有一个地址,但是每个共享库都有一个PLT,这将导致指向printf
的指针具有不同的值。