Már*_*ldi 24 c++ g++ compiler-optimization undefined-behavior clang++
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();
}
Run Code Online (Sandbox Code Playgroud)
这与编译器不同.通过优化启用Clang进行编译,该函数never_called在运行时执行.
$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!
Run Code Online (Sandbox Code Playgroud)
但是,使用GCC进行编译时,此代码只会崩溃:
$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)
Run Code Online (Sandbox Code Playgroud)
编译器版本:
$ 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.
Run Code Online (Sandbox Code Playgroud)
Már*_*ldi 36
该程序包含未定义的行为,因为取消引用空指针(即foo()在不事先为其分配有效地址的情况下调用main)是UB,因此标准不强制要求.
format_disk在运行时执行是一个完美的有效情况,当未定义的行为被击中时,它就像崩溃一样有效(就像用GCC编译时一样).好的,但为什么Clang这样做?如果你在优化关闭的情况下编译它,程序将不再输出"格式化硬盘驱动器",并且只会崩溃:
$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)
Run Code Online (Sandbox Code Playgroud)
生成的此版本代码如下:
main: # @main
push rbp
mov rbp, rsp
call qword ptr [foo]
xor eax, eax
pop rbp
ret
Run Code Online (Sandbox Code Playgroud)
它试图调用一个函数到哪个foo点,并且foo
初始化为nullptr(或者如果它没有任何初始化,这仍然是这种情况),它的值为零.在这里,未定义的行为已被击中,因此任何事情都可能发生,并且程序变得无用.通常,调用这样的无效地址会导致分段错误错误,因此我们在执行程序时会得到消息.
现在让我们检查一下相同的程序,但是在优化的基础上编译它:
$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!
Run Code Online (Sandbox Code Playgroud)
生成的此版本代码如下:
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!"
Run Code Online (Sandbox Code Playgroud)
有趣的是,某种方式的优化修改了程序,以便
直接main调用std::puts.但为什么Clang这样做呢?为什么
never_called编译成单个ret指令?
让我们暂时回到标准(N4660,具体而言).它对未定义的行为有什么看法?
3.27未定义的行为[defns.undefined]
本文档没有要求的行为
[注意:当本文档省略任何明确的行为定义或程序使用错误的构造或错误数据时,可能会出现未定义的行为.允许的未定义行为包括完全忽略不可预测的结果, 在翻译或程序执行期间以环境特征(有或没有发出诊断消息)的特定行为,终止翻译或执行(发布时)一条诊断信息).许多错误的程序结构不会产生未定义的行为; 他们需要被诊断出来.对常量表达式的求值从未表现出明确指定为undefined([expr.const])的行为. - 结束说明]
强调我的.
展示未定义行为的程序变得毫无用处,因为它迄今为止所做的一切并且如果它包含错误的数据或构造,它将进一步发挥作用.考虑到这一点,请记住编译器可能会完全忽略未命中行为被击中的情况,并且这实际上在优化程序时用作已发现的事实.例如,类似x + 1 > x(其中x是有符号整数)的构造将被编译为true,即使true在编译时未知值也是如此.原因是编译器想要针对有效情况进行优化,并且该构造有效的唯一方法是它是否不会触发算术溢出(即if x).这是优化器中一个新的学习事实.基于此,该结构被证明永远是真实的.
注意:对于无符号整数,不会发生同样的优化,因为溢出的不是UB.也就是说,编译器需要保持表达式不变,因为它在溢出时可能有不同的评估(unsigned是模块2 N,其中N是位数).对无符号整数进行优化将不符合标准(感谢aschepler.)
这很有用,因为它允许大量优化.到目前为止,这么好,但如果x != std::numeric_limits<decltype(x)>::max()在运行时保持其最大值会发生什么?嗯,这是未定义的行为,所以试图推理它是无稽之谈,因为任何事情都可能发生,标准没有要求.
现在我们有足够的信息来更好地检查你的错误程序.我们已经知道访问空指针是未定义的行为,这就是在运行时导致有趣行为的原因.因此,让我们试着理解为什么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();
}
Run Code Online (Sandbox Code Playgroud)
请记住,可以x在never_called条目开始执行之前调用.例如,当顶层声明变量时,可以在初始化该变量的值时调用它:
void never_called();
int x = (never_called(), 42);
Run Code Online (Sandbox Code Playgroud)
如果您之前编写此代码段main,程序将不再显示未定义的行为,并显示消息"格式化硬盘驱动器!" 显示,优化开启或关闭.
那么这个程序有效的唯一方法是什么?这个never_caled
函数分配了format_diskto 的地址foo,所以我们可以在这里找到一些东西.请注意,foo标记为static,这意味着它具有内部链接,无法从此翻译单元外部访问.相反,该功能never_called具有外部链接,可以从外部访问.如果另一个翻译单元包含如上所述的片段,则该程序将变为有效.
很酷,但没有人never_called从外面打电话.即使这是事实,优化器也会看到此程序有效的唯一方法是if never_called之前调用main,否则它只是未定义的行为.这是一个新的学习事实,它假定never_called
实际上是被称为.基于这些新知识,其他优化可能会利用它.
例如,当应用常量折叠时,它会看到构造foo()只有在foo可以正确初始化时才有效.发生这种情况的唯一方法never_called是在此翻译单元之外调用if foo = format_disk.
死代码消除和过程间优化可能会发现如果foo == format_disk,那么内部的代码never_called是不需要的,所以它被转换为单个ret指令.
内联扩展优化可以看到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!"
Run Code Online (Sandbox Code Playgroud)
这有点等同于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
Run Code Online (Sandbox Code Playgroud)
执行该程序会导致崩溃(分段错误),但如果never_called在执行main之前调用另一个转换单元,则该程序不再显示未定义的行为.
随着越来越多的优化被设计,所有这些都可以疯狂地改变,所以不要依赖于编译器将处理包含未定义行为的代码的假设,它可能只是让你搞砸了(并且真实地格式化你的硬盘! )
我建议你阅读每个C程序员应该知道的关于未定义行为和C和C++中未定义行为指南的内容,这两篇文章都非常有用,可以帮助你理解最新技术.