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

时间:2016-03-30 02:27:18

标签: c linux gcc assembly disassembly

我写了一个只读/写大型数组的C程序。我用命令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   $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显然在int i函数中的 write_array之后定义了。为什么gcc重新排序这两个局部变量的内存位置?

2)从偏移量来看,int i位于-0x8(%rbp)char tmp位于-0x1(%rbp),表示变量int i需要 7 字节?这很奇怪,因为在x86-64机器上int i应该是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

2 个答案:

答案 0 :(得分:3)

即使在-O0,gcc也不会发出static inline功能的定义,除非有来电者。在这种情况下,它实际上并没有内联:相反,它会发出一个独立的定义。所以我想你的反汇编是从那个。

您使用的是非常古老的gcc版本吗? gcc 4.6.4将vars按顺序放在堆栈上,但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决定为他们分配空间的顺序:

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

  • int上的rbp-8:由于从rbp-1rbp-4的4个字节不是免费的,因此下一个可用的自然对齐位置是rbp-8

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

RE:节省空间:

将char设置为-5会使触摸地址%rsp-5最低,而不是%rsp-8,但这并不能保存任何内容。

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

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

在32位代码中,没有redzone,对于任何一个订单,gcc都会在sub $16, %esp的堆栈上保留空间。 (在godbolt上尝试使用-m32)。同样,我们是否使用5或8个字节并不重要,因为我们以16为单位保留。

当有许多charint变量时,gcc将char打包成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最初非零):

read_array_handtuned:
.L0
    mov     al, dword [rdi]   ; maybe not ideal on AMD: partial-reg writes have a false dep on old value.  Hopefully the load can still start, and just the merging is serialized?
    add     rdi, 64
    sub     rsi, 64
    jae     .L0           ; or ja, depending on what semantics you want

Godbolt Compiler Explorer link with all my attempts and trial versions

如果循环终止条件为je,我可以在类似do { ... } while( size -= CL_SIZE );的循环中得到类似但是我似乎无法从编译器中获取最佳代码以便在减去时捕获无符号借位。他们想要减去然后cmp -64/jb来检测下溢。它是not that hard to get compilers to check the carry flag after an add to detect carry:/

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

答案 1 :(得分:-2)

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

  

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

如果我的内存正确地为我服务,x86-64机器上int的大小为8.您可以通过编写测试应用程序来打印error来确认它。