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

时间:2018-01-02 12:49:42

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

以下程序如果永远不会调用never_called 在代码中调用?

#include <cstdio>

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

static void (*foo)() = nullptr;

void set_foo()
{
  foo = never_called;
}

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.

2 个答案:

答案 0 :(得分:36)

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

在运行时执行never_called是完全有效的情况 未定义的行为已经被击中,它就像崩溃一样有效(比如 用GCC编译时)。好的,但为什么Clang这样做?如果你 在优化关闭的情况下编译它,程序将不再输出 &#34;格式化硬盘驱动器&#34;,并且只会崩溃:

$ 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指向的函数和foo 使用nullptr进行初始化(或者如果它没有进行任何初始化, 这仍然是这种情况),其值为零。在这里,未定义 行为已经受到打击,所以任何事情都可能发生在程序中 变得无用。通常,调用此类无效地址 导致分段错误错误,因此我们得到的消息 执行程序。

现在让我们检查相同的程序,但是在优化时编译它:

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

此版本的生成代码如下:

set_foo():                            # @set_foo()
        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这样做呢?为什么呢 set_foo编译为单个ret指令?

让我们暂时回到标准(N4660,具体而言)。什么 它是否说未定义的行为?

  

3.27未定义的行为[defns.undefined]

     

本文档没有要求的行为

     

[注意:本文档遗漏时可能会出现未定义的行为   任何明确的行为定义或当程序使用错误时   构造或错误的数据。允许的未定义行为范围   从完全忽略这种情况与不可预知的结果,到   在翻译期间表现或以文档化方式执行程序   环境的特征(有或没有发行   诊断消息),终止翻译或执行(使用   发布诊断消息)。许多错误的程序构造   不要产生未定义的行为;他们需要被诊断出来。   对常量表达式的评估从不明确地表现出行为   指定为undefined([expr.const])。 - 结束说明]

强调我的。

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

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

这很有用,因为它允许tons of optimizations to kick in。所以 远,那么好,但如果x在运行时保持其最大值会发生什么? 嗯,这是未定义的行为,所以试图推理它是无稽之谈 它,因为任何事情都可能发生,标准没有要求。

现在我们有足够的信息来更好地检查你的错误 程序。我们已经知道访问空指针是未定义的 行为,以及在运行时导致有趣行为的原因。 因此,让我们尝试理解为什么Clang(或技术上的LLVM)进行了优化 程序就像它一样。

static void (*foo)() = nullptr;

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

void set_foo()
{
  foo = never_called;
}

int main()
{
  foo();
}

请记住,可以在set_foo条目之前调用main 开始执行。例如,当顶层声明变量时, 您可以在初始化该变量的值时调用它:

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

如果您在main之前编写此代码段,则程序编号为 更长时间展示未定义的行为,并且消息&#34;格式化很难 显示磁盘驱动器!&#34; ,优化开启或关闭。

那么这个程序有效的唯一方法是什么?这是set_foonever_called的地址分配给foo的函数,我们可能会这样做 在这找到一些东西请注意,foo标记为static,这意味着它 有内部联系,无法从此翻译外部访问 单元。相反,函数set_foo具有外部链接,并且可以 从外面访问。如果另一个翻译单元包含一个片段 如上所述,那么这个程序就有效了。

很酷,但没有人从外面打电话给set_foo。即使这样 事实上,优化器看到了这个程序的唯一方法 如果在set_foo之前调用main,则有效,否则为有效 只是未定义的行为。这是一个新的学到的事实,并假设set_foo 实际上是被称为。基于那些新知识,其他优化 踢进去可能会利用它。

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

Dead code eliminationinterprocedural optimization可能会发现如果foo == never_called,则set_foo内的代码不需要, 所以它被转换成一条ret指令。

Inline expansion优化 看到foo == never_called,因此可以替换对foo的调用 用它的身体。最后,我们最终会得到这样的结论:

set_foo():
        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!"
never_called():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
set_foo():
        mov     QWORD PTR foo[rip], OFFSET FLAT:never_called()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret

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

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

我建议你阅读What every C programmer should know about Undefined BehaviorA Guide to Undefined Behavior in C and C++,这两篇文章都非常有用,可以帮助你理解艺术现状。

答案 1 :(得分:0)

除非实现指定尝试调用空函数指针的效果,否则它可以表现为对任意代码的调用。这样的任意代码可以很好地表现得像对函数的调用&#34; foo()&#34;。虽然C标准的附件L将邀请实施来区分&#34;关键UB&#34;和&#34;非关键的UB&#34;,以及一些C ++实现可能会应用类似的区别,在任何情况下调用无效的函数指针都将是关键的UB。

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

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

在后一种情况下,编译器不声称是可分析的&#34;可能会认识到,如果q在执行到达return语句时大于46,340,则会调用代码,因此它也可以无条件地调用do_something()。虽然附件L编写得很糟糕,但似乎打算禁止这样的优化&#34;。但是,在调用无效函数指针的情况下,即使在大多数平台上直接生成的代码也可能有任意行为。