【问题标题】:GCC Assembly Optimizations - Why are these equivalent?GCC 程序集优化 - 为什么这些是等价的?
【发布时间】:2015-09-18 22:22:30
【问题描述】:

我正在尝试从初级学习汇编的工作原理,因此我一直在玩 gcc 编译的-S 输出。我编写了一个简单的程序,它定义了两个字节并返回它们的总和。整个程序如下:

int main(void) {
  char A = 5;
  char B = 10;
  return A + B;
}

当我在没有优化的情况下编译它时:

gcc -O0 -S -c test.c

我得到如下所示的 test.s:

    .file   "test.c"
    .def    ___main;    .scl    2;  .type   32; .endef
    .text
    .globl  _main
    .def    _main;  .scl    2;  .type   32; .endef
_main:
LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5
    andl    $-16, %esp
    subl    $16, %esp
    call    ___main
    movb    $5, 15(%esp)
    movb    $10, 14(%esp)
    movsbl  15(%esp), %edx
    movsbl  14(%esp), %eax
    addl    %edx, %eax
    leave
    .cfi_restore 5
    .cfi_def_cfa 4, 4
    ret
    .cfi_endproc
LFE0:
    .ident  "GCC: (GNU) 4.9.2"

现在,认识到这个程序可以很容易地简化为只返回一个常量 (15),我已经能够使用以下代码手动减少程序集以执行相同的功能:

.global _main
_main:
    movl    $15, %eax
    ret

在我看来,这似乎是执行这项公认微不足道的任务的最少代码量(但我意识到这可能是非常错误的)。这种形式是我的 C 程序最“优化”的版本吗?

为什么 GCC 的初始输出如此冗长?从.cfi_startproccall __main 的行甚至可以做什么? call __main 是做什么的?我不知道这两个减法运算是干什么用的。

即使将 GCC 中的优化设置为 -O3 我也明白了:

    .file   "test.c"
    .def    ___main;    .scl    2;  .type   32; .endef
    .section    .text.unlikely,"x"
LCOLDB0:
    .section    .text.startup,"x"
LHOTB0:
    .p2align 4,,15
    .globl  _main
    .def    _main;  .scl    2;  .type   32; .endef
_main:
LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5
    andl    $-16, %esp
    call    ___main
    movl    $15, %eax
    leave
    .cfi_restore 5
    .cfi_def_cfa 4, 4
    ret
    .cfi_endproc
LFE0:
    .section    .text.unlikely,"x"
LCOLDE0:
    .section    .text.startup,"x"
LHOTE0:
    .ident  "GCC: (GNU) 4.9.2"

这似乎删除了一些操作,但仍然留下了所有通往call __main 的行,这似乎是不必要的。 所有.cfi_XXX 行是干什么用的?为什么要添加这么多标签? .section.ident.def .p2align等有什么作用?

我知道许多标签和符号都包含在调试中,但如果我没有启用 -g 进行编译,难道不应该删除或省略这些标签和符号吗?


更新

说清楚

在我看来,这可能是最少的代码(但我 意识到可能是完全错误的)来执行这个公认的微不足道的任务。 这种形式是我的 C 程序最“优化”的版本吗?

我并不是说我正在尝试或已经实现了该程序的优化版本。我意识到这个程序是无用的和微不足道的。我只是将它用作学习汇编和编译器工作原理的工具。

我添加这个位的核心是为了说明为什么我很困惑这个汇编代码的4行版本可以有效地达到与其他版本相同的效果。在我看来,GCC 添加了很多我无法辨别其目的的“东西”。

【问题讨论】:

  • 调试。优化是调试器最大的敌人。所有这些冗余的、有据可查的行使调试汇编就像调试 C 一样容易。对于第二个问题,C 程序使用 C 运行时,它们需要遵守 ABI 并且可能使用操作系统特定的功能。
  • @WeatherVane 我的意图并不是说这个程序在任何方面都有意义或有用,我只是将它用作学习汇编和编译器工作原理的工具。
  • @Kin3TiX 抱歉,删除了我的粗话。

标签: c gcc assembly x86-64 gnu-assembler


【解决方案1】:

感谢 Kin3TiX,您提出了一个 asm 新手问题,这不仅仅是一些没有 cmets 的讨厌代码的代码转储,而且是一个非常简单的问题。 :)

为了让您熟悉 ASM,我建议使用 main 以外的函数。例如只是一个接受两个整数参数并将它们相加的函数。然后编译器无法优化它。您仍然可以使用常量作为 args 来调用它,如果它与 main 位于不同的文件中,它不会被内联,因此您甚至可以单步执行它。

在编译 main 时了解 asm 级别发生的事情有一些好处,但除了嵌入式系统之外,您只会在 asm 中编写优化的内部循环。 IMO,如果你不打算优化它,那么使用 asm 就没有什么意义了。否则,您可能无法击败更容易阅读的源代码的编译器输出。

了解编译器输出的其他提示:使用
gcc -S -fno-stack-check -fverbose-asm 编译。每条指令之后的 cmets 通常很好地提醒了该负载的用途。很快它就会退化成一堆名称为D.2983 的临时变量,但类似
movq 8(%rdi), %rcx # a_1(D)->elements, a_1(D)->elements 之类的东西会为您节省往返ABI 参考的时间,以查看%rdi 中的哪个函数arg,以及哪个函数struct 成员的偏移量为 8。

另见How to remove "noise" from GCC/clang assembly output?


从 .cfi_startproc 到 call__main 的行有什么作用?

    _main:
LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5

.cfi stuff 是用于调试器(和 C++ 异常处理)展开堆栈的堆栈展开信息 如果您从objdump -d 输出而不是gcc -S 中查看asm,它将不存在,或者您可以使用-fno-asynchronous-unwind-tables

推入%ebp 然后将其设置为函数入口上堆栈指针的值的东西设置了所谓的“堆栈帧”。这就是为什么%ebp 被称为基指针。如果您使用-fomit-frame-pointer 编译,这些insn 将不存在,这为代码提供了一个额外的寄存器来使用。默认情况下,-O2 处于启用状态。 (这对于 32 位 x86 来说是巨大的,因为这需要 6 到 7 个可用的 reg。(%esp 仍然被绑定为堆栈指针;将它暂时存储在 xmm 或 mmx reg 中,然后将其用作另一个 GP reg理论上是可能的,但编译器永远不会这样做,并且它会使诸如 POSIX 信号或 Windows SEH 之类的异步内容无法使用,并且使调试变得更加困难。)

ret 之前的 leave 指令也是此堆栈帧内容的一部分。

帧指针主要是历史包袱,但确实使堆栈帧的偏移量保持一致。使用调试符号,即使使用-fomit-frame-pointer,您也可以很好地回溯调用堆栈,这是 amd64 的默认设置。 (amd64 ABI 对堆栈有对齐要求,在其他方面也好很多。例如,在 regs 中而不是在堆栈中传递 args。)

    andl    $-16, %esp
    subl    $16, %esp

and 将堆栈对齐到 16 字节边界,无论它之前是什么。 sub 在堆栈上为这个函数保留 16 个字节。 (请注意优化版本中缺少它,因为它优化了任何变量的内存存储需求。)

    call    ___main

__main (asm name = ___main) 是 cygwin 的一部分:它为共享库(包括 libc)调用构造函数/初始化函数。在 GNU/Linux 上,这是由 _start 处理的(在到达 main 之前),甚至是动态链接器钩子让 libc 在到达可执行文件自己的 _start 之前初始化自身。我已经读过动态链接器挂钩(或来自静态可执行文件的_start)而不是main 中的代码 在 Cygwin 下是可能的,但他们只是选择不这样做。

(此old mailing list message 表示_main 用于构造函数,但该 main 不应该在支持获取启动代码调用它的平台上调用它。)

    movb    $5, 15(%esp)
    movb    $10, 14(%esp)
    movsbl  15(%esp), %edx
    movsbl  14(%esp), %eax
    addl    %edx, %eax
    leave
    ret

为什么 GCC 的初始输出如此冗长?

在没有启用优化的情况下,gcc 将 C 语句尽可能地映射到 asm 中。做任何其他事情都会花费更多的编译时间。因此,movb 来自两个变量的初始化程序。返回值是通过两次加载来计算的(带有符号扩展,因为我们需要在添加之前上转换为 int,以匹配所编写的 C 代码的语义,直至溢出)。

我不知道这两个减法运算是干什么用的。

只有一条sub 指令。在调用__main 之前,它会在堆栈上为函数的变量保留空间。你说的是其他哪个潜艇?

.section、.ident、.def .p2align 等是做什么的?

请参阅manual for the GNU assembler。也可在本地作为信息页面使用:运行 info gas

.ident.def:看起来 gcc 将其标记放在目标文件上,因此您可以知道是什么编译器/汇编器产生了它。不相关,忽略这些。

.section:确定来自所有后续指令或数据指令(例如.byte 0x00)的字节进入 ELF 目标文件的哪个部分,直到下一个 .section 汇编程序指令。 code(只读,可共享)、data(初始化读/写数据,私有)或bss(块存储段。零初始化,不占用目标文件中的任何空间)。

.p2align: 2 的幂对齐。用 nop 指令填充,直到所需的对齐。 .align 16.p2align 4 相同。当目标对齐时,跳转指令更快,因为指令提取为 16B 的块,不跨越页面边界,或者只是不跨越缓存线边界。 (当代码已经在英特尔 Sandybridge 及更高版本的 uop 缓存中时,32B 对齐是相关的。)例如,请参阅 Agner Fog's docs

我加这个位的核心是为了说明我为什么糊涂 这个汇编代码的4行版本可以有效地实现 和其他的效果一样。在我看来,GCC 增加了很多 我无法辨别其用途的“东西”。

将感兴趣的代码单独放入函数中。 main 有很多特别之处。

您是正确的,mov-immediate 和 ret 是实现该功能所需的全部,但 gcc 显然没有用于识别琐碎的整个程序并省略 main 的堆栈帧的快捷方式或致电_main。 >.

不过,问得好。正如我所说,忽略所有这些废话,只关注你想要优化的小部分。

【讨论】:

  • 对此进行更新,.cfi 不是严格的调试信息,也没有被剥离。它是堆栈展开信息,说明哪个调用保留寄存器保存在哪里。另请参阅How to remove "noise" from GCC/clang assembly output?,了解有关删除“噪音”(如您通常不关心的 .cfi 指令)的更多信息。
【解决方案2】:

.cfi(调用帧信息)指令在gas(Gnu ASsembler)中主要用于调试。它们允许调试器展开堆栈。要禁用它们,您可以在调用编译驱动程序-fno-asynchronous-unwind-tables时使用以下参数。

如果你想玩通用的编译器,你可以使用下面的编译驱动调用命令-o <filename.S> -S -masm=intel -fno-asynchronous-unwind-tables <filename.C>或者直接使用godbolt's interactive compiler

【讨论】:

  • 交互式编译器非常有用。特别是,着色功能让我可以直接查看哪些汇编指令是直接从我的源代码行生成的。其他答案随后帮助我隔离了最终出现在那里的所有其他“东西”。
  • @Kin3TiX 很高兴我能帮上忙。
【解决方案3】:

首先,CFI 内容用于调试目的(在 C++ 中,还有异常处理)。它告诉调试器每条指令的堆栈帧是什么样的,以便调试器可以重建程序变量的状态。这些不会产生可执行的语句,并且对程序的运行时性能影响为零。

我不知道对 __main 的调用在那里做什么 - 我的 GCC 没有这样做。事实上,我的 GCC (4.9.2) 为 gcc test.c -S -O1 提供了以下信息:

    .section __TEXT,__text_startup,regular,pure_instructions
    .globl _main
_main:
LFB0:
    movl    $15, %eax
    ret
LFE0:
    .section __TEXT,__eh_frame,coalesced,no_toc+strip_static_syms+live_support
EH_frame1:
    .set L$set$0,LECIE1-LSCIE1
    .long L$set$0
LSCIE1:
    .long   0
    .byte   0x1
    .ascii "zR\0"
    .byte   0x1
    .byte   0x78
    .byte   0x10
    .byte   0x1
    .byte   0x10
    .byte   0xc
    .byte   0x7
    .byte   0x8
    .byte   0x90
    .byte   0x1
    .align 3
LECIE1:
LSFDE1:
    .set L$set$1,LEFDE1-LASFDE1
    .long L$set$1
LASFDE1:
    .long   LASFDE1-EH_frame1
    .quad   LFB0-.
    .set L$set$2,LFE0-LFB0
    .quad L$set$2
    .byte   0
    .align 3
LEFDE1:
    .subsections_via_symbols

你会看吗,_main 正是你所期望的两个指令序列。 (__eh_frame 是更多不同格式的调试信息)。

【讨论】:

    【解决方案4】:

    -o0 选项将输出定向到名为 0 的文件。也许您的意思是优化级别(大写O)?:禁用优化。

    我不明白为什么会有对____main 的调用,除非这是为某些模拟或挂钩环境生成的。当我使用gcc -O0 -c -S t.c 编译时,我得到:

            .file   "t.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
            movb    $5, -2(%rbp)
            movb    $10, -1(%rbp)
            movsbl  -2(%rbp), %edx
            movsbl  -1(%rbp), %eax
            leal    (%rdx,%rax), %eax
            leave
            .cfi_def_cfa 7, 8
            ret
            .cfi_endproc
    .LFE0:
            .size   main, .-main
            .ident  "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-11)"
            .section        .note.GNU-stack,"",@progbits
    

    也许您期待高水平的优化?这就是我通过gcc -O3 -c -S t.c 得到的:

            .file   "t.c"
            .text
            .p2align 4,,15
    .globl main
            .type   main, @function
    main:
    .LFB0:
            .cfi_startproc
            movl    $15, %eax
            ret
            .cfi_endproc
    .LFE0:
            .size   main, .-main
            .ident  "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-11)"
            .section        .note.GNU-stack,"",@progbits
    

    除调试信息外,它尽可能短。为gcc -O2 -c -S t.cgcc -O1 -c -S t.c 生成相同的代码。也就是说,最轻微的优化会在编译时评估所有常量。

    【讨论】:

    • 我确实有一个大写字母-O...?无论如何,我认为您的回答向我解释了 __main。我在 Cygwin 上运行了这个(在工作中,所以我不在我的 Linux 机器上),所以这将支持你关于在模拟或挂钩环境中运行的评论。
    【解决方案5】:

    我认为这部分只是一个固定模式,它设置了一个 16 字节对齐的堆栈,并且 CFI 与 exception frame 处理相关。

    很难确定任何 main() 都不需要这些,因为这是全局优化,因为 main 可能会调用其他编译单元中的函数。

    而且可能不值得花时间优化这个微不足道且相当无用的案例。

    如果您不这么认为,您可以随时开始进行此类优化并将其提交给 gcc。

    【讨论】:

    • 异常框架听起来更像 C++,因为 C 没有异常。但这实际上可能只是两种语言的通用模板。
    • 不是试图暗示我能够“优化”这个例子......只是想了解为什么我可以将这个程序集剥离到那么几行并且仍然可以工作。这让我想知道那里所有其他的东西都在做什么,这是我问题的症结所在。
    • 然后将所有你感兴趣的代码放在单独的函数中,并进行高度优化的编译。正如您所发现的,对于启动和主代码,有时存在其他规则和权衡,这可能会影响所发生的情况。
    猜你喜欢
    • 1970-01-01
    • 2015-12-25
    • 2011-01-20
    • 1970-01-01
    • 2018-06-16
    • 1970-01-01
    • 1970-01-01
    • 2012-09-20
    • 2011-05-20
    相关资源
    最近更新 更多