g++ 带有悬空引用的不完整警告行为

g++ incomplete warning behaviour with dangling references

我们看到 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 中,我既没有收到警告也没有收到段错误,并且 returns 意外地得到了正确的值。

为什么B没有警告?

目前已在 2 台机器上测试。

这不是关于什么是悬空引用的问题,而是关于警告和扩展编译器行为的问题! 这是未定义的行为。是的,程序 理论上可以做任何事情,甚至可以炸毁世界或实际工作。 “这是未定义的行为”不是一个令人满意的答案,因为它只回答了程序能够做什么,而不是为什么编译器在示例 B 中甚至没有检测到这一点。

因此这不是 this question 的副本。

程序在示例 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

B ... returns the correct value of a unexpectedly.

由于程序的行为是未定义的,所以没有任何行为是意外的。

此外,返回的任何值都不“正确”。简直就是垃圾

I am surprised by the compiler detecting the dangling reference whilst only ...

编译器几乎不可能检测到所有通过无效引用的间接寻址。因此,一定存在编译器检测不到的复杂点。您已经在该比喻性“点”的不同侧面找到了两个示例。不清楚为什么这让您感到惊讶。

Maybe anyone here as any insight as to why B is harder to detect than A.

比较复杂。返回的引用不是直接从本地对象初始化的,而是从理论上可以引用非本地对象的另一个引用初始化的。直到分析了那个中间引用的初始化程序,我们才可能发现它确实引用了一个本地对象。


所以,C++的观点被“It's UB”彻底回答了。也许您可能想知道为什么生成的汇编程序表现不同。

mov     eax, 0

这仅仅是因为案例 A 生成的程序 returns 内存值为 0,即 null。地址 0 处的内存当然不会映射为您的进程可以访问的内容,因此当程序尝试读取该内存时,操作系统会发出 SEGFAULT 信号。

 mov     rax, QWORD PTR [rbp-8]

另一方面,B 程序 returns 指向堆栈的指针。由于该地址已映射到进程,因此操作系统没有理由发出信号。


值得一提的是,GCC 确实会检测到错误并在启用优化后为不同的函数生成相同的程序集。