【问题标题】:Why does gcc reorder the local variable in function?为什么 gcc 对函数中的局部变量进行重新排序?
【发布时间】:2016-07-17 20:25:00
【问题描述】:

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

read_array和write_array函数的代码和汇编附在本题末尾。

我试图解释 gcc 是如何编译函数的。我使用// 添加我的 cmets 和问题

取一段开头的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   $0x1,-0x1(%rbp) // comparing with the source code, I think this is the `char tmp` variable 
  4008cd:   c7 45 f8 00 00 00 00    movl   $0x0,-0x8(%rbp) // this should be the `int i` variable.

我不明白的是:

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

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

3) 我发现 gcc 优化选项非常有趣。是否有一些好的文档/书籍可以解释 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   $0x1,-0x1(%rbp)
  4008cd:   c7 45 f8 00 00 00 00    movl   $0x0,-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   $0x40,-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

【问题讨论】:

  • int 必须是 4 字节对齐的,并且不能位于 -0x4,因为这会导致它与字符重叠。
  • 重点:我不相信 gcc 会重新排序参数,除非调用者和被调用者都在同一个编译单元中(因为它们似乎在这里)。
  • 在-O3以下级别做出的决定并不重要
  • @M.M 如果int 位于-0x4,那么char 将在其他地方,呵呵。
  • -O0 只有在调试时才真正有用(C 代码和底层程序集之间通常有更清晰的关联)。在过去,调试优化代码更加困难,但对于现代 GCC 和 GDB,这确实不是问题。在一些编译器的早期,优化器存在错误,因此退回到不进行优化是避免问题的一种技巧。如果您想了解标准调用约定,-O0 也很有价值。发布软件时,我想不出使用-O0 的理由(没有优化)。 -O2 是我使用的最低要求。

标签: c linux gcc assembly disassembly


【解决方案1】:

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


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

    movb    $1, -5(%rbp)    #, tmp
    movl    $0, -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 决定为它们分配空间的顺序:

  • rbp-1 上的char:这是第一个可以容纳char 的可用位置。如果还有另一个需要存储的char,可以转到rbp-2。

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

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


RE:节省空间:

确实,将 char 置于 -5 会生成最低触摸地址 %rsp-5,而不是 %rsp-8,但这并不能保存任何内容。

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

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

在没有 redzone 的 32 位代码中,对于任一命令 gcc 都会在堆栈上保留空间 sub $16, %esp。 (尝试在 Godbolt 上使用 -m32)。同样,我们使用 5 字节还是 8 字节都没有关系,因为我们以 16 为单位保留。

当有很多 char 和 int 变量时,gcc 会将 chars 打包成 4B 组,而不是因为碎片而丢失空间,即使声明混合在一起:

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    $1, -4(%rbp)    #, tmp
    movl    $1, -16(%rbp)   #, i
    movb    $2, -3(%rbp)    #, t2
    movl    $2, -12(%rbp)   #, i2
    movb    $3, -2(%rbp)    #, t3
    movl    $3, -8(%rbp)    #, i3
    movb    $4, -1(%rbp)    #, t4
    popq    %rbp    #
    ret

我认为变量的声明顺序可以是正向或反向,具体取决于 gcc 版本,-O0。


我制作了您的 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    $64, %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 循环也很容易,但并非没有序言。例如计算一个结束指针(数组+大小)并递增一个指针,直到它大于或等于。

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

【讨论】:

  • 如果对象中没有实际使用 read_array 和 write_array 的函数,我敢猜测 static inline 不会发出代码.也许OP为了简洁而忽略了这个问题?很难说,但我认为显示的代码不完整。
  • @MichaelPetch:好点。我进行了测试,将void foo(char *p, long s) { write_array(p,s); read_array_broken(p,s); } 添加到编译单元确实会为static inline 函数发出独立的定义。
【解决方案2】:

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

这很奇怪,因为 int i 在 x86-64 机器上应该是 4 个字节。不是吗?

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

【讨论】:

  • int 的大小由实现定义。在 Linux 上编译 64 位代码时,GCC 使用 32 位 int、long 和 long long 都是 64 位的。
  • en.wikipedia.org/wiki/64-bit_computing#64-bit_data_models 对一些更常见的 64 位数据模型进行了细分。
  • 大多数(如果不是全部)现代 64 位架构具有 32 位 int en.wikipedia.org/wiki/64-bit_computing#64-bit_data_models
  • 栈上的局部变量不必依赖栈是向上还是向下。如果没有优化,它很可能与编译器遍历内部表示源代码的数据结构(如抽象语法树等)的顺序有关。您不能依赖堆栈中各个变量的顺序。如果您想保持变量的顺序,您必须将它们放在 struct(或其他类似机制)中
  • @DavidHoelzer :使用典型调用约定传递参数时,参数为 8 字节宽。在堆栈上为局部变量分配空间可以看到默认打包的数据为其自然对齐。该分配空间的总大小通常是 8 的倍数。如果我有 7 个char 局部变量变量,我宁愿看到它们总共占用 8 个字节(包括填充)而不是 7 * 8 = 56 个字节(8每个字符的字节数)。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-10-20
  • 2013-01-07
  • 2021-05-17
  • 1970-01-01
相关资源
最近更新 更多