11

我有两个文件:

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: print0(); break;
        case 1: print1(); break;
        case 2: print2(); break;
        case 3: print3(); break;
        case 4: print4(); break;
    }
    return 0;
}

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    static void (*jt[])() = { print0, print1, print2, print3, print4 };
    jt[input]();
    return 0;
}

我希望它们被编译成几乎相同的汇编代码。在这两种情况下都会生成跳转表,但第一个文件中的调用由 表示jmp,而第二个文件中的调用由call. 为什么编译器不优化calls?是否可以提示 gcc 我希望看到jmps 而不是calls?

编译gcc -Wall -Winline -O3 -S -masm=intel,GCC 版本 4.6.2。GCC 4.8.0 产生的代码略少,但问题仍然存在。

UPD:定义jtconst void (* const jt[])() = { print0, print1, print2, print3, print4 };并制作函数static const inline没有帮助:http: //ideone.com/97SU0

4

6 回答 6

8

编译器编写者有很多工作要做。显然,他们优先考虑回报最大和最快的工作。

Switch 语句在各种代码中都很常见,因此对它们执行的任何优化都会对许多程序产生影响。

这段代码

jt[input](); 

不太常见,因此在编译器设计者的 TODO 列表中要长得多。也许他们还没有(还)发现尝试优化它是值得的?这会为他们赢得任何已知的基准吗?或者改进一些广泛使用的代码库?

于 2012-05-15T14:03:19.900 回答
5

因为函数指针数组是可变的。编译器决定它不能假设指针不会改变。您可能会发现 C++ 的程序集不同,和/或将 jt 设为 const。

于 2012-05-15T13:57:36.240 回答
3

我的猜测是,这种优化与您在: 优化器意识到它可以捎带嵌入到您的..函数中的返回值return之后立即有一个语句的事实有关,并减少到; CPU 在选定的内部命中用作从.switchprint0print4calljmpretprintNmain

尝试在切换jmp插入一些代码以查看编译器是否会替换为call.

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: print0(); break;
        case 1: print1(); break;
        case 2: print2(); break;
        case 3: print3(); break;
        case 4: print4(); break;
    }
    /* Inserting this line should force the compiler to use call */
    printf("\nDone");
    return 0;
}

编辑: 您在ideone上的代码有jmp一个不同的原因:它相当于:

static const char* LC0 ="Zero";
static const char* LC1 ="One";
static const char* LC2 ="Two";
static const char* LC3 ="Three";
static const char* LC4 ="Four";

int main()
{
    unsigned int input;
    scanf("%u", &input);

    switch (input)
    {
        case 0: printf(LC0); break;
        case 1: printf(LC1); break;
        case 2: printf(LC2); break;
        case 3: printf(LC3); break;
        case 4: printf(LC4); break;
    }
    printf("\nDone");
    return 0;
}
于 2012-05-15T13:56:56.840 回答
2

第一种情况(通过switch())为我创建了以下内容(Linux x86_64 / gcc 4.4):

  400570:       ff 24 c5 b8 06 40 00    jmpq   *0x4006b8(,%rax,8)
[ ... ]
  400580:       31 c0                   xor    %eax,%eax
  400582:       e8 e1 fe ff ff          callq  400468 <printf@plt>
  400587:       31 c0                   xor    %eax,%eax
  400589:       48 83 c4 08             add    $0x8,%rsp
  40058d:       c3                      retq
  40058e:       bf a4 06 40 00          mov    $0x4006a4,%edi
  400593:       eb eb                   jmp    400580 <main+0x30>
  400595:       bf a9 06 40 00          mov    $0x4006a9,%edi
  40059a:       eb e4                   jmp    400580 <main+0x30>
  40059c:       bf ad 06 40 00          mov    $0x4006ad,%edi
  4005a1:       eb dd                   jmp    400580 <main+0x30>
  4005a3:       bf b1 06 40 00          mov    $0x4006b1,%edi
  4005a8:       eb d6                   jmp    400580 <main+0x30>
[ ... ]
Contents of section .rodata:
[ ... ]
 4006b8 8e054000 p ... ]

请注意,.rodata内容@4006b8是打印的网络字节顺序(无论出于何种原因......),该值40058emain上面 - arg-initializer/jmp块开始的地方。那里的所有mov/jmp对都使用八个字节,因此是(,%rax,8)间接的。因此,在这种情况下,序列为:

jmp <to location that sets arg for printf()>
...
jmp <back to common location for the printf() invocation>
...
call <printf>
...
retq

这意味着编译器实际上已经优化了调用static站点——而是将它们全部合并到一个单一的内联printf()调用中。这里使用的表格是jmp ...(,%rax,8)指令,程序代码中包含的表格。

第二个(使用显式创建的表)为我执行以下操作:

0000000000400550 <print0>:
[ ... ]
0000000000400560 <print1>:
[ ... ]
0000000000400570 <print2>:
[ ... ]
0000000000400580 <print3>:
[ ... ]
0000000000400590 <print4>:
[ ... ]
00000000004005a0 <main>:
  4005a0:       48 83 ec 08             sub    $0x8,%rsp
  4005a4:       bf d4 06 40 00          mov    $0x4006d4,%edi
  4005a9:       31 c0                   xor    %eax,%eax
  4005ab:       48 8d 74 24 04          lea    0x4(%rsp),%rsi
  4005b0:       e8 c3 fe ff ff          callq  400478 <scanf@plt>
  4005b5:       8b 54 24 04             mov    0x4(%rsp),%edx
  4005b9:       31 c0                   xor    %eax,%eax
  4005bb:       ff 14 d5 60 0a 50 00    callq  *0x500a60(,%rdx,8)
  4005c2:       31 c0                   xor    %eax,%eax
  4005c4:       48 83 c4 08             add    $0x8,%rsp
  4005c8:       c3                      retq
[ ... ]
 500a60 50054000 00000000 60054000 00000000  P.@.....`.@.....
 500a70 70054000 00000000 80054000 00000000  p.@.......@.....
 500a80 90054000 00000000                    ..@.....

再次注意,当 objdump 打印数据部分时,字节顺序颠倒了——如果你把它们转过来,你会得到函数地址print[0-4]()

编译器通过间接调用目标call- 即表使用直接在call指令中,并且表已_显式地创建为数据。

编辑:
如果您像这样更改源:

#include <stdio.h>

static inline void print0() { printf("Zero"); }
static inline void print1() { printf("One"); }
static inline void print2() { printf("Two"); }
static inline void print3() { printf("Three"); }
static inline void print4() { printf("Four"); }

void main(int argc, char **argv)
{
    static void (*jt[])() = { print0, print1, print2, print3, print4 };
    return jt[argc]();
}

创建的程序集main()变为:

0000000000400550 <main>:
  400550:       48 63 ff                movslq %edi,%rdi
  400553:       31 c0                   xor    %eax,%eax
  400555:       4c 8b 1c fd e0 09 50    mov    0x5009e0(,%rdi,8),%r11
  40055c:       00
  40055d:       41 ff e3                jmpq   *%r11d

哪个看起来更像你想要的?

这样做的原因是您需要“无堆栈”函数才能执行此操作 - 尾递归(通过jmp而不是从函数返回ret)只有在您已经完成所有堆栈清理或不需要时才有可能做任何事情,因为您在堆栈上没有什么要清理的。编译器可以(但不需要)选择在最后一次函数调用之前进行清理(在这种情况下,最后一次调用可以由 进行jmp),但这只有在您返回从该函数获得的值或“返回void“。而且,如前所述,如果您实际使用堆栈(就像您的示例对input变量所做的那样),则没有什么可以使编译器强制以尾递归结果的方式撤消此操作。

编辑2:

第一个示例的反汇编,具有相同的更改(argc而不是input强制void main- 没有标准一致性评论,请这是一个演示),导致以下汇编:

0000000000400500 <main>:
  400500:       83 ff 04                cmp    $0x4,%edi
  400503:       77 0b                   ja     400510 <main+0x10>
  400505:       89 f8                   mov    %edi,%eax
  400507:       ff 24 c5 58 06 40 00    jmpq   *0x400658(,%rax,8)
  40050e:       66                      data16
  40050f:       90                      nop
  400510:       f3 c3                   repz retq
  400512:       bf 3c 06 40 00          mov    $0x40063c,%edi
  400517:       31 c0                   xor    %eax,%eax
  400519:       e9 0a ff ff ff          jmpq   400428 <printf@plt>
  40051e:       bf 41 06 40 00          mov    $0x400641,%edi
  400523:       31 c0                   xor    %eax,%eax
  400525:       e9 fe fe ff ff          jmpq   400428 <printf@plt>
  40052a:       bf 46 06 40 00          mov    $0x400646,%edi
  40052f:       31 c0                   xor    %eax,%eax
  400531:       e9 f2 fe ff ff          jmpq   400428 <printf@plt>
  400536:       bf 4a 06 40 00          mov    $0x40064a,%edi
  40053b:       31 c0                   xor    %eax,%eax
  40053d:       e9 e6 fe ff ff          jmpq   400428 <printf@plt>
  400542:       bf 4e 06 40 00          mov    $0x40064e,%edi
  400547:       31 c0                   xor    %eax,%eax
  400549:       e9 da fe ff ff          jmpq   400428 <printf@plt>
  40054e:       90                      nop
  40054f:       90                      nop

这在一种方式上更糟(做两个 jmp而不是一个),但在另一种方式上更好(因为它消除了static函数并内联代码)。在优化方面,编译器几乎做了同样的事情。

于 2012-05-16T15:09:32.437 回答
1

您是否分析了不同的代码?我认为可能会提出间接调用优化的论点。以下分析是使用针对 x64 平台 (MinGW) 的 GCC 4.6.1 完成的。

如果您查看jt[input]()使用时会发生什么,调用会导致执行以下代码序列:

  • printX()函数之一的间接调用
  • printX()函数为 设置参数printf(),然后
  • 跳转到printf()
  • 调用将printf()直接返回到`间接调用的站点。

共有3个分支。

当您使用 switch 语句时,会发生以下情况:

  • 为每种情况间接跳转到一些自定义代码(内联printX()调用)
  • printf()“案例处理程序”为调用加载适当的参数
  • 来电printf()
  • 调用将printf()返回到“案例处理程序”
  • 跳转到开关的出口点(除了一个内联退出代码的案例处理程序 - 其他案例跳转到那里)

总共有 4 个分支(一般情况下)。

在这两种情况下,你都有: - 一个间接分支(一个是调用,另一个是跳转) - 一个分支到printf()(一个是跳转,另一个是调用) - 一个分支返回到调用站点

但是,当使用该switch语句时,会有一个额外的分支到达开关的“结束”(在大多数情况下)。

现在,如果您实际分析事物,处理器可能会比间接调用更快地处理间接跳转,但我猜即使是这种情况,基于开关的代码中使用的附加分支仍然会推动尺度赞成通过函数指针调用。


对于那些感兴趣的人,这里是使用生成的汇编程序jk[input]();(两个示例都是使用针对 x64 的 GCC MinGW 4.6.1 编译的,使用的选项是-Wall -Winline -O3 -S -masm=intel):

print0:
    .seh_endprologue
    lea rcx, .LC4[rip]
    jmp printf
    .seh_endproc

// similar code is generated for each printX() function
// ...

main:
    sub rsp, 56
    .seh_stackalloc 56
    .seh_endprologue
    call    __main
    lea rdx, 44[rsp]
    lea rcx, .LC5[rip]
    call    scanf
    mov edx, DWORD PTR 44[rsp]
    lea rax, jt.2423[rip]
    call    [QWORD PTR [rax+rdx*8]]
    xor eax, eax
    add rsp, 56
    ret

这是为基于开关的实现生成的代码:

main:
    sub rsp, 56
    .seh_stackalloc 56
    .seh_endprologue
    call    __main
    lea rdx, 44[rsp]
    lea rcx, .LC0[rip]
    call    scanf
    cmp DWORD PTR 44[rsp], 4
    ja  .L2
    mov edx, DWORD PTR 44[rsp]
    lea rax, .L8[rip]
    movsx   rdx, DWORD PTR [rax+rdx*4]
    add rax, rdx
    jmp rax
    .section .rdata,"dr"
    .align 4
.L8:
    .long   .L3-.L8
    .long   .L4-.L8
    .long   .L5-.L8
    .long   .L6-.L8
    .long   .L7-.L8
    .section    .text.startup,"x"
.L7:
    lea rcx, .LC5[rip]
    call    printf
    .p2align 4,,10


.L2:
    xor eax, eax
    add rsp, 56
    ret

.L6:
    lea rcx, .LC4[rip]
    call    printf
    jmp .L2

     // all the other cases are essentially the same as the one above (.L6)
     // where they jump to .L2 to exit instead of simply falling through to it
     // like .L7 does
于 2012-05-15T15:59:02.880 回答
1

call后一个函数的代码在间接函数和后续函数之间没有任何作用ret吗?如果间接调用的地址计算使用后一个函数需要保留其值的寄存器,我不会感到惊讶(这意味着它必须在计算之前保存该值,并在一段时间后恢复它)。虽然可以在间接调用之前移动寄存器恢复代码,但编译器只能在已被编程为合法机会的情况下执行此类代码移动。

此外,虽然我认为这并不重要,但我建议例程不应该如此,inline因为编译器将无法以这种方式执行它们。

于 2012-05-15T18:04:26.720 回答