为什么 GCC 8 编译的 std::string 示例会触发 double free?
结论
stream = stream.substr(5); 是合法的 C++ 写法,通过非 const 引用访问 stream 本身也不会导致 double free。结合题目中的版本差异和编译参数,这更像是旧版 GCC 在优化和异常清理过程中生成了错误代码,值得按编译器缺陷排查。
目前没有核实到与该示例准确对应的 GCC 官方缺陷记录和修复提交,因此无法给出确定的修复编号。“GCC 9.1 没有复现”也不足以说明 9.1 及以后在所有情况下都不会出现同类问题,更不能单凭这些现象认定根因是 copy elision。
为什么赋回原字符串是合法的?
对左值 stream 调用 substr(5),会得到一个新的 std::string,再将结果赋给原对象:
stream = stream.substr(5);
substr 返回的是字符串对象,而不是指向原缓冲区的 std::string_view。这一语义可参见 C++ 标准草案的 basic_string::substr 条款。
在 C++17 中,右侧结果可以参与移动赋值。实现必须正确维护两个对象的资源所有权:临时对象的销毁,以及随后原对象在异常展开中的销毁,都应正常完成。标准草案的赋值操作条款也规定了 basic_string 的移动赋值行为。
示例中的 stream 由 run() 持有,其生命周期覆盖 modify(stream) 的调用。引用传参没有在这里产生悬空引用,代码也没有手动释放字符串内存。示例中的 substr(5) 位置同样没有越界。
copy elision 可以改变临时对象的构造过程,但不能让合法程序产生重复释放内存的语义。即使缺陷只在某种临时对象处理方式下触发,仍需进一步定位编译器生成的代码。
为什么异常展开值得重点检查?
调用过程可以简化为:
run() 创建 stream
↓
modify() 将子串赋回 stream
↓
modify() 抛出异常
↓
异常离开 run(),销毁 stream
↓
进入 main() 的 catch
赋值完成后,stream 的内部缓冲区可能已经更换。异常清理代码必须根据赋值后的对象状态释放资源。
一种可能的解释是:优化后的异常清理路径错误地保留了赋值前的缓冲区地址,而该地址已在赋值过程中被释放,于是展开时又释放了一次。这能解释 double free,但题目中的错误消息本身不能证明这一具体机制,还需要通过汇编或内存诊断工具验证。
异常沿调用栈传播到 main() 的 catch 时,run() 中的局部字符串应在进入处理器之前完成析构。因此,不能把错误简单归因于 catch 内的 exit(1)。
-fnon-call-exceptions 为什么会影响结果?
GCC 8.5 文档说明,-fnon-call-exceptions 允许可能产生陷阱的指令抛出异常,例如内存访问或浮点指令。该选项需要平台运行时支持,并不允许从任意信号处理函数中抛出异常。参见 GCC 8.5 代码生成选项说明。
这个选项会影响编译器对潜在异常路径的处理,因此可以排查它是否与优化共同触发了缺陷。普通 C++ throw / catch 不需要额外添加这个选项;GCC 对 C++ 默认启用异常处理。
题目列出的字符串长度、前置 erase、引用传参和赋值形式,可能影响内联、内存分配或优化路径。它们目前只是复现条件,不能据此认定这些 API 的组合存在语言层面的限制。代码调用的是 std::string::erase 成员函数,不能与其他名为 erase 的接口混为一谈。
如何规避并验证?
检查是否需要该编译选项
如果项目不依赖非调用指令抛出异常的机制,可以移除 -fnon-call-exceptions,保留正常的 C++ 异常处理:
g++ test.cpp -O2 -std=c++17 -o test
./test
如果项目确实依赖该选项的行为,就需要结合平台和运行时要求处理,不能只为消除崩溃而直接删除。
对当前业务操作使用 erase
如果目的是删除前 5 个字符,在已知 stream.size() >= 5 的前提下,可以写成:
void modify(std::string& stream) {
stream.erase(0, 5);
std::cerr << "Checkpoint.\n";
throw std::runtime_error("boom");
}
这符合示例的字符串内容需求,也避开了创建子串再赋回的路径。但字符串不足 5 个字符时,两种写法的行为不同:substr(5) 在 5 > size() 时抛出 std::out_of_range,而 erase(0, 5) 会删除现有字符。
将创建子串与赋值拆开
题目反馈下面的形式没有触发错误,可作为旧环境中的临时规避候选:
std::string tempStr = stream.substr(5);
stream = tempStr;
这里 tempStr 是左值,使用的是复制赋值,与直接使用 substr 的返回值不同。不要未经验证就加入 std::move(tempStr),那会再次改变赋值路径。能否稳定规避错误,仍应使用项目实际的编译参数验证。
在目标工具链上检查异常路径
可以保留原来的优化参数并加入调试信息,再用 Valgrind 检查两次释放的调用栈:
g++ test.cpp -O2 -g -std=c++17 -fnon-call-exceptions -o test
valgrind --tool=memcheck ./test
重点比较第一次释放发生的位置,以及异常展开时第二次释放使用的地址。记录 GCC、libstdc++、目标平台和完整参数,避免把工具链中多个组件的变化都归到 GCC 版本号上。上述命令是排查步骤,不代表已经执行并得到特定结果。
复现文件还应显式包含用到的声明:
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string>
使用 std::exit(1) 时需要包含 <cstdlib>。补齐头文件有助于代码可移植,但不能据此解释或修复 double free。
GCC 9.1 以后是否可以放心使用?
可以正常使用 std::string::substr,也可以将结果赋回通过非 const 引用传入的原对象;这种用法本身符合语言和库的语义要求。
“某个版本不再复现”不等于“已确认某项缺陷被修复”。要确定修复范围,需要对应的缺陷记录、补丁、回归测试及分支回移信息。即便确认了修复,也只能说明对应缺陷已被处理,无法保证所有平台、参数和后续版本永远没有其他编译器或标准库缺陷。
实际处理时,应在拟采用的工具链上保留这个异常路径用例,并验证项目中相似的字符串修改场景,无需因此全面禁止 substr 或非 const 引用传参。
备注:内容仅供参考。
