理解基指针和栈指针:与gcc输出上下文相关

12

我有如下的C程序:

int main()
{
    int c[10] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 2};
    return c[0];
}

使用gcc并采用-S指令编译后,得到以下汇编代码:

    .file   "array.c"
    .text
.globl main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, -48(%rbp)
    movl    $0, -44(%rbp)
    movl    $0, -40(%rbp)
    movl    $0, -36(%rbp)
    movl    $0, -32(%rbp)
    movl    $0, -28(%rbp)
    movl    $0, -24(%rbp)
    movl    $0, -20(%rbp)
    movl    $1, -16(%rbp)
    movl    $2, -12(%rbp)
    movl    -48(%rbp), %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.4.5 20110214 (Red Hat 4.4.5-6)"
    .section        .note.GNU-stack,"",@progbits

我不明白的是为什么早期的数组元素距离bp更远?它似乎几乎是将数组元素按相反的顺序放置。

此外,为什么gcc没有使用push而是使用movl来将数组元素推入堆栈?


不同的观点

将数组作为模块的静态变量移动到全局命名空间中:

    .file   "array.c"
    .data
    .align 32
    .type   c, @object
    .size   c, 40
c:
    .long   0
    .long   0
    .long   0
    .long   0
    .long   0
    .long   0
    .long   0
    .long   0
    .long   1
    .long   2
    .text
.globl main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    c(%rip), %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.4.5 20110214 (Red Hat 4.4.5-6)"
    .section    .note.GNU-stack,"",@progbits

使用下面这个 C 程序:

static int c[10] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 2};

int main() 
{
    return c[0];
}

这并没有提供更多关于堆栈的见解。但是看到使用略有不同语义的汇编产生的不同输出结果还是很有趣的。


这是使用gcc命令生成的代码,通常情况下编译器会生成执行相同功能的不同代码。基本上,一个问题可能有多种解决方案。在编译时你是否使用了“-o”进行优化?这可能是原因(或者缺少这个选项)。 - swiftcode
这实际上是编译器生成的代码,它不能与编译器生成的代码不同。优化标志是“-O”,而不是“-o”,是的,Matthew在他的问题中明确说明了他使用了“-Os”。 - user405725
他说的是 -S(将汇编生成为文本),而不是 -Os(为大小进行优化)。此汇编代码似乎是在没有优化的情况下生成的;无论使用哪个级别的-O,我都看不到任何数组存储。 - zwol
3个回答

9
首先,x86堆栈是向下增长的。按照惯例,rbp存储rsp的原始值。因此,该函数的参数位于相对于rbp的正偏移量处,其自动变量位于负偏移量处。自动数组的第一个元素具有较低的地址,因此离rbp最远。
这是一个方便的图示,出现在此页面上: stack layout 我认为编译器完全可以使用一系列push指令来初始化您的数组。但是,我不确定这是否是一个好主意。

这个图示通常会被上下翻转,对吧?因为高内存可以被视为芯片的顶部。 - Matthew Hoggan
1
@MatthewHoggan:也许吧。就清晰度而言,我个人没有强烈的偏好。 - NPE
@MatthewHoggan 内存区域的地图通常会将高地址绘制在页面顶部。然而,数据结构、网络数据包等的图示通常会将较大的偏移量绘制在页面底部。 - zwol
@Zack:很久以前,我读到过小端序有利于上述图形图表,而大端序则有利于从左到右、从上到下的表示。 - ninjalj
@ninjalj IME字节序有时会影响人们在单词中绘制高位是在左侧还是右侧,但我从未见过它改变高地址是在顶部还是底部的情况。 - zwol
@Zack:这个想法是,如果你有一个DWORD在一个WORD大小的图表中分成两部分,那么内容读取起来就没问题。 - ninjalj

3
此外,为什么gcc不使用push而是使用movl将数组元素推入堆栈呢?很少有一个大的初始化数组恰好位于堆栈帧中的正确位置,可以使用一系列的push,因此gcc没有被教导这样做。(更详细地说,数组初始化被处理为块内存复制,它被发出为一系列move指令或调用memcpy,具体取决于它的大小。决定要发出什么的代码不知道块在内存中的位置,因此它不知道是否可以使用push。)
另外,movl更快。具体来说,push对%esp进行了隐式读-修改-写操作,因此一系列push必须按顺序执行。相比之下,对独立地址进行的movl可以并行执行。因此,通过使用一系列movl而不是push,gcc为CPU提供了更多的指令级并行性来利用。
请注意,如果我激活任何优化级别编译您的代码,则整个数组都会消失!这是使用任何优化级别编译您的代码的结果!以下是-O1的结果(这是在对象文件上运行objdump -dr的结果,而不是-S输出,因此您可以看到实际的机器代码)。
0000000000000000 <main>:
   0:   b8 00 00 00 00          mov    $0x0,%eax
   5:   c3                      retq   

以及-Os:

0000000000000000 <main>:
   0:   31 c0                   xor    %eax,%eax
   2:   c3                      retq   

什么都不做总是比做些什么要快。用xor清除寄存器只需要两个字节而不是五个字节,但它对寄存器的旧内容有正式的数据依赖性并修改条件码,因此可能会变慢,因此只有在优化大小时才选择这种方法。


我会期望将一个寄存器与自身进行异或操作是特殊处理的,但是它可能会更慢。 - ninjalj
1
我手头没有任何x86优化指南,但我记得有些型号会对该操作进行特殊处理,而有些则不会。如果你没有告诉它,GCC会尝试生成在广泛的CPU上都运行良好的代码,尽管并不完全针对特定CPU进行优化。 - zwol
我认为之所以不这样做是因为它会变慢。虽然在这里进行区分很容易,但由于效率低下,因此不这样做。 - glglgl
现在push不再慢了;自 Pentium-M(以及 AMD 同期)开始,有一个“堆栈引擎”通过 RSP 打破了依赖链,使 push/pop 成为单uop指令。这对于 mov-zero (What C/C++ compiler can use push pop instructions for creating local variables, instead of just increasing esp once?) 来说是一种胜利,也适用于代码大小(push imm8 vs. REX movq imm32),但 pxor-zero / movdqa 16-byte 存储在这里更好。 - Peter Cordes
自从PPro(?)以前,xor-zeroing就已经被特殊处理了(在x86汇编中将寄存器设置为零的最佳方法是什么:xor、mov还是and?),尽管它实际上一直到PIII仍然存在虚假依赖。你不会在-O1中得到xor-zeroing的原因是-fpeephole2只在-O2及更高版本中启用。-O1是部分优化;-Os是完全针对大小(和速度)的优化;-O3是完全针对速度的优化。(我相信你在发布10年后已经弄清楚了大部分这些事情,但这个答案有一些错误信息:) - Peter Cordes
此外,push 指令每次会推送两个数组元素,而 GCC 的未优化汇编则会朴素地一次存储 4 字节。现代 GCC 至少会在 -O0 优化级别下使用 movq,而在 -O3 下则使用 2 个 movapsmovq-immdeiate(如果你将数组传递给调用方,这样清零就无法被优化掉)。https://godbolt.org/z/nfs9sfsj6 - Peter Cordes

2
请记住,在x86上,堆栈是向下增长的。将数据压入堆栈会导致堆栈指针减小。
%rbp <-- Highest memory address
-12
-16
-20
-24
-28
-32
-36
-40
-44
-48  <-- Address of array

每当你感到困惑时,画出这样的图表,你很快就会停止感到困惑! - abcde123483

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