【问题标题】:Function not called in code gets called at runtime代码中未调用的函数在运行时被调用
【发布时间】:2018-06-12 04:20:57
【问题描述】:

下面的程序怎么会调用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.

【问题讨论】:

标签: c++ g++ compiler-optimization undefined-behavior clang++


【解决方案1】:

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

在运行时执行format_disk 是一个完美的有效情况,当 未定义的行为已被击中,它与崩溃一样有效(例如 用 GCC 编译时)。好的,但是为什么 Clang 会这样做呢?如果你 在关闭优化的情况下编译它,程序将不再输出 “格式化硬盘驱动器”,并且会崩溃:

$ 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 未定义行为[defns.undefined]

本文档没有要求的行为

[注意:当本文档省略时,可能会出现未定义的行为 任何明确的行为定义或当程序使用错误的 构造或错误的数据。 允许的未定义行为范围 从完全无视情况导致不可预测的结果,到 翻译过程中的行为或程序执行过程中的记录方式 环境特征(无论是否发布 诊断消息),终止翻译或执行(与 发出诊断消息)。许多错误的程序结构 不要产生未定义的行为;他们需要被诊断出来。 常量表达式的求值永远不会显式地表现出行为 指定为未定义 ([expr.const])。 —— 尾注]

强调我的。

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

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

这很有用,因为它允许tons of optimizations to kick in。所以 到目前为止,一切都很好,但是如果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 开始执行。例如,在声明顶级变量时, 您可以在初始化该变量的值时调用它:

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

如果你在你的程序中写了这个sn-p,程序没有 更长的表现出未定义的行为,并且消息 “格式化困难 磁盘驱动器!” 会显示,优化可以打开也可以关闭。

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

很酷,但是外面没有人打电话给never_called。尽管这 事实上,优化器认为该程序的唯一方法是 有效的是如果在main 执行之前调用never_called,否则它是 只是未定义的行为。这是一个新的学习事实,因此编译器假定 never_called 实际上被称为。基于这些新知识,其他优化 加入可能会利用它。

例如,当constant folding 是 应用,它看到构造 foo() 仅在 foo 可以正确初始化时才有效。发生这种情况的唯一方法是在此翻译单元之外调用never_called,因此foo = format_disk

Dead code eliminationinterprocedural optimization 可能会发现如果foo == format_disk,则不需要never_called 中的代码, 所以函数的主体被转换成一条ret指令。

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

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 BehaviorA Guide to Undefined Behavior in C and C++,这两篇文章系列都非常丰富,可能会帮助您了解最新技术。

【讨论】:

  • x + 1 &gt; x 的例子中,你可能应该说x 的类型是有符号整数类型。将其折叠为 true 对于无符号类型(或者当然对于类类型)不是有效的优化。
【解决方案2】:

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

请注意,这个问题的情况与例如非常不同

unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}

在后一种情况下,一个不声称是“可分析”的编译器可能会认识到,当执行到达return 语句时,如果 q 大于 46,340,代码将调用,因此它也可能调用do_something()无条件地。虽然附件 L 写得不好,但其意图似乎是禁止这种“优化”。然而,在调用无效函数指针的情况下,即使是在大多数平台上直接生成的代码也可能具有任意行为。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-04-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多