函数指针局部变量的意外值

Unexpected value of a function pointer local variable

我做了一些实验,其中我创建了一个指向函数的指针类型的局部变量,该变量指向 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    [=11=]x10,%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    [=11=]x0,%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    [=11=]x0,%eax
   0x000000000000067c <+66>:    callq  *%rdx
   0x000000000000067e <+68>:    mov    [=11=]x0,%eax
   0x0000000000000683 <+73>:    leaveq
   0x0000000000000684 <+74>:    retq

对我来说奇怪的是调用 printf 直接使用 plt(正如预期的那样)但是 使用局部变量调用它使用了一个完全不同的地址(正如您在程序集的第 4 行中看到的,存储在局部变量 x 中的值不是 plt 条目的地址)。

怎么可能?不是所有对可执行文件中未定义函数的调用都首先通过 plt 以获得更好的性能和 pic 代码吗?

你反汇编的第四行和第五行对应代码中的func x=printf;语句。 printf 的地址存储在地址 0x200fd8 的内存中,使用 rip 相对地址 (0x20098f(%rip)) 访问该地址。然后将其存储在局部变量中(相对于 ebp,地址为 -0x8(%rbp))。

在 运行 时所需的任何调整都将对存储在 0x200fd8 的值进行。

一个函数在整个程序中有一个地址,但每个共享库都有一个 PLT,这会导致指向 printf 的不同指针具有不同的值。

(as you can see in line 4 of the assembly that the value stored in local variable x is not the address of the plt entry)

嗯? value 在反汇编中不可见,仅在加载它的位置可见。 (实际上它没有加载指向 PLT 条目的指针,但程序集的第 4 行没有告诉你 1。)使用 objdump -dR 查看动态重定位。

这是使用 RIP 相对寻址模式从内存加载。在这种情况下,它正在加载指向 libc 中真实 printf 地址的指针。该指针存储在全局偏移量 Table (GOT) 中。

为了使这项工作有效,printf 符号得到 "early binding" 而不是惰性动态 linking,避免了以后使用该函数指针的 PLT 开销。

脚注 1:尽管您的推理可能基于这样一个事实,即它是一个负载而不是 RIP 相关的 LEA。这几乎可以告诉您这不是 PLT 条目; PLT 的部分要点是要有一个地址,该地址是 call rel32 的 link 时间常数,这也使 LEA 具有 RIP+rel32 寻址模式。如果编译器想要寄存器中的 PLT 地址,它会使用它。


顺便说一句,PLT 存根本身也使用 GOT 条目进行内存间接跳转;对于仅用作函数调用目标的符号,GOT 条目包含一个指向 PLT 存根的指针,指向 push / jmp 指令,这些指令调用惰性动态 linker 来解析该 PLT 条目。即更新 GOT 条目。


Don't all the calls to functions undefined in the executable go first through the plt for better performance

不,PLT 成本 运行时性能通过向每个调用添加额外的间接级别。 gcc -fno-plt 使用早期绑定而不是等待第一次调用,因此它可以通过 GOT 直接将间接 call 内联到每个调用站点。

PLT 的存在是为了避免在动态 linking 期间对 call rel32 偏移进行运行时修正。在 64 位系统上,允许访问超过 2GB 的地址。并且还支持符号插入。参见 https://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/(写于 -fno-plt 存在之前;基本上就像他提出的想法之一)。

与早期绑定相比,PLT 的惰性绑定可以提高启动性能,但在缓存命中 非常 重要的现代系统中,在启动期间立即执行所有符号扫描工作很好。

and for pic code?

您的代码 PIC,或者实际上是 PIE(位置无关的可执行文件),大多数发行版默认配置 GCC。

I expected x to point to the address of the PLT entry of printf

如果使用-fno-pie,那么PLT入口的地址是一个link时间常数,在编译时编译器不会'不知道您是要静态地还是动态地 link libc。因此它使用 mov $printf, %eax 将函数指针的地址获取到寄存器中,并且在 link 时只能转换为 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

对比

# 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")));告诉编译器对于这个符号绝对不需要通过GOT,用pie-fno-plt

留到 link-时间让 linker 在必要时将 symbol 转换为 symbol@plt 允许编译器始终使用高效的 32 位绝对立即数或 RIP 相对寻址,并且仅以 PLT 间接寻址结束,这些功能原来位于共享库中。但是你最终会得到指向 PLT 条目的指针,而不是指向最终地址的指针。


如果您使用的是 Intel 语法,那么在 GCC 的输出中会是 mov rbp, QWORD PTR printf@GOTPCREL[rip],如果您查看的是 asm 而不是反汇编。

查看编译器输出可为您提供比普通 objdump 输出中 RIP 的数字偏移量更多的信息。 -r 显示重定位符号有一些帮助,但编译器输出通常更好。 (除非您没有看到 printf 被重写为 printf@plt