为什么 gcc 会重新排序函数中的局部变量?

Why does gcc reorder the local variable in function?

我写了一个 C 程序,只是 read/write 一个大数组。我用命令 gcc -O0 program.c -o program 编译了程序 出于好奇,我用 objdump -S 命令反汇编了 C 程序。

read_arraywrite_array函数的代码和汇编附在本题末尾

我正在尝试解释 gcc 是如何编译该函数的。我使用 // 添加我的评论和问题

write_array()函数的汇编代码开头一段

  4008c1:   48 89 7d e8             mov    %rdi,-0x18(%rbp) // this is the first parameter of the fuction
  4008c5:   48 89 75 e0             mov    %rsi,-0x20(%rbp) // this is the second parameter of the fuction
  4008c9:   c6 45 ff 01             movb   [=10=]x1,-0x1(%rbp) // comparing with the source code, I think this is the `char tmp` variable 
  4008cd:   c7 45 f8 00 00 00 00    movl   [=10=]x0,-0x8(%rbp) // this should be the `int i` variable.

我不明白的是:

1) char tmp显然是在write_array函数中afterint i定义的。为什么 gcc 对这两个局部变量的内存位置重新排序?

2) 从偏移量来看,int i-0x8(%rbp)char tmp-0x1(%rbp),这表明变量int i需要7 字节?这很奇怪,因为 int i 在 x86-64 机器上应该是 4 个字节。不是吗?我的猜测是 gcc 试图做一些调整?

3) 我发现 gcc 优化选项非常 有趣 。有什么好的 documents/book 可以解释 gcc 是如何工作的吗? (第三个问题可能跑题了,如果你这么认为,请忽略。我只是想看看有没有捷径可以学习gcc用于编译的底层机制。:-))

下面是一段函数代码:

#define CACHE_LINE_SIZE 64
static inline void
read_array(char* array, long size)
{
    int i;
    char tmp;
    for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
    {
        tmp = array[i];
    }
    return;
}

static inline void
write_array(char* array, long size)
{
    int i;
    char tmp = 1;
    for ( i = 0; i < size; i+= CACHE_LINE_SIZE )
    {
        array[i] = tmp;
    }
    return;
}

下面是 write_array 的一段反汇编代码,来自 gcc -O0:

00000000004008bd <write_array>:
  4008bd:   55                      push   %rbp
  4008be:   48 89 e5                mov    %rsp,%rbp
  4008c1:   48 89 7d e8             mov    %rdi,-0x18(%rbp)
  4008c5:   48 89 75 e0             mov    %rsi,-0x20(%rbp)
  4008c9:   c6 45 ff 01             movb   [=12=]x1,-0x1(%rbp)
  4008cd:   c7 45 f8 00 00 00 00    movl   [=12=]x0,-0x8(%rbp)
  4008d4:   eb 13                   jmp    4008e9 <write_array+0x2c>
  4008d6:   8b 45 f8                mov    -0x8(%rbp),%eax
  4008d9:   48 98                   cltq
  4008db:   48 03 45 e8             add    -0x18(%rbp),%rax
  4008df:   0f b6 55 ff             movzbl -0x1(%rbp),%edx
  4008e3:   88 10                   mov    %dl,(%rax)
  4008e5:   83 45 f8 40             addl   [=12=]x40,-0x8(%rbp)
  4008e9:   8b 45 f8                mov    -0x8(%rbp),%eax
  4008ec:   48 98                   cltq
  4008ee:   48 3b 45 e0             cmp    -0x20(%rbp),%rax
  4008f2:   7c e2                   jl     4008d6 <write_array+0x19>
  4008f4:   5d                      pop    %rbp
  4008f5:   c3                      retq

对于保存在栈中的局部变量,地址顺序取决于栈的增长方向。您可以参考Does stack grow upward or downward?了解更多信息。

This is quite weird because int i should be 4 bytes on x86-64 machine. Isn't it?

如果我没记错的话,x86-64 机器上 int 的大小是 8。你可以通过编写一个测试应用程序来打印来确认它 sizeof(int)

即使在 -O0,gcc 也不会发出 static inline 函数的定义,除非有调用者。在那种情况下,它实际上并没有内联:而是发出一个独立的定义。所以我猜你的反汇编是从那个开始的。


您使用的是非常旧的 gcc 版本吗? gcc 4.6.4 将变量按此顺序放入堆栈,但 4.7.3 及更高版本使用其他顺序:

    movb    , -5(%rbp)    #, tmp
    movl    [=10=], -4(%rbp)    #, i

在您的 asm 中,它们按初始化顺序而不是声明顺序存储,但我认为这只是偶然,因为 gcc 4.7 改变了顺序。此外,添加像 int i=1; 这样的初始化程序不会改变分配顺序,因此完全破坏了该理论。

记住 gcc is designed around a series of transformations from source to asm, so -O0 doesn't mean "no optimization"。您应该将 -O0 视为遗漏了 -O3 通常会做的一些事情。没有选项试图从源代码到 asm 进行尽可能逐字的翻译。

一旦 gcc 决定为它们分配 space 的顺序:

  • 位于 rbp-1char:这是第一个可以容纳 char 的位置。如果还有另一个 char 需要存储,它可以放在 rbp-2.

  • int at rbp-8:由于从rbp-1rbp-4的4个字节不是空闲的,下一个可用的自然对齐位置是 rbp-8.

或者对于 gcc 4.7 和更新版本,-4 是 int 的第一个可用位置,-5 是它下面的下一个字节。


回复:space 储蓄:

的确,将字符置于 -5 会产生最低接触地址 %rsp-5,而不是 %rsp-8,但这不会保存任何内容。

堆栈指针在 AMD64 SysV ABI 中是 16B 对齐的。 (从技术上讲,%rsp+8(堆栈参数的开始)在函数入口处对齐,在你推送任何东西之前。)%rbp-8 触摸新页面或缓存行的唯一方法 %rbp-5 不会是堆栈小于 4B 对齐。这是极不可能的,即使在 32 位代码中也是如此。

就函数“分配”或“拥有”多少堆栈而言:在 AMD64 SysV ABI 中,函数“拥有”下方 128B 的红色区域 %rsp (That size was chosen because a one-byte displacement can go up to -128) .信号处理程序和 user-space 堆栈的任何其他异步用户将避免破坏红色区域,这就是为什么函数可以写入 %rsp 以下的内存而不递减 %rsp 的原因。所以从这个角度来看,我们使用多少红色区域并不重要;信号处理程序 运行 出栈的几率不受影响。

在没有 redzone 的 32 位代码中,对于任何一个命令,gcc 都在 sub , %esp 的堆栈上保留 space。 (尝试在 godbolt 上使用 -m32)。所以还是那句话,不管我们用5字节还是8字节都无所谓,因为我们是以16为单位预留的。

当有许多 charint 变量时,gcc 将 char 打包成 4B 组,而不是将 space 丢失到碎片中,即使声明混合在一起:

void many_vars(void) {
  char tmp = 1;  int i=1;
  char t2 = 2;   int i2 = 2;
  char t3 = 3;   int i3 = 3;
  char t4 = 4;
}

with gcc 4.6.4 -O0 -fverbose-asm,这有助于标记哪个存储是哪个变量,这就是编译器 asm 输出优于反汇编的原因:

    pushq   %rbp  #
    movq    %rsp, %rbp      #,
    movb    , -4(%rbp)    #, tmp
    movl    , -16(%rbp)   #, i
    movb    , -3(%rbp)    #, t2
    movl    , -12(%rbp)   #, i2
    movb    , -2(%rbp)    #, t3
    movl    , -8(%rbp)    #, i3
    movb    , -1(%rbp)    #, t4
    popq    %rbp    #
    ret

我认为变量在 -O0.

处根据 gcc 版本以声明的正向或反向顺序排列

我制作了您的 read_array 函数的一个版本,可以优化:

// assumes that size is non-zero.  Use a while() instead of do{}while() if you want extra code to check for that case.
void read_array_good(const char* array, size_t size) {
    const volatile char *vp = array;
    do {
      (void) *vp;    // this counts as accessing the volatile memory, with gcc/clang at least
      vp += CACHE_LINE_SIZE/sizeof(vp[0]);
    } while (vp < array+size);
}

Compiles to the following, with gcc 5.3 -O3 -march=haswell:

        addq    %rdi, %rsi      # array, D.2434
.L11:
        movzbl  (%rdi), %eax        # MEM[(const char *)array_1], D.2433
        addq    , %rdi       #, array
        cmpq    %rsi, %rdi      # D.2434, array
        jb      .L11        #,
        ret

将表达式转换为 void 是告诉编译器使用了一个值的规范方法。例如要抑制未使用变量警告,您可以编写 (void)my_unused_var;.

对于 gcc 和 clang,使用 volatile 指针解除引用确实会生成内存访问,不需要 tmp 变量。 C 标准对于什么构成对 volatile 的访问的内容非常不明确,因此这可能不是完全可移植的。另一种方法是 xor 您读入累加器的值,然后将其存储到全局变量中。只要你不使用全程序优化,编译器就不知道什么都没有读取全局,所以它无法优化计算。

有关第二种技术的示例,请参阅 the vmtouch source code。 (它实际上为累加器使用了一个全局变量,这使得代码很笨重。当然,这并不重要,因为它涉及页面,而不仅仅是缓存行,所以它很快就会成为 TLB 未命中和页面错误的瓶颈,即使是内存读取 -在循环携带的依赖链中修改写入。)


我尝试编写了一些 gcc 或 clang 将编译为没有序言的函数(假设 size 最初是非零的),但失败了。 GCC 总是希望 add rsi,rdi 用于 cmp/jcc 循环条件,即使 -march=haswell 其中 sub rsi,64/jae 可以像 cmp/jcc 一样进行宏融合].但总的来说,在 AMD 上,GCC 在循环中的微指令更少。

read_array_handtuned_haswell:
.L0
    movzx   eax, byte [rdi]     ; overwrite the full RAX to avoid any partial-register false deps from writing AL
    add     rdi, 64
    sub     rsi, 64
    jae     .L0           ; or ja, depending on what semantics you want
    ret

Godbolt Compiler Explorer link with all my attempts and trial versions

如果循环终止条件是je,在像do { ... } while( size -= CL_SIZE );这样的循环中,我可以得到类似的结果,但我似乎无法说服gcc在减法时捕获无符号借用。它想减去然后 cmp -64/jb 来检测下溢。这是 not that hard to get compilers to check the carry flag after an add to detect carry :/

让编译器创建一个 4-insn 循环也很容易,但并非没有序言。例如计算结束指针(数组+大小)并递增指针直到它大于或等于。

幸好这没什么大不了的;我们得到的循环很好。