GNU C 编译器破坏未定义的行为

Ton*_*nyK 5 c gcc undefined-behavior

我有一个嵌入式项目,需要在某个时候写入地址 0。所以我很自然地尝试:

*(int*)0 = 0 ;
Run Code Online (Sandbox Code Playgroud)

但是在优化级别 2 或更高级别,gcc 编译器会搓手说,“这是未定义的行为!我可以做我喜欢做的事!哇哈哈!” 并向代码流发出无效指令!

这是我的源文件:

void f (void)
  {
  *(int*)0 = 0 ;
  }
Run Code Online (Sandbox Code Playgroud)

这是输出列表:

    .file   "bug.c"
    .text
    .p2align 4,,15
    .globl  _f
    .def    _f; .scl    2;  .type   32; .endef
_f:
LFB0:
    .cfi_startproc
    movl    $0, 0
    ud2                <-- Invalid instruction!
    .cfi_endproc
LFE0:
    .ident  "GCC: (i686-posix-dwarf-rev0, Built by MinGW-W64 project) 7.3.0"
Run Code Online (Sandbox Code Playgroud)

我的问题是:为什么有人会这样做?像这样破坏代码可能会带来什么好处?当然,显而易见的做法是发出警告并继续编译?

我知道编译器可以这样做,我只是想知道编译器作者的动机。我花了两天时间和四个工程样本来追踪这个问题,所以我有点生气。

编辑添加:我已经通过使用汇编语言解决了这个问题。所以我不是在寻找解决方案。我只是好奇为什么有人会认为这种编译器行为是个好主意。

Nat*_*dge 4

(免责声明:我不是 GCC 内部的专家,这更多的是解释其行为的“事后”尝试。但也许会有所帮助。)

gcc 编译器搓着手,实际上说:“这是未定义的行为!我可以做我喜欢做的事!哇哈哈哈!” 并向代码流发出无效指令!

我不会否认,在某些情况下,GCC 或多或少会这样做,但这里还有更多的事情发生,而且有一些方法可以解决它的疯狂。

据我了解,GCC 并没有将 null 取消引用视为完全未定义;它正在对其所做的事情做出一些假设。它对空取消引用的处理由名为 的标志控制-fdelete-null-pointer-checks,当您打开优化时,该标志可能默认启用。从手册中:

-fdelete-null-pointer-checks

假设程序无法安全地取消引用空指针,并且没有代码或数据元素驻留在地址零处。此选项可以在所有优化级别上实现简单的恒定折叠优化。此外,GCC 中的其他优化过程使用此标志来控制全局数据流分析,从而消除对空指针的无用检查;这些假设对地址零的内存访问总是会导致陷阱,因此如果在取消引用指针后检查指针,则它不能为空。

但请注意,在某些环境中此假设并不成立。使用 -fno-delete-null-pointer-checks 为依赖于该行为的程序禁用此优化。

大多数目标上默认启用此选项。在 Nios II ELF 上,它默认为关闭。在 AVR、CR16 和 MSP430 上,此选项完全禁用。

使用数据流信息的通道在不同的优化级别独立启用。

因此,如果您打算实际访问地址 0,或者由于某些其他原因您的代码将在取消引用后继续执行,那么您需要使用 来禁用它-fno-delete-null-pointer-checks。这将实现您想要的“继续编译”部分。但是,它不会向您发出警告,大概是假设此类取消引用是故意的。


但是在默认选项下,为什么您会看到生成的代码带有未定义的指令,并且为什么没有警告?我猜GCC的逻辑运行如下:

  • 因为-fdelete-null-pointer-checks有效,所以编译器假设执行不会在空取消引用之后继续,而是会陷入陷阱。它不知道如何处理陷阱:也许是程序终止,也许是信号或异常处理程序,也许是longjmp堆栈。空取消引用本身是根据请求发出的,也许是在假设您有意执行陷阱处理程序的情况下发出的。但无论哪种方式,空取消引用之后出现的任何代码现在都无法访问。

  • 所以现在它会执行任何合理的优化编译器对无法访问的代码所做的操作:它不会发出它。在你的例子中,这只不过是 a ret,但无论它是什么,就 GCC 而言,它只是浪费内存字节,应该被省略。

    您可能认为您应该在这里收到警告,但 GCC 长期以来的设计决定是不对无法访问的代码发出警告,因为此类警告往往不一致,而且误报弊大于利。例如,请参见https://gcc.gnu.org/legacy-ml/gcc-help/2011-05/msg00360.html

  • 然而,作为一项安全功能,GCC 会发出未定义的指令(ud2在 x86 上)来代替省略的无法访问的代码。我认为,这个想法是,万一执行确实继续经过空取消引用,程序最好死掉,而不是陷入困境并尝试执行接下来发生的任何内存内容。(事实上​​,即使在取消映射零页的系统上,这种情况也可能发生;例如,如果您这样做struct huge *p = NULL; p->x = 0;,GCC 会将其理解为空取消引用,即使p->x可能根本不在零页上,并且可能位于可访问地址。)

有一个警告标志 ,-Wnull-dereference它将针对您公然的空取消引用触发警告。但是,它仅在-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);
Run Code Online (Sandbox Code Playgroud)

现在想象一下您重新设计get_foo()以使其不会失败。您忘记取出“备份计划”代码,但您继续并立即使用返回的对象:

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
}
Run Code Online (Sandbox Code Playgroud)

编译器事先并不知道它get_foo()总是会返回一个有效的指针。但它可以看到您已经取消引用它,因此可以假设只有在指针不为空时执行才会继续经过该点。因此,它可以判断精心设计的备份计划是无法访问的,应该被省略,这将为您的二进制文件节省大量的空间。


顺便说一下,clang的情况。尽管正如 Eric Postpischil 指出的那样,您确实收到了警告,但您没有收到来自地址 0 的实际负载:clang 忽略它并只发出ud2. 这就是“为所欲为”的真正样子,如果您希望锻炼您的零页陷阱处理程序,那么您就不走运了。