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

问题描述 投票:24回答:2

如果从未在代码中调用过以下程序如何调用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.
c++ g++ compiler-optimization undefined-behavior clang++
2个回答
36
投票

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

在运行时执行never_called是一个完美的有效情况,当未定义的行为被击中时,它就像崩溃一样有效(就像用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指向的函数,并且当用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])的行为。 - 结束说明]

强调我的。

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

注意:对于无符号整数,不会发生同样的优化,因为溢出的不是UB。也就是说,编译器需要保持表达式不变,因为它在溢出时可能有不同的评估(unsigned是模块2N,其中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之前编写此代码段,程序将不再显示未定义的行为,并显示消息“格式化硬盘驱动器!”显示,优化开启或关闭。

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

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

例如,当应用constant folding时,它会看到构造foo()仅在foo可以正确初始化时才有效。发生这种情况的唯一方法是在这个翻译单元之外调用set_foo,所以foo = 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++,两篇文章系列都非常有用,可能会帮助你了解艺术现状。


0
投票

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

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

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

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

© www.soinside.com 2019 - 2024. All rights reserved.