g++对于悬空引用的不完整警告行为

3
我们看到2个例子都存在悬空引用: 例子 A:
int& getref()
{
        int a;
        return a;
}

例子 B:

int& getref()
{
        int a;
        int&b = a;
        return b;
}

我们使用同一个主要的函数来调用它们两个:
int main()
{
        cout << getref() << '\n';
        cout << "- reached end" << std::endl;
        return 0;
}

在示例A中,我得到了编译器警告和读取悬空引用时预期的段错误。在示例B中,既没有警告也没有段错误,并返回了意外的a正确值。
为什么B没有警告?
已在两台机器上进行测试。 - 编译器7.4.0 Ubuntu - 编译器7.5.0 Ubuntu
这不是关于什么是悬空引用的问题,而是关于警告和扩展编译器行为的问题!这是未定义的行为。是的,程序理论上可以做任何事情,甚至可能会使世界爆炸或实际工作。“这是未定义的行为”并不是一个令人满意的答案,因为它只回答了程序能够做什么,而不是为什么编译器甚至没有检测到Example B中的问题。
因此,这不是这个问题的重复。
程序在Example B中似乎没有运行时错误,而在那里也没有警告,这可能只是巧合,也可能不是。
我已经利用Compiler Explorer查看了在g++ 7.5下生成的代码,特别是getref()在汇编中的操作。示例A:
    getref():
    push    rbp
    mov     rbp, rsp
    mov     eax, 0
    pop     rbp
    ret
    

示例 B:

    getref():
    push    rbp
    mov     rbp, rsp
    lea     rax, [rbp-12]
    mov     QWORD PTR [rbp-8], rax
    mov     rax, QWORD PTR [rbp-8]
    pop     rbp
    ret

现在我的汇编语言有点生疏了,但是在例子B中涉及到更多的堆栈内存,这理论上会创建更多悬垂引用的可能性,因此更容易被检测到,因为不太可能被优化。我对编译器在处理寄存器时能够检测到悬垂引用感到惊讶,但在实际内存(如示例B的汇编代码)中未能检测到。

也许有人可以解释为什么B比A更难以检测。

如果感兴趣,这里是示例B的完整汇编代码:

getref():
        push    rbp
        mov     rbp, rsp
        lea     rax, [rbp-12]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        pop     rbp
        ret
.LC0:
        .string "- reached end"
main:
        push    rbp
        mov     rbp, rsp
        call    getref()
        mov     eax, DWORD PTR [rax]
        mov     esi, eax
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
        mov     esi, 10
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char)
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
        mov     esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
        mov     rdi, rax
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))
        mov     eax, 0
        pop     rbp
        ret
__static_initialization_and_destruction_0(int, int):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        cmp     DWORD PTR [rbp-4], 1
        jne     .L7
        cmp     DWORD PTR [rbp-8], 65535
        jne     .L7
        mov     edi, OFFSET FLAT:_ZStL8__ioinit
        call    std::ios_base::Init::Init() [complete object constructor]
        mov     edx, OFFSET FLAT:__dso_handle
        mov     esi, OFFSET FLAT:_ZStL8__ioinit
        mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
        call    __cxa_atexit
.L7:
        nop
        leave
        ret
_GLOBAL__sub_I_getref():
        push    rbp
        mov     rbp, rsp
        mov     esi, 65535
        mov     edi, 1
        call    __static_initialization_and_destruction_0(int, int)
        pop     rbp
        ret

1
"...在读取悬空引用时预期的段错误..." 为什么你会这样预期? - Eljay
1
这基本上是一个QOI问题。请注意,clang确实会发出警告。 - cigien
3
标准中充斥着许多“不需要诊断”的实例。如果不需要,那么你是否会得到诊断就是个未知数了。 - user4581301
1
@Meph 为了明确起见:我并不反对GCC在Debug构建中没有提供与优化构建相同的警告。这并不能改变GCC的运行方式。据我所记得阅读的内容,这可能是由于GCC的某些优化发生在语义/代码分析之前的较早阶段--因此我的猜测是在-O2时引用被合并,并且分析部分捕获到这是一个本地引用。我对不比较未优化的汇编的评论与此观点无直接关系。 - Human-Compiler
1
未经优化的代码生成几乎总是对您编写的代码进行1:1转录。因此,比较两个不同源代码的未经优化汇编通常没有太大意义。当不进行优化时,使用函数本地引用对象的示例会产生不同的汇编,因为GCC为该引用在堆栈上分配存储空间,就像它为指针所做的那样。这通常不是一个有用的比较,因此不值得试图从中得出结论。这就是我所说的全部内容。 - Human-Compiler
显示剩余10条评论
1个回答

3

B ... 返回了一个意外的正确值。

由于程序行为是未定义的,因此不应该出现任何意外行为。

此外,返回的任何值都不是“正确”的。它只是垃圾。

编译器能够检测到悬空引用,但我很惊讶只有在…时才能检测到。

编译器几乎无法检测到所有通过无效引用进行的间接操作。因此,在某个复杂度点上必须存在一些无法检测到的情况。您已经在这个象征性的“点”的两侧找到了两个示例。不清楚为什么这让您感到惊讶。

也许在座的各位对于为什么B比A更难检测有更深入的见解。

它更复杂。返回的引用不是直接从本地对象初始化的,而是另一个引用,理论上可能指向非本地对象。只有在分析了中间引用的初始化器后,我们才可能发现它确实指向了本地对象。


因此,“这是未定义的行为”彻底回答了C++方面的问题。也许你会想知道为什么所生成的汇编程序行为不同。

mov     eax, 0

这是因为案例A生成的程序返回了一个内存值0,即null。地址为0的内存当然没有映射为您的进程可以访问的内容,所以当程序试图读取该内存时,操作系统会引发一个SEGFAULT信号。

 mov     rax, QWORD PTR [rbp-8]

相反,B程序返回一个指向栈的指针。由于该地址被映射到进程中,操作系统没有理由发出信号。


值得一提的是,启用优化时,GCC可以检测到该错误并生成相同的汇编代码。


你为什么感到惊讶还不清楚吗?” 尽管原帖作者并没有尝试,但事实上gcc在这种情况下没有警告,而clang和msvc却有,这至少有点令人惊讶。 - cigien
@cigien 我想期望是高度主观的。我见过足够多的情况,其中一个编译器会发出警告,而另一个则不会,这一点并不让我感到惊讶。也许遇到这个例子会让程序员们有所预料。 - eerorika
是的,这正是我的观点。我同意有一些经验后这并不令人惊讶,但措辞表明OP应该已经知道这一点了。那么换个说法,比如“我知道这可能看起来令人惊讶,但是在一段时间后,您会习惯于UB诊断的不同QOI”。 - cigien
1
@Meph 接受一个答案并不意味着关闭问题。如果您更喜欢后来的答案,随时可以自由更改您接受的答案。 - eerorika
1
@cigien “gcc不会警告,但clang和msvc会,这至少有点令人惊讶”,实际上它确实会发出警告——但您必须在-O2或更高版本。如果我没记错的话,这与GCC在到达代码分析之前执行优化的方式有关,因此可能是在-O2之后引用被折叠,从而允许检测到这一点。 - Human-Compiler
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接