Appearance
double free 为什么会 crash:从 glibc ptmalloc 链表不变量到 SIGABRT
进程崩了(SIGSEGV/SIGABRT)怎么办?本篇专讲其中一类高频根因:double free(重复释放)。从 glibc ptmalloc 的空闲链表不变量讲起,解释它为什么"必崩"、崩在哪儿,并给出 C++ 里(尤其是与 mutex/锁相关的)典型误用模式与抓取手段。
阅读时间:2026-08-05
1. 本篇要回答的问题
free(p)两次为什么会 crash?是立刻崩还是之后崩?- glibc 的堆管理器内部是怎么发现"这块已经被释放了"的?
- 为什么有时崩在
free、有时崩在之后某次malloc或写内存? - C++ 代码里(尤其和
std::mutex等锁配合时)有哪些常见的 double free 触发模式? - 怎么在崩溃前就抓到 double free?(ASan / Valgrind)
2. 信号层:double free 表现为 SIGABRT
在 signals.md 中,double free 被归类为触发 SIGABRT 的根因之一(与 assert 失败、未捕获异常并列)。
典型现场:
bash
*** Error in `./app': double free or corruption (!prev): 0x0000000001c99040 ***
======= Backtrace: =========
...
======= Memory map: ========
Aborted (core dumped)关键点:double free 不是硬件异常(SIGSEGV),而是 glibc 主动 abort() 发出的 SIGABRT。也就是说,是分配器"自己发现了非法状态"后主动自杀,而不是 CPU 访问了非法地址。这也解释了为什么崩点常出现在 free/malloc 内部而非你的业务代码行。
3. 原理层:glibc ptmalloc 的空闲链表不变量
3.1 堆块元数据与 bin
glibc 的 ptmalloc 不把"空闲内存"当作一片裸区域,而是用每个块前面的隐藏 malloc_chunk 头(prev_size / size / fd / bk …)维护元数据,并把空闲块挂进不同的 bin:
| bin 类型 | 组织方式 | 适用大小 |
|---|---|---|
tcache | 每线程单链表(per-thread cache) | 小对象(默认 ≤ 1032B) |
fastbin | 单链表(fd 串联同 size 块) | 较小块(默认 ≤ 64B 或 128B) |
smallbin | 双向链表(fd + bk) | ≤ 1008B |
largebin | 双向链表 + 大小序 | > 1008B |
空闲链表不变量:任何时刻,一个空闲块在且只在一个 bin 的一条链里;双向链表的对称性必须成立——对链中节点 P,必有 P->bk->fd == P 且 P->fd->bk == P。
3.2 第一次 free 发生了什么
free(p) 时,分配器把 p 链入对应 bin:
- fastbin / tcache:把
p->fd设成当前 bin 头,bin_head = p; - smallbin / largebin:做双向插入,维护
fd/bk对称。
free 之后,p 的 fd/bk 已经是合法的链表指针,且 p 已被标记为"在 bin 中"。
3.3 第二次 free 同一块:不变量被破坏
再次 free(p) 时,分配器又要把 p 链入 bin,但 p 已经在表里了:
- fastbin / tcache 场景:会把
p->fd再次改写,使p在单链表里出现重复节点甚至自环(p->fd == p)。这一步未必立刻崩——不变量被悄悄破坏,但程序继续跑。直到下一次malloc从该 bin 取块时,遍历链表会死循环或返回已经发出去的地址,后续写入就踩坏另一块内存 → 在别处 SIGSEGV。 - smallbin / largebin(双链表)场景:插入/后续
unlink会检查双向链表对称性(P->bk->fd == P && P->fd->bk == P)。重复插入让对称性失效 → glibc 打印double free or corruption (!prev)/corrupted double-linked list然后abort()。
3.4 现代 glibc 的主动防护
glibc 2.34+ 在 fastbin / tcache 路径加了重复释放检测(如 tcache double-free check、next_chunk 越界检查、free_check),能在"检测到这块还挂在 bin 上"时直接 abort,而不是等到下次 malloc 才暴露。所以新版本上 double free 往往当场就在 free 行崩。

一句话原理:double free 让空闲链表出现重复/自环节点,破坏了分配器维持的链表不变量;glibc 在再次 free(新版本主动检测)或下次 malloc 遍历链表(旧版本)时发现这个非法状态,主动 abort()。所以"必崩",且崩点往往不在你 free 的那一行,而在之后某次 malloc/写内存时。
4. C++ 中的典型 double free 触发模式
4.1 裸指针 + 手动 delete 重复调用
最直白的形式:同一裸指针被 delete 两次。
cpp
int* p = new int[10];
delete[] p;
// ... 中间忘了 p 已释放,或异常跳过了置空 ...
delete[] p; // double free!防护:delete 后立刻 p = nullptr;(二次 delete nullptr 是安全的空操作)。但更根本的做法是——别用裸指针管理所有权。
4.2 析构函数与拷贝构造/赋值的"浅拷贝"陷阱
cpp
struct Bad {
int* data = new int[10];
~Bad() { delete[] data; } // 没有自定义拷贝构造/赋值
};
Bad a;
Bad b = a; // 默认浅拷贝:a.data == b.data
// 离开作用域时 a 和 b 各析构一次 → data 被 double free修复:遵循 Rule of Three/Five(自定义拷贝构造、拷贝赋值、析构;或更好——用 std::unique_ptr / std::shared_ptr / std::vector 把所有权交给标准库)。
4.3 异常路径导致 free 两次(与 lock 强相关)
这是和 std::mutex 等锁最容易耦合出 double free 的地方。看下面的反例:
cpp
std::mutex m;
int* p = new int[10];
void process() {
m.lock(); // 手写加锁
do_something(p); // 若这里抛异常...
delete[] p; // 这行没执行到
p = nullptr;
m.unlock(); // unlock 也没执行到 → 锁泄漏
}
// 调用方 catch 到异常后,可能再次尝试释放 p → double free问题不在锁本身,而在于手写 lock/unlock 不防异常:异常从 do_something 逃出,unlock 和 delete 都被跳过,p 仍"逻辑上持有"。调用方若认为"process 失败了,我自己清理"就会再 delete 一次。
正确做法:用 RAII 同时管锁和内存,让异常安全:
cpp
void process() {
std::lock_guard<std::mutex> lk(m); // 析构自动 unlock,异常也安全
std::unique_ptr<int[]> p(new int[10]); // 析构自动 delete,不会 double free
do_something(p.get());
} // 锁和内存都在作用域结束时确定性释放关于
std::mutex/recursive_mutex/timed_mutex/shared_mutex等 6 种 C++ 互斥量的能力、底层实现与选型,见 ../concepts/latency/cpp-mutex-types.md。关键结论:永远用lock_guard/unique_lock/shared_lock接管锁,手工lock/unlock在异常路径下既会锁泄漏,也常间接引发 double free。
4.4 递归锁掩盖的重复释放
cpp-mutex-types.md 提到 std::recursive_mutex 允许同一线程重入。若把"释放内存"也放在可重入的临界区里,递归调用可能让 delete 执行多次:
cpp
std::recursive_mutex rm;
int* p = new int[10];
void cleanup() {
std::lock_guard<std::recursive_mutex> lk(rm);
delete[] p; // 第一次
p = nullptr;
recursively_call(); // 递归又进 cleanup → 若 p 还没置空就再次 delete
}递归锁让这种重入"不报错",反而把 double free 藏得更深。优先重构消除重入需求,而非长期依赖递归锁。
4.5 多线程下无锁保护共享指针
若多个线程共享同一裸指针且没有用 mutex/原子保护好释放动作,一个线程 delete 后另一个线程又 delete——这是最隐蔽、最难复现的 double free:
cpp
int* shared = new int[10];
// 线程 A: delete[] shared;
// 线程 B: delete[] shared; // 竞争 → double free 或 use-after-free修复:要么把所有权用 std::shared_ptr 管理(引用计数保证只释放一次),要么用 std::mutex 把"释放"夹进临界区且释放后立即把指针置空、并让所有使用者先判空。
5. 怎么在崩溃前抓到 double free
double free 往往"隔很久才崩、且崩在别处",事后看 core 只能看到受害者。推荐在测试/CI 阶段用工具当场抓现行:
| 工具 | 手段 | 特点 | 详见 |
|---|---|---|---|
| ASan | 编译期插桩 + shadow memory + quarantine 隔离区 | free 后把块染毒 0xfd 并扣押,再次 free 立刻报 double-free,附分配/释放栈 | asan.md |
| Valgrind (Memcheck) | 运行期二进制翻译监控每次 malloc/free | 无需重编译,能直接跑现有二进制;慢 10~50x | valgrind.md |
| core dump | 事后现场 | 崩了才有,常用于生产;但 double free 崩点常偏离凶手 | core-dump.md |
ASan 视角下为什么能抓(摘自 asan.md 的 quarantine 机制):普通 free 立刻把内存还回分配器、很可能马上被复用,UAF/double-free 就抓不到;ASan 改成"free 后染毒 0xfd 并丢进隔离区暂不归还",第二次 free 时发现它已在隔离区 → 直接报 double-free,并打印访问栈 + 分配栈 + 释放栈三段信息。
bash
# 用 ASan 抓 double free(编译期插桩,必须重编)
g++ -fsanitize=address -g -O1 demo.cpp -o demo
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./demo
# 输出会精确指出:哪行 free 了两次、当初在哪 malloc、第一次在哪 free6. 一句话总结
double free 的本质是破坏了 glibc ptmalloc 空闲链表的"一个块只在一条链里"不变量——fastbin/tcache 下形成重复/自环节点(常在下次 malloc 才崩),smallbin/largebin 或新版本 glibc 下当场断言失败 abort()(SIGABRT)。C++ 里它常由裸指针重复 delete、浅拷贝析构、异常路径跳过释放、递归锁重入释放、多线程无锁竞争释放触发;用 unique_ptr/lock_guard 等 RAII 把所有权与锁交给标准库,并在 CI 跑 ASan,可把这类问题消灭在合并之前。
关联文档
- signals.md —— SIGABRT 根因清单(double free / assert / 未捕获异常)
- asan.md —— ASan 如何靠 quarantine + shadow 当场抓 double-free
- valgrind.md —— Memcheck 无重编译抓内存错误
- core-dump.md —— 崩溃后现场还原
- cpp-mutex-types.md —— C++ 6 种互斥量详解,本文 §4.3/§4.4/§4.5 的锁相关误用均依赖此篇
- lockfree-deep.md —— 无锁编程,替代锁的另一种思路