一、C++程序为什么会突然崩溃
C++ 相比一些高级语言,需要程序员自己处理更多:
内存
指针
对象生命周期
线程同步
所以运行过程中可能出现:
Segmentation fault
Aborted
程序异常退出
Access Violation
例如:
int *p = nullptr;
*p = 10;
这里:
p == nullptr
却对它进行:
*p
访问,就可能直接导致程序崩溃。
再例如:
int *p = new int(10);
delete p;
std::cout << *p << std::endl;
这里的问题是:
内存已经释放
↓
但是继续访问
也就是:
Use After Free
再比如:
int arr[5] = {0};
arr[100] = 10;
这是:
数组越界
这类问题更加麻烦,因为它不一定在:
arr[100] = 10;
这一行立刻崩溃。
有可能:
这里把别的内存破坏了
↓
程序继续运行
↓
过一段时间
↓
在另一个完全不同的地方崩溃
所以看到:
程序崩在A函数
并不一定说明:
真正的问题就在A函数
真正原因可能更早就已经发生了。
C++ 中常见崩溃原因可以简单归纳为:
空指针访问
野指针
数组 / buffer越界
Use After Free
Double Free
错误的类型转换
对象生命周期错误
栈溢出
多线程数据竞争
库接口使用错误
因此程序崩溃以后,第一件事不应该是:
看到哪个指针就加if(ptr)
而应该先确定:
程序具体死在哪里?
调用路径是什么?
当时变量是什么状态?
二、第一步:先复现问题并找到崩溃位置
程序崩溃以后,排查流程首先应该是:
能不能稳定复现?
例如:
每次启动都会崩
这种最好排查。
而:
运行几个小时才偶尔崩一次
就要更多依赖:
日志
Core Dump
所以先记录:
什么输入会触发?
执行到哪一步会触发?
必现还是偶现?
单线程还是多线程?
Debug版本和Release版本是否都出现?
如果问题能够稳定复现,可以先通过日志快速缩小范围。
例如:
void process() {
printf("1. enter process\n");
loadData();
printf("2. loadData finished\n");
parseData();
printf("3. parseData finished\n");
}
运行以后只有:
1. enter process
2. loadData finished
随后崩溃。
那么至少可以判断:
崩溃发生在parseData()
或者parseData()内部调用的函数
问题范围马上就缩小了。
实际项目中的日志最好带上:
时间
线程ID
函数名
关键变量
错误码
例如:
printf("[parseData] buffer=%p, len=%d\n", buffer, len);
如果日志显示:
buffer = 0x0
len = 1024
就已经非常可疑了。
但如果能够直接 Debug,通常下一步就应该:
使用调试器定位准确崩溃位置
Windows 下可以使用:
Visual Studio Debugger
Linux 下则经常使用:
GDB
三、Linux下使用GDB定位崩溃
假设有这样一段代码:
#include <iostream>
void test(int *p) {
*p = 100;
}
int main() {
int *ptr = nullptr;
test(ptr);
return 0;
}
编译时加:
g++ -g main.cpp -o app
其中:
-g
表示:
加入调试信息
然后:
gdb ./app
进入 GDB。
运行:
run
程序可能直接停在:
Program received signal SIGSEGV
也就是:
Segmentation Fault
段错误
这时候首先看:
bt
也就是:
backtrace
调用栈可能显示:
#0 test(int*) at main.cpp:4
#1 main() at main.cpp:10
这句话实际上已经告诉我们:
main()
↓
调用test()
↓
test第4行崩溃
所以:
bt
通常是程序崩溃后最重要的命令之一。
可以继续:
frame 0
切换到:
第0层栈帧
再:
p p
查看变量。
可能得到:
$1 = (int *) 0x0
马上就发现:
p是空指针
完整思路:
程序崩溃
↓
GDB停在崩溃位置
↓
bt
↓
看调用栈
↓
frame
↓
切换到相关函数
↓
p 变量
↓
检查参数和局部变量
常用几个 GDB 命令可以记住:
run / r
↓
运行程序
bt
↓
查看调用栈
frame n
↓
切换栈帧
p variable
↓
查看变量
list
↓
查看源码
info locals
↓
查看当前局部变量
info threads
↓
查看线程
如果程序还没有崩,但想观察某个位置:
b test
设置断点。
然后:
run
再:
next
step
逐步执行。
所以如果面试问:
程序发生 Segmentation fault 怎么查?
一个非常标准的回答就是:
如果可以稳定复现,我通常先使用 GDB 运行程序,崩溃后通过
bt查看调用栈,确定崩溃函数和调用路径,再用frame切换栈帧,通过info locals查看当时的指针、参数和局部变量状态。
四、偶现崩溃怎么用Core Dump和ASan
有些程序不能直接一直挂在 GDB 下面。
例如:
服务器运行了8个小时
突然崩溃
或者:
客户现场偶尔崩一次
这种问题就非常适合:
Core Dump
Core Dump 可以理解成:
程序崩溃瞬间的现场快照
程序崩溃时,把当时的一部分:
内存状态
寄存器状态
线程状态
调用栈
保存下来。
之后再分析。
Linux 中可以先检查:
ulimit -c
如果:
0
说明当前不允许生成 Core 文件。
可以临时:
ulimit -c unlimited
程序崩溃以后,如果获得:
core
文件,就可以:
gdb ./app core
然后最先:
bt
如果是多线程程序,可以:
info threads
然后:
thread apply all bt
把所有线程的调用栈都打印出来。
例如发现:
Thread 1
↓
崩在memcpy()
Thread 2
↓
正在释放某个对象
那么就要怀疑:
线程1正在使用资源
线程2已经把资源释放了
也就是:
生命周期
+
并发竞争
问题。
但有些内存错误,单纯 GDB 不一定容易定位。
例如:
int *p = new int[10];
p[100] = 20;
越界可能先破坏别的内存:
真正越界的位置
↓
没有立即崩
几十行代码以后
↓
程序才崩
这时候非常适合使用:
AddressSanitizer
ASan
编译:
g++ -g -fsanitize=address main.cpp -o app
然后正常运行:
./app
如果发生数组越界,ASan 往往会直接告诉你:
heap-buffer-overflow
并给出:
出错行号
调用栈
非法访问地址
ASan 特别适合检查:
Heap Buffer Overflow
Stack Buffer Overflow
Use After Free
Double Free
部分内存泄漏
例如:
int *p = new int(10);
delete p;
*p = 20;
可能得到:
heap-use-after-free
这比单纯看到:
Segmentation fault
有效得多。
所以实际排查崩溃时:
GDB
↓
回答"程序崩在哪里"
ASan
↓
回答"是不是更早发生了内存破坏"
两者经常结合使用。
五、程序崩溃时一套完整的排查思路
实际工作中,可以按照固定顺序排查。
首先:
1. 收集现场
不要一出问题就马上改代码。
先记录:
错误信息
日志
输入数据
线程情况
崩溃时间
Core Dump
然后:
2. 判断能否复现
如果:
稳定复现
↓
直接Debugger / GDB
如果:
偶现
↓
日志 + Core Dump
接下来:
3. 看调用栈
例如:
bt
找到:
崩溃函数
+
上层调用路径
然后:
4. 检查关键变量
重点看:
指针是否为空
地址是否合法
数组下标是否越界
长度参数是否异常
对象是否已经析构
返回值是否正确
线程之间是否同时操作资源
如果看到:
memcpy(dst, src, len);
崩溃,不要马上认为:
memcpy有问题
而应该重点检查:
dst是否有效?
src是否有效?
len是否正确?
buffer是否已经释放?
因为库函数通常只是:
最后触发问题的地方
而不是:
真正制造错误的地方
如果怀疑内存破坏:
5. 开ASan
例如:
-fsanitize=address
检查:
越界
UAF
Double Free
如果是多线程程序,还要继续检查:
6. 生命周期和线程问题
例如:
线程A正在使用对象
↓
线程B把对象delete
↓
线程A继续访问
↓
崩溃
这种问题可能表现为:
偶现崩溃
地址每次都不同
Release更容易出现
可以配合:
GDB线程调用栈
ThreadSanitizer
日志中的线程ID
排查。
所以完整流程可以记成:
程序崩溃
↓
先保存现场
↓
能否复现?
↓
┌──────────────┐
│ │
能 不能
│ │
GDB Core Dump
│ │
└──────┬───────┘
↓
bt调用栈
↓
确定崩溃位置
↓
检查指针 / 下标 / 生命周期
↓
怀疑内存错误
↓
ASan
↓
怀疑多线程
↓
线程栈 / TSan
↓
找到真正根因
这里最重要的一个思想就是:
崩溃位置
≠
问题产生位置
例如:
第100行数组越界
↓
破坏堆内存
↓
程序继续运行
↓
第500行malloc时崩溃
真正的问题其实在:
第100行
而不是:
malloc
所以不能只盯着:
最后崩溃的那一行
还要结合:
调用栈
内存检测工具
日志
对象生命周期
一起判断。
如果面试官问:
程序发生崩溃时,通常如何定位问题?
可以直接回答:
我一般先保留日志、错误信息和输入条件,确认问题是否能够稳定复现。如果可以复现,会使用 GDB 或 IDE 调试器,在崩溃以后通过调用栈确定崩溃位置,再检查相关参数、指针和对象状态;如果是线上或偶现崩溃,会保存 Core Dump,再通过 GDB 分析。如果怀疑数组越界、Use After Free、Double Free 等内存问题,会进一步使用 AddressSanitizer。多线程程序还会检查各线程调用栈和对象生命周期,避免只根据最后崩溃的位置判断问题。
如果继续问:
Segmentation fault最常见的原因有哪些?
可以回答:
空指针
野指针
数组越界
Use After Free
栈溢出
对象生命周期错误
多线程并发访问导致内存被破坏
如果继续问:
为什么程序崩在memcpy,不一定是memcpy的问题?
可以回答:
memcpy只是执行内存访问的地方,真正的问题可能是上层传入了非法的源地址、目标地址或错误的长度,也可能是这块内存更早已经被释放或越界破坏。因此需要沿调用栈向上检查参数来源,而不能只看最后崩溃的库函数。
最后把面试中需要记住的几个关键词串起来:
崩溃
↓
复现
↓
日志
↓
GDB
↓
bt调用栈
↓
Core Dump
↓
ASan
↓
指针 / 越界 / 生命周期
↓
找到Root Cause
程序崩溃排查真正考察的不是会不会背几个 GDB 命令,而是能不能做到:
先保存证据
↓
定位现场
↓
缩小范围
↓
找到真正根因
而不是看到哪里崩了,就只修改哪里。