Segmentation fault (core dumped)——大概是每个 C++ 程序员见过最多的一行报错。它只告诉你「程序崩了」,不告诉你崩在哪、为什么崩。这一篇讲三种找到答案的办法:在调试器里运行,崩溃时直接停在现场;事后用 core dump 还原现场;以及用 AddressSanitizer 在内存错误发生的那一刻就抓住它,哪怕这个错误并没有让程序崩溃。

这是「C++ 调试实战」系列的第 6 篇。本篇会大量用到第 04 篇的调用栈知识:崩溃分析的第一步永远是 bt。

一、示例:一个忘了判空的指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// crash.cpp
#include <cstdio>
#include <string>

struct Config {
std::string name;
int retries;
};

Config* find_config(const std::string& key) {
if (key == "default") {
static Config def{"default", 3};
return &def;
}
return nullptr; // 找不到时返回空指针
}

int retries_for(const std::string& key) {
Config* cfg = find_config(key);
return cfg->retries; // 没判空,key 不存在时崩溃
}

int main() {
std::printf("default: %d\n", retries_for("default"));
std::printf("custom: %d\n", retries_for("custom"));
return 0;
}
行号 代码
14 return nullptr;
19 return cfg->retries;
23 / 24 查询 "default" / "custom"

直接运行:

1
2
3
4
5
6
$ g++ -std=c++20 -g -O0 crash.cpp -o crash
$ ./crash
default: 3
Segmentation fault
$ echo $?
139

退出码 139 = 128 + 11,11 就是 SIGSEGV(段错误)的信号编号。

一个容易被忽略的坑:如果把输出重定向到文件或管道(./crash | cat、./crash > log.txt),你会发现连 default: 3 都没有了。因为 stdout 不连终端时是全缓冲的,程序被信号杀死时缓冲区里的内容来不及写出去。用 printf 调试崩溃问题时,要么输出到 stderr(无缓冲),要么每次打印后 fflush(stdout)。这也是调试器比 printf 可靠的原因之一。

二、办法一:在调试器里运行

最直接的办法:在调试器里 run,不用下任何断点。程序崩溃时,调试器会收到信号并停下来,现场原封不动地保留着:

1
2
3
4
5
6
7
8
9
10
11
12
(gdb) run

Program received signal SIGSEGV, Segmentation fault.
0x0000aaaaaaaa17fc in retries_for (key="custom") at crash.cpp:19
19 return cfg->retries; // 没判空,key 不存在时崩溃
(gdb) print cfg
$1 = (Config *) 0x0
(gdb) print *cfg
Cannot access memory at address 0x0
(gdb) bt
#0 0x0000aaaaaaaa17fc in retries_for (key="custom") at crash.cpp:19
#1 0x0000aaaaaaaa18b0 in main () at crash.cpp:24

三条命令就破案了:

  1. 崩在 retries_for 的第 19 行,参数 key="custom";
  2. cfg 是空指针 0x0;
  3. 调用栈显示是 main 第 24 行查询 "custom" 时出的事。

接下来的推理就是读代码:cfg 来自 find_config("custom"),而 find_config 对不认识的 key 返回 nullptr。修复方法是在第 19 行之前判空(或者让 find_config 返回 std::optional)。

LLDB 的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(lldb) run
default: 3
Process 6148 launched: '/tmp/cppdebug/crash.mac' (arm64)
Process 6148 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x18)
frame #0: 0x0000000100000768 crash.mac`retries_for(key="custom") at crash.cpp:19:17
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x18)
* frame #0: 0x0000000100000768 crash.mac`retries_for(key="custom") at crash.cpp:19:17
frame #1: 0x00000001000007e4 crash.mac`main at crash.cpp:24:34
frame #2: 0x00000001884a84e4 dyld`start + 6992
(lldb) v cfg
(Config *) cfg = nullptr
(lldb) v key
(const std::string &) key = 0x000000016fdfdd48 "custom"

macOS 上段错误显示为 EXC_BAD_ACCESS。注意 address=0x18:程序访问的不是地址 0,而是 0x18。

2.1 读懂「崩溃地址」

cfg->retries 的地址是 cfg 加上 retries 在 Config 里的偏移量。Config 的第一个成员是 std::string,在 macOS 的 libc++ 里它占 24 字节(0x18),所以 retries 的偏移是 0x18,nullptr->retries 访问的就是地址 0x18。

在 Linux 上用 AddressSanitizer 运行同一个程序(第五节会讲),报告里写的是:

1
2
3
==20==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000020 (pc 0xaaaac1c82094 bp 0xffffe16fbdd0 sp 0xffffe16fbdd0 T0)
==20==The signal is caused by a READ memory access.
==20==Hint: address points to the zero page.

地址是 0x20,因为 libstdc++ 的 std::string 占 32 字节。

这是一个很有用的经验:崩溃地址是一个很小的数(几十、几百、几千),几乎可以断定是空指针加上成员偏移。反过来,看到 0xdeadbeef、0xfeeefeee 这类有规律的值,或者一个看起来像是 ASCII 字符拼起来的地址,通常是野指针或内存被踩坏。

2.2 崩溃点不一定是出错点

这个例子里,崩溃的第 19 行恰好就是 bug 所在。但很多时候,崩溃的地方只是「受害者」:

  • 一个函数返回了空指针,传了好几层之后才被解引用;
  • 一块内存被越界写坏了,很久之后读取它的代码才崩溃;
  • 一个对象已经被释放,指向它的指针后来才被使用。

这时要做的是沿着调用栈往上走(up、frame N),一层一层地看「这个错误的值是从哪来的」。如果坏值的来源是一块被踩坏的内存,就轮到第 05 篇的观察点或下面的 AddressSanitizer 出场了。

三、办法二:core dump——事后分析

在调试器里运行的前提是你能复现。线上服务半夜崩了一次、用户机器上崩了、或者要跑好几个小时才崩,你不可能一直守在调试器前。这时需要 core dump:程序崩溃时,操作系统把进程的整个内存和寄存器状态写成一个文件,事后用调试器加载它,就像回到了崩溃的那一刻。

3.1 打开 core dump(Linux)

大多数系统默认不生成 core 文件(大小限制为 0):

1
2
3
4
5
6
$ ulimit -c            # 查看当前限制
0
$ ulimit -c unlimited # 在当前 shell 里放开限制
$ ./crash
default: 3
Segmentation fault (core dumped)

core 文件写到哪里,由内核参数 core_pattern 决定:

1
2
$ cat /proc/sys/kernel/core_pattern
core
core_pattern 的值 core 文件去向
core 进程当前目录下的 core 文件(本文 Docker 环境就是这样)
core.%e.%p 当前目录,文件名带程序名和 PID
|/usr/lib/systemd/systemd-coredump ... 交给 systemd 管理,用 coredumpctl list 查看、coredumpctl gdb 直接打开
|/usr/share/apport/apport ... Ubuntu 桌面版默认交给 apport 处理

Docker 容器和宿主机共享同一个内核,所以容器里看到的 core_pattern 就是宿主机(或 Docker Desktop 虚拟机)的设置。

3.2 用 GDB 加载 core 文件

1
gdb ./crash core

(下面输出里的 crash.linux 是本文实测时 Linux 版可执行文件的文件名。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[New LWP 8]
Core was generated by `./crash.linux'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000aaaae5f717fc in retries_for (key=...) at crash.cpp:19
19 return cfg->retries; // 没判空,key 不存在时崩溃
(gdb) bt
#0 0x0000aaaae5f717fc in retries_for (key="custom") at crash.cpp:19
#1 0x0000aaaae5f718b0 in main () at crash.cpp:24
(gdb) print cfg
$1 = (Config *) 0x0
(gdb) print key
$2 = "custom"
(gdb) info registers pc
pc 0xaaaae5f717fc 0xaaaae5f717fc <retries_for(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)+28>

加载后立刻显示崩溃原因(signal SIGSEGV)和崩溃位置,之后 bt、print、frame、info locals 等所有查看类命令都能用,和在调试器里运行时崩溃的体验几乎一样。区别是:

能做 不能做
查看调用栈、变量、内存、寄存器 run、next、step、continue——进程已经死了
切换栈帧、线程 调用函数(print f(x))——没有活的进程来执行

LLDB 加载 core 文件:lldb ./crash -c core。

3.3 让 core dump 真正有用

  • 保留带调试信息的二进制。core 文件只有内存数据,行号、变量名都来自可执行文件。线上发布版通常会去掉调试信息,这时要把调试信息单独保存(objcopy --only-keep-debug),分析时再加载;
  • 二进制必须和 core 完全对应。用重新编译过的程序去分析旧的 core,行号和变量会全乱;
  • core 文件可能很大,它包含整个进程的内存。长期运行的服务要注意磁盘空间;
  • core 文件里有进程内存里的所有数据,可能包含密码、密钥、用户数据,不要随意分享。

3.4 macOS 上呢?

macOS 也支持 core dump(ulimit -c unlimited 后写到 /cores/ 目录),但默认有更多限制,配置起来比 Linux 麻烦。日常更实用的做法是:

  • 能复现就直接在 LLDB 里运行;
  • 不能复现就看系统生成的崩溃报告:「控制台」App 的「崩溃报告」一栏,或 ~/Library/Logs/DiagnosticReports/ 目录,里面有崩溃时每个线程的调用栈。

四、更隐蔽的问题:不崩溃的内存错误

空指针解引用是「最好的」内存错误:它立刻就崩,崩溃点就是出错点。更可怕的是那些不崩溃的:

1
2
3
4
5
6
7
8
9
10
11
// dangling.cpp
#include <cstdio>
#include <vector>

int main() {
std::vector<int> scores = {90, 85, 77};
int& first = scores[0]; // 引用指向 vector 内部元素
scores.push_back(60); // 容量不足,重新分配,旧内存被释放
std::printf("first = %d\n", first); // 悬空引用:读已释放的内存
return 0;
}

vector 容量满了之后 push_back 会重新分配一块更大的内存,把元素搬过去,释放旧内存。first 仍然指向旧内存,成了悬空引用(dangling reference)。运行:

1
2
$ g++ -std=c++20 -g -O0 dangling.cpp -o dangling && ./dangling
first = -1431416589

没有崩溃,只是打印了一个莫名其妙的数字。换台机器、换个编译选项,它可能恰好打印 90(旧内存还没被覆盖),也可能在很久以后的某个地方引发崩溃。这类 bug 用调试器也不好抓:你不知道该在哪里停,观察点也不知道该盯哪块内存。

五、办法三:AddressSanitizer

AddressSanitizer(ASan) 是 GCC 和 Clang 都内置的内存错误检测器。它在编译时给每次内存访问插入检查代码,运行时维护一张「哪些内存可以访问」的影子表,非法访问发生的那一刻就报错并终止程序。

5.1 使用方法

编译和链接时都加上 -fsanitize=address:

1
2
g++ -std=c++20 -g -O1 -fsanitize=address -fno-omit-frame-pointer dangling.cpp -o dangling.asan
./dangling.asan
选项 作用
-fsanitize=address 启用 ASan
-g 报告里显示文件名和行号
-fno-omit-frame-pointer 保留帧指针,让报告里的调用栈更完整准确
-O1 ASan 官方推荐,速度和报告质量的折中;用 -O0 也可以

CMake 项目可以这样加:

1
2
target_compile_options(myapp PRIVATE -fsanitize=address -fno-omit-frame-pointer)
target_link_options(myapp PRIVATE -fsanitize=address)

5.2 读懂 ASan 报告

运行结果(为了篇幅省略了部分标准库内部的栈帧):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
=================================================================
==18==ERROR: AddressSanitizer: heap-use-after-free on address 0xfc1f9afe0010 at pc 0xaaaacde31860 bp 0xffffdd114030 sp 0xffffdd114020
READ of size 4 at 0xfc1f9afe0010 thread T0
#0 0xaaaacde3185c in main /work/dangling.cpp:8
...

0xfc1f9afe0010 is located 0 bytes inside of 12-byte region [0xfc1f9afe0010,0xfc1f9afe001c)
freed by thread T0 here:
#0 0xffff9c2194c0 in operator delete(void*, unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:190
#1 0xaaaacde317a0 in std::__new_allocator<int>::deallocate(int*, unsigned long) /usr/include/c++/15/bits/new_allocator.h:172
...
#6 0xaaaacde317a0 in void std::vector<int, std::allocator<int> >::_M_realloc_append<int>(int&&) /usr/include/c++/15/bits/vector.tcc:640
#7 0xaaaacde317a0 in int& std::vector<int, std::allocator<int> >::emplace_back<int>(int&&) /usr/include/c++/15/bits/vector.tcc:123
#8 0xaaaacde317a0 in std::vector<int, std::allocator<int> >::push_back(int&&) /usr/include/c++/15/bits/stl_vector.h:1434
#9 0xaaaacde317a0 in main /work/dangling.cpp:7
...

previously allocated by thread T0 here:
#0 0xffff9c218450 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:109
...
#6 0xaaaacde31200 in std::vector<int, std::allocator<int> >::vector(std::initializer_list<int>, std::allocator<int> const&) /usr/include/c++/15/bits/stl_vector.h:712
#7 0xaaaacde31200 in main /work/dangling.cpp:5
...

SUMMARY: AddressSanitizer: heap-use-after-free /work/dangling.cpp:8 in main

ASan 报告分成三段,正好回答了三个问题:

段落 回答的问题 本例
第一段:heap-use-after-free ... READ of size 4 哪里做了非法访问?什么类型的错误? dangling.cpp:8 读了 4 字节(一个 int),访问的是已释放的堆内存
freed by thread T0 here 这块内存是谁释放的? dangling.cpp:7 的 push_back 触发了 _M_realloc_append,释放了旧内存
previously allocated by thread T0 here 这块内存最初是谁分配的? dangling.cpp:5 构造 vector 时分配

12-byte region 是 3 个 int,就是 vector 最初的那块内存。三段合起来,整个 bug 的来龙去脉一目了然。读 ASan 报告的技巧和读调用栈一样:跳过 std::、__asan 开头的帧,找你自己的源文件。

5.3 ASan 能抓哪些错误

报告类型 含义 典型原因
heap-buffer-overflow 堆内存越界 new int[10] 访问第 10 个、vector 用 [] 越界
stack-buffer-overflow 栈上数组越界 局部数组 char buf[16] 写了 17 字节
global-buffer-overflow 全局数组越界 同上,发生在全局 / 静态数组上
heap-use-after-free 使用已释放的堆内存 悬空指针 / 引用、迭代器失效
stack-use-after-return 使用已返回函数的栈内存 返回局部变量的引用(部分编译器需要 ASAN_OPTIONS=detect_stack_use_after_return=1 才开启)
double-free 重复释放 两个裸指针 delete 同一个对象
SEGV on unknown address 普通的段错误 空指针、野指针(ASan 会给出更友好的调用栈)
内存泄漏 程序退出时未释放的内存 Linux 上默认开启 LeakSanitizer

5.4 ASan 的局限

  • 有运行开销:程序大约慢 2 倍,内存占用明显增加。适合开发和测试阶段,不适合直接上生产;
  • 只能发现被执行到的错误:没跑到的代码路径里的 bug 它看不到,所以要配合充分的测试;
  • 抓不到结构体内部的越界。回头看第 05 篇的 watch.cpp:balances[3] 越界写到了同一个结构体的 audit_total 上。用 ASan 编译运行,结果是:
1
2
balances = 149 199 299, audit = 649
exit=0

没有任何报告。因为越界的地址仍然在 Ledger 这个合法对象的内存范围之内,ASan 的影子表认为它「可以访问」。这种情况还得靠观察点,或者把裸数组换成 std::array 并用 .at() 做边界检查。

5.5 其他 Sanitizer

Sanitizer 编译选项 检测什么
UndefinedBehaviorSanitizer -fsanitize=undefined 有符号整数溢出、除零、非法移位、空指针调用成员函数等未定义行为
ThreadSanitizer -fsanitize=thread 数据竞争(第 07 篇)
MemorySanitizer -fsanitize=memory(仅 Clang) 读取未初始化的内存

ASan 和 UBSan 可以同时开:-fsanitize=address,undefined。把它们加进 Debug 构建和单元测试里,很多内存 bug 在进入调试器之前就会被发现。

5.6 ASan 与调试器配合

ASan 报错后默认直接退出进程。如果想在报错的那一刻停在调试器里、查看当时的变量,可以给 ASan 的报错函数下断点。注意这个函数在 ASan 运行时库里,程序启动、库加载之后才能找到,所以先 start:

1
2
3
4
5
6
7
(gdb) start
...
(gdb) break __asan::ReportGenericError
Breakpoint 2 at 0xfffff79930ac: __asan::ReportGenericError. (3 locations)
(gdb) continue

Breakpoint 2.2, __asan::ReportGenericError (pc=187649984436320, bp=bp@entry=281474976708848, sp=sp@entry=281474976708832, addr=addr@entry=277214207541264, is_write=is_write@entry=false, access_size=access_size@entry=4, exp=exp@entry=0, fatal=fatal@entry=true) at ../../../../src/libsanitizer/asan/asan_report.cpp:515

停在了报告打印之前,参数里 is_write=false、access_size=4 就是「读 4 字节」。之后用 bt 找到 main 所在的帧,frame N 切过去看变量即可。(如果在 run 之前直接下这个断点,GDB 会提示函数未定义、询问是否设为 pending 断点,回答 y 也可以。)

另一个办法是设置环境变量 ASAN_OPTIONS=abort_on_error=1,让 ASan 报错后调用 abort() 而不是正常退出(退出码变为 134,即 SIGABRT):在调试器里会停在 SIGABRT 处,不在调试器里则会按 core dump 的设置生成 core 文件。

六、崩溃分析流程总结

1
2
3
4
5
6
7
8
9
10
11
程序崩溃
│
├─ 能复现? ──是──▶ 在 GDB / LLDB 里 run,崩溃时 bt + print
│
├─ 不能复现? ──▶ 开启 core dump,事后 gdb ./prog core
│
├─ 崩溃点看起来「莫名其妙」?(值被踩坏、偶发崩溃)
│ └──▶ 用 ASan 编译运行,定位真正的出错点
│
└─ 某个变量被改错但不崩溃?
└──▶ 观察点(第 05 篇)盯住它
工具 命令
调试器里运行 gdb ./prog → run → bt;lldb ./prog → run → bt
开启 core dump ulimit -c unlimited,查看 /proc/sys/kernel/core_pattern
分析 core gdb ./prog core;lldb ./prog -c core;systemd 系统用 coredumpctl gdb
AddressSanitizer -g -fsanitize=address -fno-omit-frame-pointer
未定义行为 -fsanitize=undefined

C++ 调试实战系列第 6 篇完。下一篇:多线程调试——线程切换、attach 到卡死的进程定位死锁、ThreadSanitizer 抓数据竞争。