未在代码中调用的函数在运行时被调用

Function not called in code gets called at runtime

如果下面的程序从不调用 format_disk,它怎么会调用呢? 在代码中调用?

#include <cstdio>

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

static void (*foo)() = nullptr;

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}

这因编译器而异。用 Clang 编译 优化,函数 never_called 在运行时执行。

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

使用 GCC 编译,但是,这段代码崩溃了:

$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)

编译器版本:

$ clang --version
clang version 5.0.0 (tags/RELEASE_500/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
$ gcc --version
gcc (GCC) 7.2.1 20171128
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

程序包含未定义的行为,如取消引用空指针 (即在 main 中调用 foo() 而未为其分配有效地址 之前)是UB,因此标准没有强加要求。

在运行时执行 format_disk 是一个完美的有效情况 未定义的行为已被击中,它与崩溃一样有效(比如 当用 GCC 编译时)。好的,但是为什么 Clang 会那样做呢?如果你 在关闭优化的情况下编译它,程序将不再输出 "formatting hard disk drive",并且会崩溃:

$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)

本版本生成代码如下:

main:                                   # @main
        push    rbp
        mov     rbp, rsp
        call    qword ptr [foo]
        xor     eax, eax
        pop     rbp
        ret

它尝试调用 foo 指向的函数,并且 foonullptr 初始化(或者如果它没有任何初始化, 这仍然是这种情况),它的值为零。这里,未定义 行为已被击中,所以任何事情都可能发生,程序 变得毫无用处。通常,调用这样一个无效地址 导致分段错误,因此我们得到的消息是 执行程序。

现在让我们检查同一个程序,但对其进行优化编译:

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

本版本生成代码如下:

never_called():                         # @never_called()
        ret
main:                                   # @main
        push    rax
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

有趣的是,优化修改了程序,因此 main 直接调用 std::puts。但为什么 Clang 会那样做呢?为什么是 never_called 编译为单个 ret 指令?

让我们暂时回到标准(特别是 N4660)。什么 它是否说明未定义的行为?

3.27 undefined behavior [defns.undefined]

behavior for which this document imposes no requirements

[Note: Undefined behavior may be expected when this document omits any explicit definition of behavior or when a program uses an erroneous construct or erroneous data. Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message). Many erroneous program constructs do not engender undefined behavior; they are required to be diagnosed. Evaluation of a constant expression never exhibits behavior explicitly specified as undefined ([expr.const]). — end note]

强调我的。

表现出未定义行为的程序变得无用,因为一切 它到目前为止已经完成并且将做进一步没有意义,如果它包含 错误的数据或结构。考虑到这一点,请记住这一点 编译器可能会完全忽略未定义行为的情况 被击中,这实际上在优化一个时被用作发现的事实 程序。例如,像 x + 1 > x 这样的结构(其中 x 是一个带符号的整数)将被优化为一个常量, true,即使 x 的值在 compile-time 处未知。推理 是编译器想要针对有效情况进行优化,并且唯一 该构造有效的方法是它不触发算术 溢出(即如果 x != std::numeric_limits<decltype(x)>::max())。这个 是优化器中新学到的事实。基于此,构造是 证明总是评估为真。

注意:同样的优化不能发生在无符号整数上,因为溢出一个不是UB。也就是说,编译器需要保持表达式的原样,因为当发生溢出时它可能有不同的评估(无符号是模块 2N,其中 N 是位数)。为无符号整数优化它会不符合标准(感谢 aschepler)。

这很有用,因为它允许进行 大量优化 在。所以 到目前为止,还不错,但是如果 x 在运行时保持其最大值会怎样? 好吧,这是未定义的行为,所以尝试推理是无稽之谈 它,因为任何事情都可能发生,并且标准没有强加任何要求。

现在我们有足够的信息以便更好地检查您的故障 程序。我们已经知道访问空指针是未定义的 行为,这就是在运行时导致有趣行为的原因。 因此,让我们尝试理解为什么 Clang(或技术上 LLVM)进行了优化 程序的方式。

static void (*foo)() = nullptr;

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}

请记住,可以在 main 条目之前调用 never_called 开始执行。例如,当声明一个 top-level 变量时, 您可以在初始化该变量的值时调用它:

void never_called();
int x = (never_called(), 42);

如果你在你的程序中写这个片段,程序没有 不再表现出未定义的行为,并且消息 "formatting hard 磁盘驱动器!” 显示,优化打开或关闭。

那么这个程序唯一有效的方法是什么?有这个 never_caledformat_disk 的地址分配给 foo 的函数,所以我们可能 在这里找到一些东西。注意foo被标记为static,意思是 具有内部链接,无法从该翻译外部访问 单元。相比之下,函数 never_called 具有外部链接,并且可能 可以从外部访问。如果另一个翻译单元包含一个片段 像上面那个,那么这个程序就生效了。

很好,但是没有人从外面打电话给 never_called。尽管这 事实上,优化器认为这个程序的唯一方法是 如果 never_calledmain 执行之前调用,则有效,否则 只是未定义的行为。这是一个新发现的事实,所以编译器假定 never_called 实际上被称为。基于这些新知识,其他优化 踢进来可以利用它。

例如,当常量 折叠 是 应用后,它发现构造 foo() 仅在 foo 可以正确初始化时才有效。唯一的办法或者如果 never_called 在这个翻译单元之外被调用,那么 foo = format_disk.

Dead code elimination and interprocedural optimization可能会发现,如果foo == format_disk,那么never_called里面的代码就不需要了, 因此函数的 body 被转换为单个 ret 指令。

Inline expansion优化 看到 foo == format_disk,所以可以替换对 foo 的调用 及其 body。最后,我们得到这样的结果:

never_called():
        ret
main:
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        ret
.L.str:
        .asciz  "formatting hard disk drive!"

这在某种程度上相当于启用了优化的 Clang 的输出。当然,Clang 真正做的可能(也可能)不同,但优化仍然能够得出相同的结论。

在优化的情况下检查 GCC 的输出,它似乎没有费心去调查:

.LC0:
        .string "formatting hard disk drive!"
format_disk():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
never_called():
        mov     QWORD PTR foo[rip], OFFSET FLAT:format_disk()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret

执行该程序会导致崩溃(分段错误),但如果您在执行 main 之前在另一个翻译单元中调用 never_called,则该程序不会再表现出未定义的行为。

随着越来越多的优化被设计出来,所有这些都可能发生疯狂的变化,所以不要依赖你的编译器会处理包含未定义行为的代码的假设,它可能也会把你搞砸(并格式化你的真正的硬盘驱动器!)


我建议您阅读 What every C programmer should know about Undefined Behavior and A Guide to Undefined Behavior in C and C++,这两个系列文章都提供了很多信息,可能会帮助您了解最新技术。

除非实现指定尝试调用空函数指针的效果,否则它可能表现为对任意代码的调用。这样的任意代码可以完美地表现得像对函数 "foo()" 的调用。虽然 C 标准的附件 L 会要求实现区分 "Critical UB" 和 "non-critical UB",并且某些 C++ 实现可能会应用类似的区别,但在任何情况下调用无效函数指针都是关键的 UB。

请注意,这个问题的情况与例如

有很大不同
unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}

在后一种情况下,未声明为 "analyzable" 的编译器可能会识别出如果 q 大于 46,340 时代码将在执行到达 return 语句时调用,因此它可能以及无条件调用 do_something() 。虽然附件 L 写得不好,但似乎打算禁止这样的 "optimizations"。然而,在调用无效函数指针的情况下,即使是在大多数平台上直接生成的代码也可能具有任意行为。