为什么GCC 8会在这个std::string示例中导致内存被重复释放?

编程语言 2026-07-11

以下测试代码在与 g++ test.cpp -O2 -std=c++1z -fnon-call-exceptions 一起运行时,在gcc 8.5或更低版本会导致双重释放错误。在其他如9.1或更高版本的GCC中则不会。

请在 https://godbolt.org/z/jfMrG113M 上对8.5与 9.1进行对比。

#include <iostream>

namespace MyClass {
    void modify(std::string &stream) {
        stream = stream.substr(5);
        std::cerr << "Checkpoint.\n";
        throw std::runtime_error("boom");
    }

    void run() {
        std::string stream = "aaa[ERASE]aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";   // at least 40 chars
        size_t pos = 3;
        size_t end = 4;
        stream.erase(pos, end - pos);
        modify(stream);
    }
};

int main(void) {
    try {
        MyClass::run();
        return 0;
    }
    catch (...) {
        exit(1);
    }
}
free(): double free detected in tcache 2
Aborted (core dumped)

因此,以下前提条件似乎都必不可少,才能重现该问题:

  • 某些GCC版本,例如8.5或更低,在9.1或更高版本中不会出现
  • 编译选项 -O2-std=c++1z(或 -std=c++17)、-fnon-call-exceptions
  • main 中使用 try / catch
  • std::erase
  • std::string 以非常量引用传递
  • 通过 stream = stream.substr(...) 就地修改字符串
  • 若改为在这里使用 stream.erase()stream.replace(),则不会发生
  • 或者一个 tempStr = stream.substr(...),然后再 stream = tempStr
  • 抛出异常

这是否可能与在该GCC版本上对拷贝消除的处理方式不同有关?

如果这是8.5上的一个bug,在9.1或更高版本中已修复,并且你知道该修复,那么我们是否可以确定从9.1开始,对以非常量引用传入的字符串使用 std::string::substr,在一般情况下都不会导致双重释放(除了显然错误的用法之外)?因为在这些较低版本中,这很可能不仅仅是唯一的复现方式。

解决方案

正如注释所指出的,这很可能是一个编译器缺陷。我在本地Linux系统上,使用gcc 8.5,通过valgrind对来自 https://godbolt.org/z/sGWWbc7WG 的源码进行编译,得到了以下输出:

==48== Invalid free() / delete / delete[] / realloc()
==48==    at 0x484C3B4: operator delete(void*) (vg_replace_malloc.c:1131)
==48==    by 0x401711: deallocate (new_allocator.h:125)
==48==    by 0x401711: deallocate (alloc_traits.h:462)
==48==    by 0x401711: _M_destroy (basic_string.h:230)
==48==    by 0x401711: _M_dispose (basic_string.h:225)
==48==    by 0x401711: ~basic_string (basic_string.h:661)
==48==    by 0x401711: MyClass::run() (example.cpp:16)
==48==    by 0x4011AD: main (example.cpp:27)
==48==  Address 0x4e54080 is 0 bytes inside a block of size 41 free'd
==48==    at 0x484C3B4: operator delete(void*) (vg_replace_malloc.c:1131)
==48==    by 0x4014D1: deallocate (new_allocator.h:125)
==48==    by 0x4014D1: deallocate (alloc_traits.h:462)
==48==    by 0x4014D1: _M_destroy (basic_string.h:230)
==48==    by 0x4014D1: _M_dispose (basic_string.h:225)
==48==    by 0x4014D1: ~basic_string (basic_string.h:661)
==48==    by 0x4014D1: MyClass::shrinkAndCopy(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&) (example.cpp:7)
==48==    by 0x4015F3: MyClass::modify(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&) (example.cpp:11)
==48==    by 0x4016CC: MyClass::run() (example.cpp:20)
==48==    by 0x4011AD: main (example.cpp:27)
==48==  Block was alloc'd at
==48==    at 0x4848F93: operator new(unsigned long) (vg_replace_malloc.c:487)
==48==    by 0x40137E: void std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_construct<char const*>(char const*, char const*, std::forward_iterator_tag) (basic_string.tcc:219)
==48==    by 0x401673: _M_construct_aux<char const*> (basic_string.h:240)
==48==    by 0x401673: _M_construct<char const*> (basic_string.h:259)
==48==    by 0x401673: basic_string<> (basic_string.h:520)
==48==    by 0x401673: MyClass::run() (example.cpp:16)
==48==    by 0x4011AD: main (example.cpp:27)

这条回溯清楚地表明,在 run 中创建的字符串的原始缓冲区被释放了两次。第一次释放发生在 shrinkAndCopy 内部,第二次释放发生在异常展开并运行时。调试信息把对再次释放缓冲区的字符串析构函数的调用,和局部变量 stream 的声明(第16行)联系起来。

原始的41字符缓冲区在shrinkAndCopy内部被释放是有道理的,因为 substr 创建了一个新字符串,随后会被移动赋值给 stream,因此 stream 现在应该管理新缓冲区,旧缓冲区已处于无效状态。展开期间,应该释放新缓冲区,而不是再次释放旧缓冲区。

似乎 run 中的异常展开代码并没有对它应有的 stream 的状态起作用。

如果你查看compiler explorer上已编译并链接的代码(在 https://godbolt.org/z/8Mvr3TbYY 的配置中),请观察 [rsp](其中包含指向字符串缓冲区的指针)如何在第401行被加载到寄存器 rbx,GCC将该值称为 _10。展开/异常处理代码从第422行开始,使用 rbx,再次命名为 _10,因此这应该是在调用run之前加载到 rbx 的值。这最终成为 delete[] 的参数。这显然是错误的,因为 stream._M_dataplus 已经被 run 修改了。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章