未在代码中调用的函数在运行时被调用
Function not called in code gets called at runtime
如果下面的程序从不调用 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.
程序包含未定义的行为,如取消引用空指针
(即在 main 中调用 foo()
而未为其分配有效地址
之前)是UB,因此标准没有强加要求。
在运行时执行 format_disk
是一个完美的有效情况
未定义的行为已被击中,它与崩溃一样有效(比如
当用 GCC 编译时)。好的,但是为什么 Clang 会那样做呢?如果你
在关闭优化的情况下编译它,程序将不再输出
"formatting hard disk drive",并且会崩溃:
$ 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 undefined behavior [defns.undefined]
behavior for which this document imposes no requirements
[Note: Undefined behavior may be expected when this document omits
any explicit definition of behavior or when a program uses an erroneous
construct or erroneous data. Permissible undefined behavior ranges
from ignoring the situation completely with unpredictable results, to
behaving during translation or program execution in a documented manner
characteristic of the environment (with or without the issuance of a
diagnostic message), to terminating a translation or execution (with the
issuance of a diagnostic message). Many erroneous program constructs
do not engender undefined behavior; they are required to be diagnosed.
Evaluation of a constant expression never exhibits behavior explicitly
specified as undefined ([expr.const]). — end note]
强调我的。
表现出未定义行为的程序变得无用,因为一切
它到目前为止已经完成并且将做进一步没有意义,如果它包含
错误的数据或结构。考虑到这一点,请记住这一点
编译器可能会完全忽略未定义行为的情况
被击中,这实际上在优化一个时被用作发现的事实
程序。例如,像 x + 1 > x
这样的结构(其中 x
是一个带符号的整数)将被优化为一个常量,
true
,即使 x
的值在 compile-time 处未知。推理
是编译器想要针对有效情况进行优化,并且唯一
该构造有效的方法是它不触发算术
溢出(即如果 x != std::numeric_limits<decltype(x)>::max()
)。这个
是优化器中新学到的事实。基于此,构造是
证明总是评估为真。
注意:同样的优化不能发生在无符号整数上,因为溢出一个不是UB。也就是说,编译器需要保持表达式的原样,因为当发生溢出时它可能有不同的评估(无符号是模块 2N,其中 N 是位数)。为无符号整数优化它会不符合标准(感谢 aschepler)。
这很有用,因为它允许进行 大量优化
在。所以
到目前为止,还不错,但是如果 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
开始执行。例如,当声明一个 top-level 变量时,
您可以在初始化该变量的值时调用它:
void never_called();
int x = (never_called(), 42);
如果你在你的程序中写这个片段,程序没有
不再表现出未定义的行为,并且消息 "formatting hard
磁盘驱动器!” 显示,优化打开或关闭。
那么这个程序唯一有效的方法是什么?有这个 never_caled
将 format_disk
的地址分配给 foo
的函数,所以我们可能
在这里找到一些东西。注意foo
被标记为static
,意思是
具有内部链接,无法从该翻译外部访问
单元。相比之下,函数 never_called
具有外部链接,并且可能
可以从外部访问。如果另一个翻译单元包含一个片段
像上面那个,那么这个程序就生效了。
很好,但是没有人从外面打电话给 never_called
。尽管这
事实上,优化器认为这个程序的唯一方法是
如果 never_called
在 main
执行之前调用,则有效,否则
只是未定义的行为。这是一个新发现的事实,所以编译器假定 never_called
实际上被称为。基于这些新知识,其他优化
踢进来可以利用它。
例如,当常量
折叠 是
应用后,它发现构造 foo()
仅在 foo
可以正确初始化时才有效。唯一的办法或者如果 never_called
在这个翻译单元之外被调用,那么 foo = format_disk
.
Dead code elimination and interprocedural optimization可能会发现,如果foo == format_disk
,那么never_called
里面的代码就不需要了,
因此函数的 body 被转换为单个 ret
指令。
Inline expansion优化
看到 foo == format_disk
,所以可以替换对 foo
的调用
及其 body。最后,我们得到这样的结果:
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 and A Guide to Undefined Behavior in C and C++,这两个系列文章都提供了很多信息,可能会帮助您了解最新技术。
除非实现指定尝试调用空函数指针的效果,否则它可能表现为对任意代码的调用。这样的任意代码可以完美地表现得像对函数 "foo()" 的调用。虽然 C 标准的附件 L 会要求实现区分 "Critical UB" 和 "non-critical UB",并且某些 C++ 实现可能会应用类似的区别,但在任何情况下调用无效函数指针都是关键的 UB。
请注意,这个问题的情况与例如
有很大不同
unsigned short q;
unsigned hey(void)
{
if (q < 50000)
do_something();
return q*q;
}
在后一种情况下,未声明为 "analyzable" 的编译器可能会识别出如果 q 大于 46,340 时代码将在执行到达 return
语句时调用,因此它可能以及无条件调用 do_something()
。虽然附件 L 写得不好,但似乎打算禁止这样的 "optimizations"。然而,在调用无效函数指针的情况下,即使是在大多数平台上直接生成的代码也可能具有任意行为。
如果下面的程序从不调用 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.
程序包含未定义的行为,如取消引用空指针
(即在 main 中调用 foo()
而未为其分配有效地址
之前)是UB,因此标准没有强加要求。
在运行时执行 format_disk
是一个完美的有效情况
未定义的行为已被击中,它与崩溃一样有效(比如
当用 GCC 编译时)。好的,但是为什么 Clang 会那样做呢?如果你
在关闭优化的情况下编译它,程序将不再输出
"formatting hard disk drive",并且会崩溃:
$ 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 undefined behavior [defns.undefined]
behavior for which this document imposes no requirements
[Note: Undefined behavior may be expected when this document omits any explicit definition of behavior or when a program uses an erroneous construct or erroneous data. Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message). Many erroneous program constructs do not engender undefined behavior; they are required to be diagnosed. Evaluation of a constant expression never exhibits behavior explicitly specified as undefined ([expr.const]). — end note]
强调我的。
表现出未定义行为的程序变得无用,因为一切
它到目前为止已经完成并且将做进一步没有意义,如果它包含
错误的数据或结构。考虑到这一点,请记住这一点
编译器可能会完全忽略未定义行为的情况
被击中,这实际上在优化一个时被用作发现的事实
程序。例如,像 x + 1 > x
这样的结构(其中 x
是一个带符号的整数)将被优化为一个常量,
true
,即使 x
的值在 compile-time 处未知。推理
是编译器想要针对有效情况进行优化,并且唯一
该构造有效的方法是它不触发算术
溢出(即如果 x != std::numeric_limits<decltype(x)>::max()
)。这个
是优化器中新学到的事实。基于此,构造是
证明总是评估为真。
注意:同样的优化不能发生在无符号整数上,因为溢出一个不是UB。也就是说,编译器需要保持表达式的原样,因为当发生溢出时它可能有不同的评估(无符号是模块 2N,其中 N 是位数)。为无符号整数优化它会不符合标准(感谢 aschepler)。
这很有用,因为它允许进行 大量优化
在。所以
到目前为止,还不错,但是如果 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
开始执行。例如,当声明一个 top-level 变量时,
您可以在初始化该变量的值时调用它:
void never_called();
int x = (never_called(), 42);
如果你在你的程序中写这个片段,程序没有 不再表现出未定义的行为,并且消息 "formatting hard 磁盘驱动器!” 显示,优化打开或关闭。
那么这个程序唯一有效的方法是什么?有这个 never_caled
将 format_disk
的地址分配给 foo
的函数,所以我们可能
在这里找到一些东西。注意foo
被标记为static
,意思是
具有内部链接,无法从该翻译外部访问
单元。相比之下,函数 never_called
具有外部链接,并且可能
可以从外部访问。如果另一个翻译单元包含一个片段
像上面那个,那么这个程序就生效了。
很好,但是没有人从外面打电话给 never_called
。尽管这
事实上,优化器认为这个程序的唯一方法是
如果 never_called
在 main
执行之前调用,则有效,否则
只是未定义的行为。这是一个新发现的事实,所以编译器假定 never_called
实际上被称为。基于这些新知识,其他优化
踢进来可以利用它。
例如,当常量
折叠 是
应用后,它发现构造 foo()
仅在 foo
可以正确初始化时才有效。唯一的办法或者如果 never_called
在这个翻译单元之外被调用,那么 foo = format_disk
.
Dead code elimination and interprocedural optimization可能会发现,如果foo == format_disk
,那么never_called
里面的代码就不需要了,
因此函数的 body 被转换为单个 ret
指令。
Inline expansion优化
看到 foo == format_disk
,所以可以替换对 foo
的调用
及其 body。最后,我们得到这样的结果:
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 and A Guide to Undefined Behavior in C and C++,这两个系列文章都提供了很多信息,可能会帮助您了解最新技术。
除非实现指定尝试调用空函数指针的效果,否则它可能表现为对任意代码的调用。这样的任意代码可以完美地表现得像对函数 "foo()" 的调用。虽然 C 标准的附件 L 会要求实现区分 "Critical UB" 和 "non-critical UB",并且某些 C++ 实现可能会应用类似的区别,但在任何情况下调用无效函数指针都是关键的 UB。
请注意,这个问题的情况与例如
有很大不同unsigned short q;
unsigned hey(void)
{
if (q < 50000)
do_something();
return q*q;
}
在后一种情况下,未声明为 "analyzable" 的编译器可能会识别出如果 q 大于 46,340 时代码将在执行到达 return
语句时调用,因此它可能以及无条件调用 do_something()
。虽然附件 L 写得不好,但似乎打算禁止这样的 "optimizations"。然而,在调用无效函数指针的情况下,即使是在大多数平台上直接生成的代码也可能具有任意行为。