程序包含未定义的行为,如取消引用空指针
(即在 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 指向的函数,并作为foo
用nullptr 初始化(或者如果它没有任何初始化,
这仍然是这种情况),它的值为零。这里,未定义
行为受到打击,所以任何事情都可能发生,程序
变得无用。通常,调用此类无效地址
导致分段错误错误,因此我们得到的消息是
执行程序。
现在让我们检查相同的程序,但对其进行了优化:
$ 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 > x (其中x 是有符号整数)这样的构造将被优化为常量,
true,即使 x 的值在编译时是未知的。推理
是编译器想要针对有效情况进行优化,并且唯一的
该构造有效的方法是当它不触发算术时
溢出(即如果x != std::numeric_limits<decltype(x)>::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_caled
将format_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 elimination 和interprocedural 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 Behavior 和A Guide to Undefined Behavior in C and C++,这两篇文章系列都非常丰富,可能会帮助您了解最新技术。