GNU C 编译器破坏未定义的行为
GNU C compiler sabotages undefined behaviour
我有一个嵌入式项目,需要在某个时候写入地址 0。所以我很自然地尝试:
*(int*)0 = 0 ;
但是在优化级别 2 或更高级别时,gcc 编译器会搓着手说,实际上,“这是未定义的行为!我可以做我喜欢的事!哇哈哈!”并向代码流发出无效指令!
这是我的源文件:
void f (void)
{
*(int*)0 = 0 ;
}
这是输出清单:
.file "bug.c"
.text
.p2align 4,,15
.globl _f
.def _f; .scl 2; .type 32; .endef
_f:
LFB0:
.cfi_startproc
movl [=12=], 0
ud2 <-- Invalid instruction!
.cfi_endproc
LFE0:
.ident "GCC: (i686-posix-dwarf-rev0, Built by MinGW-W64 project) 7.3.0"
我的问题是:为什么会有人这样做?像这样破坏代码可能会带来什么好处?显而易见的做法当然是发出警告并继续编译?
我知道编译器允许这样做,我只是想知道编译器作者的动机。我花了两天时间和四个工程样本来追踪这个,所以我有点生气。
编辑添加: 我已经通过使用汇编语言解决了这个问题。所以我不是在寻找解决方案。我很好奇为什么有人会认为这种编译器行为是个好主意。
(免责声明:我不是 GCC 内部专家,这更像是一种“post 临时”尝试解释其行为。但也许它会有所帮助。)
the gcc compiler rubs its hands and says, in effect, "That is undefined behaviour! I can do what I like! Bwahaha!" and emits an invalid instruction to the code stream!
我不否认在某些情况下 GCC 或多或少会这样做,但这里还有更多的事情要做,并且有一些方法可以解决它的疯狂问题。
据我了解,GCC 并未将 null 取消引用视为此处完全未定义;它正在对其所做的事情做出一些假设。它对空取消引用的处理由一个名为 -fdelete-null-pointer-checks
的标志控制,当您打开优化时,该标志可能默认启用。来自 manual:
-fdelete-null-pointer-checks
Assume that programs cannot safely dereference null pointers, and that no code or data element resides at address zero. This option
enables simple constant folding optimizations at all optimization
levels. In addition, other optimization passes in GCC use this flag to
control global dataflow analyses that eliminate useless checks for
null pointers; these assume that a memory access to address zero
always results in a trap, so that if a pointer is checked after it has
already been dereferenced, it cannot be null.
Note however that in some environments this assumption is not true. Use -fno-delete-null-pointer-checks to disable this optimization
for programs that depend on that behavior.
This option is enabled by default on most targets. On Nios II ELF, it defaults to off. On AVR, CR16, and MSP430, this option is
completely disabled.
Passes that use the dataflow information are enabled independently at different optimization levels.
因此,如果您打算实际访问地址 0,或者如果出于某些其他原因您的代码将在取消引用后继续执行,那么您需要使用 -fno-delete-null-pointer-checks
禁用它。这将实现您想要的“继续编译”部分。但是,它不会给你警告,大概是在这样的取消引用是有意的假设下。
但是在默认选项下,为什么您会看到您生成的代码带有未定义的指令,为什么没有警告?我猜 GCC 的逻辑是 运行 如下:
因为 -fdelete-null-pointer-checks
有效,编译器假定执行不会继续通过 null 取消引用,而是会陷入陷阱。它不知道如何处理陷阱:可能是程序终止,可能是信号或异常处理程序,可能是 longjmp
堆栈。空解引用本身是按要求发出的,可能是在假设您有意使用陷阱处理程序的情况下发出的。但无论哪种方式,现在都无法访问 null 取消引用之后的任何代码。
所以现在它做了任何合理的优化编译器对无法访问的代码所做的事情:它不发出它。在你的例子中,那只不过是一个 ret
,但不管它是什么,就 GCC 而言,它只会浪费内存字节,应该被省略。
您可能认为您应该在此处收到警告,但 GCC 有一个长期的设计决定,即不对无法访问的代码发出警告,理由是此类警告往往不一致,误报弊大于利。例如参见 [=29=].
但是,作为一项安全功能,GCC 发出未定义的指令(ud2
在 x86 上)来代替省略的无法访问的代码。我认为,这个想法是为了以防万一执行以某种方式继续通过空取消引用,程序最好死掉,而不是陷入杂草并尝试执行接下来碰巧出现的任何内存内容。 (事实上 ,即使在取消映射零页的系统上也会发生这种情况;例如,如果您执行 struct huge *p = NULL; p->x = 0;
,GCC 将其理解为空取消引用,即使 p->x
可能不在零页上完全没有,并且可以想象地位于可访问的地址。)
有一个警告标志,-Wnull-dereference
,它会在您明目张胆的 null 取消引用时触发警告。但是,它仅在启用 -fdelete-null-pointer-checks
时有效。
GCC 的行为什么时候有用?这是一个例子,可能是人为的,但它可能会传达这个想法。假设您的程序有一些可能会失败的分配函数:
struct foo *p = get_foo();
// do other stuff for a while
if (!p) {
// 5000 lines of elaborate backup plan in case we can't get a foo
}
frob(p->bar);
现在假设您重新设计 get_foo()
以使其不会失败。您忘记取出“备份计划”代码,但您继续并立即使用 returned 对象:
struct foo *p = get_foo();
frob(p->bar);
// do other stuff for a while
if (!p) {
// 5000 lines of elaborate backup plan in case we can't get a foo
}
编译器不知道,先验,get_foo()
总是return 一个有效的指针。但它可以看到您已经取消引用它,因此可以假设如果指针不为空,执行只会继续超过该点。因此,它可以告诉您精心设计的备份计划是无法实现的,应该被省略,这将为您的二进制文件节省大量的膨胀。
顺带一提,clang的情况。尽管正如 Eric Postpischil 指出的那样,您确实收到了警告,但您没有得到的是来自地址 0 的实际负载:clang 忽略了它,只是发出 ud2
。这就是“为所欲为”的真正样子,如果您希望练习您的页面零陷阱处理程序,那您就倒霉了。
在描述未定义行为时,标准将其称为“在使用不可移植或错误的程序构造或错误数据时”产生的结果,并且标准的作者在已发布的基本原理中更清楚地阐明了他们的意图: “未定义的行为允许实施者不捕获某些难以诊断的程序错误。它还确定了可能符合语言扩展的区域:实施者可以通过提供官方未定义行为的定义来扩充语言。”何时以这种方式扩展语言的问题——将各种形式的 UB 视为不可移植但正确,作为实施质量问题留在标准管辖范围之外。
clang 和 gcc 的维护者认为短语“不可移植或错误”应解释为“错误”的同义词,因为标准不会禁止这种解释。如果编译器永远不会被用来处理永远不会被提供错误数据的不可移植程序,那么这样的解释有时会允许他们处理一些严格符合的程序,这些程序比其他方式更快地提供完全有效的数据,在使它们不太适合其他用途的费用。我个人认为编译器可以合理有效地处理的程序范围是比编译器可以处理严格符合程序的效率更好的质量指标,但是出于不同目的使用编译器的人可能有不同的看法关于什么会使编译器或多或少地用于这些目的。
我有一个嵌入式项目,需要在某个时候写入地址 0。所以我很自然地尝试:
*(int*)0 = 0 ;
但是在优化级别 2 或更高级别时,gcc 编译器会搓着手说,实际上,“这是未定义的行为!我可以做我喜欢的事!哇哈哈!”并向代码流发出无效指令!
这是我的源文件:
void f (void)
{
*(int*)0 = 0 ;
}
这是输出清单:
.file "bug.c"
.text
.p2align 4,,15
.globl _f
.def _f; .scl 2; .type 32; .endef
_f:
LFB0:
.cfi_startproc
movl [=12=], 0
ud2 <-- Invalid instruction!
.cfi_endproc
LFE0:
.ident "GCC: (i686-posix-dwarf-rev0, Built by MinGW-W64 project) 7.3.0"
我的问题是:为什么会有人这样做?像这样破坏代码可能会带来什么好处?显而易见的做法当然是发出警告并继续编译?
我知道编译器允许这样做,我只是想知道编译器作者的动机。我花了两天时间和四个工程样本来追踪这个,所以我有点生气。
编辑添加: 我已经通过使用汇编语言解决了这个问题。所以我不是在寻找解决方案。我很好奇为什么有人会认为这种编译器行为是个好主意。
(免责声明:我不是 GCC 内部专家,这更像是一种“post 临时”尝试解释其行为。但也许它会有所帮助。)
the gcc compiler rubs its hands and says, in effect, "That is undefined behaviour! I can do what I like! Bwahaha!" and emits an invalid instruction to the code stream!
我不否认在某些情况下 GCC 或多或少会这样做,但这里还有更多的事情要做,并且有一些方法可以解决它的疯狂问题。
据我了解,GCC 并未将 null 取消引用视为此处完全未定义;它正在对其所做的事情做出一些假设。它对空取消引用的处理由一个名为 -fdelete-null-pointer-checks
的标志控制,当您打开优化时,该标志可能默认启用。来自 manual:
-fdelete-null-pointer-checks
Assume that programs cannot safely dereference null pointers, and that no code or data element resides at address zero. This option enables simple constant folding optimizations at all optimization levels. In addition, other optimization passes in GCC use this flag to control global dataflow analyses that eliminate useless checks for null pointers; these assume that a memory access to address zero always results in a trap, so that if a pointer is checked after it has already been dereferenced, it cannot be null.
Note however that in some environments this assumption is not true. Use -fno-delete-null-pointer-checks to disable this optimization for programs that depend on that behavior.
This option is enabled by default on most targets. On Nios II ELF, it defaults to off. On AVR, CR16, and MSP430, this option is completely disabled.
Passes that use the dataflow information are enabled independently at different optimization levels.
因此,如果您打算实际访问地址 0,或者如果出于某些其他原因您的代码将在取消引用后继续执行,那么您需要使用 -fno-delete-null-pointer-checks
禁用它。这将实现您想要的“继续编译”部分。但是,它不会给你警告,大概是在这样的取消引用是有意的假设下。
但是在默认选项下,为什么您会看到您生成的代码带有未定义的指令,为什么没有警告?我猜 GCC 的逻辑是 运行 如下:
因为
-fdelete-null-pointer-checks
有效,编译器假定执行不会继续通过 null 取消引用,而是会陷入陷阱。它不知道如何处理陷阱:可能是程序终止,可能是信号或异常处理程序,可能是longjmp
堆栈。空解引用本身是按要求发出的,可能是在假设您有意使用陷阱处理程序的情况下发出的。但无论哪种方式,现在都无法访问 null 取消引用之后的任何代码。所以现在它做了任何合理的优化编译器对无法访问的代码所做的事情:它不发出它。在你的例子中,那只不过是一个
ret
,但不管它是什么,就 GCC 而言,它只会浪费内存字节,应该被省略。您可能认为您应该在此处收到警告,但 GCC 有一个长期的设计决定,即不对无法访问的代码发出警告,理由是此类警告往往不一致,误报弊大于利。例如参见 [=29=].
但是,作为一项安全功能,GCC 发出未定义的指令(
ud2
在 x86 上)来代替省略的无法访问的代码。我认为,这个想法是为了以防万一执行以某种方式继续通过空取消引用,程序最好死掉,而不是陷入杂草并尝试执行接下来碰巧出现的任何内存内容。 (事实上 ,即使在取消映射零页的系统上也会发生这种情况;例如,如果您执行struct huge *p = NULL; p->x = 0;
,GCC 将其理解为空取消引用,即使p->x
可能不在零页上完全没有,并且可以想象地位于可访问的地址。)
有一个警告标志,-Wnull-dereference
,它会在您明目张胆的 null 取消引用时触发警告。但是,它仅在启用 -fdelete-null-pointer-checks
时有效。
GCC 的行为什么时候有用?这是一个例子,可能是人为的,但它可能会传达这个想法。假设您的程序有一些可能会失败的分配函数:
struct foo *p = get_foo();
// do other stuff for a while
if (!p) {
// 5000 lines of elaborate backup plan in case we can't get a foo
}
frob(p->bar);
现在假设您重新设计 get_foo()
以使其不会失败。您忘记取出“备份计划”代码,但您继续并立即使用 returned 对象:
struct foo *p = get_foo();
frob(p->bar);
// do other stuff for a while
if (!p) {
// 5000 lines of elaborate backup plan in case we can't get a foo
}
编译器不知道,先验,get_foo()
总是return 一个有效的指针。但它可以看到您已经取消引用它,因此可以假设如果指针不为空,执行只会继续超过该点。因此,它可以告诉您精心设计的备份计划是无法实现的,应该被省略,这将为您的二进制文件节省大量的膨胀。
顺带一提,clang的情况。尽管正如 Eric Postpischil 指出的那样,您确实收到了警告,但您没有得到的是来自地址 0 的实际负载:clang 忽略了它,只是发出 ud2
。这就是“为所欲为”的真正样子,如果您希望练习您的页面零陷阱处理程序,那您就倒霉了。
在描述未定义行为时,标准将其称为“在使用不可移植或错误的程序构造或错误数据时”产生的结果,并且标准的作者在已发布的基本原理中更清楚地阐明了他们的意图: “未定义的行为允许实施者不捕获某些难以诊断的程序错误。它还确定了可能符合语言扩展的区域:实施者可以通过提供官方未定义行为的定义来扩充语言。”何时以这种方式扩展语言的问题——将各种形式的 UB 视为不可移植但正确,作为实施质量问题留在标准管辖范围之外。
clang 和 gcc 的维护者认为短语“不可移植或错误”应解释为“错误”的同义词,因为标准不会禁止这种解释。如果编译器永远不会被用来处理永远不会被提供错误数据的不可移植程序,那么这样的解释有时会允许他们处理一些严格符合的程序,这些程序比其他方式更快地提供完全有效的数据,在使它们不太适合其他用途的费用。我个人认为编译器可以合理有效地处理的程序范围是比编译器可以处理严格符合程序的效率更好的质量指标,但是出于不同目的使用编译器的人可能有不同的看法关于什么会使编译器或多或少地用于这些目的。