【问题标题】:Why is the address of static variables relative to the Instruction Pointer?为什么静态变量的地址是相对于指令指针的?
【发布时间】:2017-03-12 18:33:54
【问题描述】:

我正在关注this tutorial 关于大会。

根据教程(我在本地也试过,结果差不多),源码如下:

int natural_generator()
{
        int a = 1;
        static int b = -1;
        b += 1;              /* (1, 2) */
        return a + b;
}

编译为这些汇编指令:

$ gdb static
(gdb) break natural_generator
(gdb) run
(gdb) disassemble
Dump of assembler code for function natural_generator:
push   %rbp
mov    %rsp,%rbp
movl   $0x1,-0x4(%rbp)
mov    0x177(%rip),%eax        # (1)
add    $0x1,%eax
mov    %eax,0x16c(%rip)        # (2)
mov    -0x4(%rbp),%eax
add    0x163(%rip),%eax        # 0x100001018 <natural_generator.b>
pop    %rbp
retq   
End of assembler dump.

(由我添加的行号 cmets (1)(2)(1, 2)。)

问题为什么是,在编译的代码中,静态变量b相对于指令指针(RIP)的地址,它不断变化(见行(1)(2)),从而生成更复杂的汇编代码,而不是相对于存储这些变量的可执行文件的特定部分?

根据上面提到的教程,有这样一个部分:

这是因为b 的值被硬编码在不同的部分 示例可执行文件,它与所有 当进程运行时,操作系统加载程序的机器码 启动。

(强调我的。)

【问题讨论】:

  • 这使其位置独立,这对于共享库和 ASLR 等非常有用。另请注意,没有“相对于可执行文件的特定部分”的寻址模式,甚至同一部分中的地址也可以是相对的(通常用于控制传输)。
  • 因此会生成更复杂的汇编代码:不,它不会。使用objdump -drwC -Mintel 获得不错的输出。 -r 解码符号表。 objdump 始终为您计算,并显示 RIP 相关指令的实际目标地址以及与 RIP 的偏移量。
  • 生成指令的大小很重要,它都需要来自 RAM 并缓存在处理器缓存中。内存是现代处理器的一个重要瓶颈。想象一下,如果访问内存的每条指令也需要 8 个字节来对地址进行编码,那么您的首选方案会如何工作。机器码是机器生成的,复杂的工作不介意。
  • @PeterCordes 您通常不会看到 C++ 编译器在运行时初始化静态分配的变量,因为您不会看到 C 编译器进行运行时初始化(即 C++ 初始化会在 C 中是允许的,因为 C 编译器通常不支持静态的运行时初始化)。这就是这里的情况,因为变量 b 没有在函数中初始化。
  • @RossRidge:是的,我的评论变得一团糟,因为一旦我意识到在这种情况下这不是问题,我就没有从头开始重写它。一开始我在想,对于这样一个简单的功能来说,它看起来太多了,但当然那只是因为 OP 未能启用优化。我只注意到当我仔细观察并没有看到分支时,然后是 /facepalm,哦,是的,这只是一个带有常量初始化器的 int

标签: c gcc assembly cpu-registers


【解决方案1】:

使用 RIP 相对寻址来访问静态变量 b 有两个主要原因。首先是它使代码位置独立,这意味着如果它在共享库或position independent executable 中使用,代码可以更容易地重新定位。第二个是它允许将代码加载到 64 位地址空间中的任何位置,而无需在指令中编码巨大的 8 字节(64 位)位移,而 64 位 x86 CPU 无论如何都不支持这些位移。

您提到编译器可以改为生成引用变量的代码,该变量相对于它所在的部分的开头。虽然这样做也具有与上面给出的相同的优点,但它不会使程序集任何不太复杂。事实上,它会让事情变得更复杂。生成的汇编代码首先必须计算变量所在部分的地址,因为它只知道它相对于指令指针的位置。然后它必须将其存储在寄存器中,因此可以相对于该地址访问b(以及该部分中的任何其他变量)。

由于 32 位 x86 代码不支持 RIP 相对寻址,您的替代解决方案实际上是编译器在生成 32 位位置无关代码时所做的事情。它将变量b 放在全局偏移表(GOT) 中,然后访问相对于GOT 基址的变量。这是您的代码在使用gcc -m32 -O3 -fPIC -S test.c 编译时生成的程序集:

natural_generator:
        call    __x86.get_pc_thunk.cx
        addl    $_GLOBAL_OFFSET_TABLE_, %ecx
        movl    b.1392@GOTOFF(%ecx), %eax
        leal    1(%eax), %edx
        addl    $2, %eax
        movl    %edx, b.1392@GOTOFF(%ecx)
        ret

第一个函数调用将以下指令的地址放入 ECX。下一条指令通过将 GOT 与指令开头的相对偏移量相加来计算 GOT 的地址。变量 ECX 现在包含 GOT 的地址,并在其余代码中访问变量 b 时用作基址。

将其与gcc -m64 -O3 -S test.c 生成的 64 位代码进行比较:

natural_generator:
        movl    b.1745(%rip), %eax
        leal    1(%rax), %edx
        addl    $2, %eax
        movl    %edx, b.1745(%rip)
        ret

(代码与您问题中的示例不同,因为优化已打开。一般来说,只查看优化的输出是一个好主意,因为如果没有优化,编译器通常会生成糟糕的代码,这些代码会做很多无用的事情。另请注意,不需要使用-fPIC 标志,因为编译器会生成与位置无关的 64 位代码。)

请注意,64 位版本中的汇编指令少了两条,因此它的复杂度更低。您还可以看到代码使用了少一个寄存器 (ECX)。虽然它对您的代码没有太大影响,但在一个更复杂的示例中,它是一个可以用于其他用途的寄存器。这使得代码更加复杂,因为编译器需要对寄存器进行更多处理。

【讨论】:

  • 感谢您的详细解释。我还是这个领域的新手,所以我并不完全了解每个细节,但如果我没记错的话,原因是这种方式效率更高。更详细地说,它更有效,因为 RIP 是一个无论如何总是使用的寄存器,而数据部分的开头不一定必须存储在单独的寄存器中(这是一种稀缺资源)。这是正确的吗?
  • 这是一个原因 - 但它也更有效,因为在 x86-64 模式下 CPU 直接支持 RIP 相对寻址。使用 GOT 的 32 位等效项需要跳过各种环来确定当前 IP(get_pc_thunk 的东西),然后做一些数学计算来计算 GOT 的位置。使用 RIP 相对寻址通过直接支持它作为寻址模式来消除这种复杂性(以及 32 位变体中的前两条指令)。
猜你喜欢
  • 1970-01-01
  • 2019-10-09
  • 2020-12-30
  • 2016-02-08
  • 2021-04-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多