gcc 和 clang 发出看似多余的函数调用

use*_*687 8 c gcc function-pointers x86-64 clang

typedef struct foo
{
    void (*const t)(struct foo *f);
} foo;

void t(struct foo *f)
{
    
}

void (*const myt)(struct foo *f) = t;

foo f = {.t = t};

int main(void)
{
    f.t(&f);
    myt(&f);

    return 0;
}
Run Code Online (Sandbox Code Playgroud)

当使用 x86-64 gcc 13.2 和 clang 16.0.0 编译上述代码时,会生成类似的汇编代码。下面显示的是 gcc 输出。

t:
        ret
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:f
        call    [QWORD PTR f[rip]]
        xor     eax, eax
        add     rsp, 8
        ret
f:
        .quad   t
myt:
        .quad   t
Run Code Online (Sandbox Code Playgroud)

为什么两个编译器t在通过函数调用时发出对函数的调用struct foo,而在通过函数指针调用时则不发出函数调用myt?为什么编译器无法看到t函数指针struct foo指向空函数并消除所有调用?由于f是静态分配并在编译时初始化,因此其成员t(一个在运行时无法更改的常量指针(不会调用未定义的行为))指向一个空函数t

Pet*_*des 10

GCC 和 Clang 都提供了在 before 之前运行任意代码的机制main,因此这些编译器必须尊重这种可能性。(包括使用静态构造函数与 C++ 库或GNU C__attribute__((constructor))链接。)

全局变量foo f不是const,因此 GCC 和 clang 假设它可能在执行到达之前已被修改f.t(&f);更改为const foo f = ...;让 GCC 和 clang 知道函数指针值是编译时常量,并内联它并优化掉空函数。(神箭


那么修改后的代码会是什么样子f.t呢?

显然它不能这样做,f.t = whatever因为.t成员是const。GCC 和 clang 也拒绝编译f = (foo){NULL};以替换整个结构的值,抱怨const成员。(Clang 说错误:无法使用 const 限定数据成员 't' 分配给变量 'f'。GCC 使用与const foo f, error: assignment of read-only variable 'f'相同的错误相同的错误。)

memcpy但即使是在没有警告的情况下,他们也确实允许,而如果是-Wall -Wextra,他们就会警告。(我用作占位符只是为了避免编写另一个有效函数。如果您可以将其更改为 NULL,则可以将其更改为在调用时会执行某些操作的函数。)fconstNULL

#include <string.h>
// Code like this could hypothetically run before main in another compilation unit
void startup(void){
    // f = (foo){NULL};    // illegal

    foo tmp = {NULL};
    memcpy(&f, &tmp, sizeof(f));  // legal, it seems, as long as foo f isn't const foo f
      //   (In which case this warns, and will segfault at run-time)
      // actually clang emits no instructions for the memcpy in the UB case where it's trying to write const foo f.
}
Run Code Online (Sandbox Code Playgroud)

我还没有检查 ISO 标准,但假设 GCC/clang 的(缺少)警告与其优化器假设其他代码可以执行的操作一致,这解释了为什么在const f这种情况下 non- 不被视为具有已知值。


为什么没有static foo f帮助?

制作它static foo f(在没有定义startup()修改它的函数的编译单元中)应该允许常量传播,因为编译单元外部的任何东西都看不到它,并且内部的任何东西都不会改变值。

但即使gcc -fwhole-program是 GCC/clang -flto(反汇编链接的二进制文件)也无法内联f.t(&f),即使这些选项确实让它们内联myt(&f)const非非static myt。(神箭)。

将调用更改为f.t(0);myt(0)允许 GCC 和 clang 优化这两个调用!(使用static foo fand const myt,或使用-fwhole-programor -flto。) 我认为&f作为 arg 传递会击败转义分析,即使编译器理论上可以证明调用目标仍然始终是t()在此编译单元中定义的,这实际上并没有让地址逃逸到编译单元之外。

// or non-const is the same if we're using -flto or -fwhole-program
void (*const myt)(const struct foo *f) = t;
static foo f = {.t = t};

int main(void)
{
    f.t(0);    // not f.t(&f) so it's clear to the optimizer the address of f doesn't escape
    myt(0);

// f.t(0); myt(&f);  // lets clang -flto optimize away both calls, but still not GCC -fwhole-program
}
Run Code Online (Sandbox Code Playgroud)

main在 C 中,从程序内部再次调用并不是 UB 。因此,如果f.t(&f)发生变化, (例如,从另一个编译单元中的函数)f.t的下一次执行将是对不同函数的调用。这实际上是不可能的,但很容易想象编译器可能陷入了试图证明这一点的循环并放弃了。(通过此编译单元中的非函数获取其地址并将其传递给函数调用,这通常意味着地址转义。看到它转到不改变的位置,则需要在完成转义分析之前进行内联,但它不能通过函数指针内联,除非转义分析可以证明它知道正在调用哪个函数。)main__attribute__((destructor))fstatict()f

myt另一方面确实如此,const很容易优化掉。即使我们不使用constor static,使用-fltoor-fwhole-program编译也很容易看到此编译单元(或任何具有 LTO 的编译单元)中的任何内容都不会更改它,并且&myt不会作为函数 arg 传递,因此显然不会转义。(没有-fltoor const/ static,通过 global 的 constpropmyt也不会发生。)

Godbolt - 使用maindof.t(0);myt(0);*const myt足以static foo f让 GCC 和 clang 优化掉这两个调用,即使没有-flto-fwhole-program。除非这个编译单元定义了一个类似startup()to 的memcpy函数&f

但是对于 non- static foo f,这种优化再次是不可能的,因为它甚至可以在第一次/唯一执行 之前被修改main

顺便说一句,我的 Godbolt 链接-fvisibility=hidden只是为了确保不可能进行符号插入,这将是非变量static的值与源中的初始值设定项不同的另一种方式。但这对于主可执行文件中的全局变量来说已经不可能了,只与-fPIC编译对共享库安全的代码有关。