为什么静态变量的地址与指令指针有关?

9

我正在学习有关汇编的这个教程

根据该教程(我也尝试了本地操作,并且得到了类似的结果),以下源代码:

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.

(我添加了行号注释(1)(2)(1,2)。)

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

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

这是因为b的值是硬编码在示例可执行文件的不同部分中的,并且在进程启动时由操作系统的加载程序与所有机器代码一起加载到内存中。

(强调是我的。)


2
这使得它具有位置独立性,这对于共享库和ASLR等方面非常有用。还要注意,没有针对可执行文件特定部分的寻址模式,甚至同一节中的地址也可以是相对的(在控制传递中很常见)。 - Jester
因此会生成更复杂的汇编代码:不,它不会。使用 objdump -drwC -Mintel 获取漂亮的输出。-r 解码符号表。objdump总是为您计算数学,并显示RIP相对指令的实际目标地址以及与RIP的偏移量。 - Peter Cordes
2
生成的指令大小非常重要,所有指令都需要来自RAM并缓存在处理器缓存中。内存是现代处理器的一个重要瓶颈。想象一下,如果每个访问内存的指令还需要8个字节来编码地址,那么您喜欢的方案将会有多好。机器代码由机器生成,它不介意做复杂的工作。 - Hans Passant
@PeterCordes 在通常情况下,你不会看到 C++ 编译器在静态分配变量的初始化时进行运行时初始化,除非在 C 编译器进行运行时初始化的情况下(即在 C 中允许 C++ 初始化的情况下),因为 C 编译器通常不支持静态变量的运行时初始化。这里的情况是变量 b 在函数中没有被初始化。 - Ross Ridge
显示剩余2条评论
1个回答

9
有两个主要原因使用RIP相对寻址来访问静态变量b。第一个原因是使代码位置无关,这意味着如果它在共享库或位置无关可执行文件中使用,代码可以更容易地被重新定位。第二个原因是允许代码在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是一个总是被使用的寄存器,而数据段的起始位置则不一定需要存储在单独的寄存器中(这是一种稀缺资源)。这样说是否正确? - Attilio
2
这是一个原因 - 但在x86-64模式下,它也更有效率,因为RIP相对寻址被CPU直接支持。使用GOT的32位等效方法需要跳过各种障碍来确定当前IP(get_pc_thunk)的位置,然后进行一些数学计算以计算GOT的位置。使用RIP相对寻址可以通过直接支持其作为寻址模式来消除这种复杂性(以及32位变体中的前两个指令)。 - BeeOnRope

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接