C++ 调试实战(06):崩溃现场分析——段错误、core dump 与 AddressSanitizer
Segmentation fault (core dumped)——大概是每个 C++ 程序员见过最多的一行报错。它只告诉你「程序崩了」,不告诉你崩在哪、为什么崩。这一篇讲三种找到答案的办法:在调试器里运行,崩溃时直接停在现场;事后用 core dump 还原现场;以及用 AddressSanitizer 在内存错误发生的那一刻就抓住它,哪怕这个错误并没有让程序崩溃。
这是「C++ 调试实战」系列的第 6 篇。本篇会大量用到第 04 篇的调用栈知识:崩溃分析的第一步永远是
bt。
一、示例:一个忘了判空的指针
1 | // crash.cpp |
| 行号 | 代码 |
|---|---|
| 14 | return nullptr; |
| 19 | return cfg->retries; |
| 23 / 24 | 查询 "default" / "custom" |
直接运行:
1 | $ g++ -std=c++20 -g -O0 crash.cpp -o crash |
退出码 139 = 128 + 11,11 就是 SIGSEGV(段错误)的信号编号。
一个容易被忽略的坑:如果把输出重定向到文件或管道(
./crash | cat、./crash > log.txt),你会发现连default: 3都没有了。因为 stdout 不连终端时是全缓冲的,程序被信号杀死时缓冲区里的内容来不及写出去。用printf调试崩溃问题时,要么输出到stderr(无缓冲),要么每次打印后fflush(stdout)。这也是调试器比printf可靠的原因之一。
二、办法一:在调试器里运行
最直接的办法:在调试器里 run,不用下任何断点。程序崩溃时,调试器会收到信号并停下来,现场原封不动地保留着:
1 | (gdb) run |
三条命令就破案了:
- 崩在
retries_for的第 19 行,参数key="custom"; cfg是空指针0x0;- 调用栈显示是
main第 24 行查询"custom"时出的事。
接下来的推理就是读代码:cfg 来自 find_config("custom"),而 find_config 对不认识的 key 返回 nullptr。修复方法是在第 19 行之前判空(或者让 find_config 返回 std::optional)。
LLDB 的输出:
1 | (lldb) run |
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 | ==20==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000020 (pc 0xaaaac1c82094 bp 0xffffe16fbdd0 sp 0xffffe16fbdd0 T0) |
地址是 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 | $ ulimit -c # 查看当前限制 |
core 文件写到哪里,由内核参数 core_pattern 决定:
1 | $ cat /proc/sys/kernel/core_pattern |
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 | [New LWP 8] |
加载后立刻显示崩溃原因(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 | // dangling.cpp |
vector 容量满了之后 push_back 会重新分配一块更大的内存,把元素搬过去,释放旧内存。first 仍然指向旧内存,成了悬空引用(dangling reference)。运行:
1 | $ g++ -std=c++20 -g -O0 dangling.cpp -o dangling && ./dangling |
没有崩溃,只是打印了一个莫名其妙的数字。换台机器、换个编译选项,它可能恰好打印 90(旧内存还没被覆盖),也可能在很久以后的某个地方引发崩溃。这类 bug 用调试器也不好抓:你不知道该在哪里停,观察点也不知道该盯哪块内存。
五、办法三:AddressSanitizer
AddressSanitizer(ASan) 是 GCC 和 Clang 都内置的内存错误检测器。它在编译时给每次内存访问插入检查代码,运行时维护一张「哪些内存可以访问」的影子表,非法访问发生的那一刻就报错并终止程序。
5.1 使用方法
编译和链接时都加上 -fsanitize=address:
1 | g++ -std=c++20 -g -O1 -fsanitize=address -fno-omit-frame-pointer dangling.cpp -o dangling.asan |
| 选项 | 作用 |
|---|---|
-fsanitize=address |
启用 ASan |
-g |
报告里显示文件名和行号 |
-fno-omit-frame-pointer |
保留帧指针,让报告里的调用栈更完整准确 |
-O1 |
ASan 官方推荐,速度和报告质量的折中;用 -O0 也可以 |
CMake 项目可以这样加:
1 | target_compile_options(myapp PRIVATE -fsanitize=address -fno-omit-frame-pointer) |
5.2 读懂 ASan 报告
运行结果(为了篇幅省略了部分标准库内部的栈帧):
1 | ================================================================= |
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 | balances = 149 199 299, audit = 649 |
没有任何报告。因为越界的地址仍然在 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 | (gdb) start |
停在了报告打印之前,参数里 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 | 程序崩溃 |
| 工具 | 命令 |
|---|---|
| 调试器里运行 | 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 抓数据竞争。







