半夜被电话叫醒,说线上进程又没了——做过 Linux 应用层开发的朋友,对这种场景应该都不陌生。崩溃追踪这件事,难的不是崩溃本身,而是当你想查的时候,现场已经被清理得干干净净。这篇文章不绕弯子,直接讲清楚我在 Linux 上追踪应用层崩溃时常用的完整链路:core dump 的配置、dmesg 和 gdb 的配合、strace 补时间线,以及在完全没有 core 文件的情况下怎么给自己留后路。适合正在做服务器后端、嵌入式 Linux 应用或容器内守护进程,并且被各种莫名段错误困扰过的读者。哪怕你平时只写业务逻辑,只要程序要跟 native 代码、共享库、内核打交道,这套方法论都能把排查时间从小时级拉到分钟级。
1. 崩溃不是玄学:内核早就替你把案发现场记下来了
崩溃那一刻,内核要做的事其实比你想象的固定:捕获异常,把出错地址、指令地址、栈指针落到日志里,再根据 core_pattern 决定要不要把进程内存镜像抓下来;如果有调试器正附着在进程上,还会把信号转交给调试器。这一连串动作是自动发生的,所以与其等崩溃以后满脑子问号,不如先学会读这些现成的“现场记录”。
1.1 崩溃信号本身就是“死亡原因”
Linux 下进程异常终止,基本都绕不开那几种信号。很多新手一看“Segmentation fault”就慌了,其实这个名词翻译过来就是“进程访问了一段它不该访问的内存”。下面是我在实际项目里见到最多的几类:
| 信号 | 常见原因 | 典型现场 |
|---|---|---|
| SIGSEGV | 访问未映射或不可写内存 | 空指针、野指针、释放后再访问、栈溢出踩到保护页 |
| SIGABRT | 进程主动终止 | assert 失败、C++ 未捕获异常、glibc 检测到堆结构被破坏 |
| SIGBUS | 硬件层面的非法访问 | 非对齐访问、mmap 文件越界后读写 |
| SIGILL | 执行了非法指令 | 二进制文件损坏、编译采用的 CPU 指令集与运行机器不一致 |
| SIGFPE | 算术异常 | 除零、整数除法溢出 |
这里我要特意提醒一句:看到 SIGABRT,别第一时间怀疑内存踩踏。现实中很多 SIGABRT 来自 assert 失败,或者 C++ 的 std::terminate 被调用,本质是业务逻辑到了一个不可能的状态,而不是内存坏了。打开 core 文件以后,如果你只看到 raise、abort、__GI___assert_fail 这些底层函数就停住,那就白分析了;要继续往上层翻,一直翻到真正触发断言的业务代码。
1.2 看懂 dmesg 里那段“乱码”
系统日志里经常出现类似这样一行:
code复制[12345.678901] app[2345]: segfault at 0x7f3d0c0a1000 ip 0x55f4a1a2b3c4 sp 0x7ffd9e2f8c30 error 6 in app[0x55f4a1a29000+2000]
用 dmesg -T | grep -i segfault 或 journalctl -k -n 100 就能捞到。这行信息很多,但不少人只看到一个“segfault”就关了,很可惜。逐项拆开看:
at 0x...:进程在访问这个虚拟地址时发生了缺页。如果地址是 0x0、0x8、0x10 这种很小的值,基本可以断定是空指针偏移访问;如果是一个看起来正常但没有任何映射的地址,那要么是指针内容被踩了,要么是越界读写。ip 0x...:导致崩溃的指令地址。结合in app[0x55f4a1a29000+2000]里给出的模块基址,可以算出崩溃点在这个模块内的偏移。sp 0x...:崩溃时刻的栈指针,配合 ip 能够判断调用关系。error 6:这里值得展开说。error code 的低几位是按页错误信息编码的,bit0 表示“页是否存在”,bit1 表示“是否写操作”,bit2 表示“是否来自用户态”。最常见的两类是 4 和 6:4 表示用户态读到了不存在/未映射的页,6 表示用户态写到了不存在/未映射的页。见到 4 多发于空指针、只读段被非法跳转;见到 6 多发于写空指针、缓冲区越界后写向了保护页。
不要小看这一行日志,在那种 core 文件被清掉、gdb 又没法上手的现场,它可能是唯一能定位到崩溃模块的线索。曾经有个项目就是二进制被 strip 得干干净净,全靠 dmesg 里 in libxxx.so[...] 的偏移,配合另一个未 strip 的调试副本,定位到某个第三方库的 memcpy 处。
如果你手头有崩溃模块的加载基址,还可以做个快速换算:用 ip - base 得到模块偏移,再用 addr2line 或者 objdump 去反查代码位置。这种方法虽然不如 gdb 直接,但在只有一行内核日志的时候,已经足够救命了。
1.3 core 文件:进程临终前的“全息照片”
dmesg 只给了一句话,而 core 文件保存的是进程终止瞬间的完整状态:收到的是哪个信号、每个线程的寄存器、每个线程的完整调用栈、各线程私有栈和堆内存、全局变量值、加载了哪些共享库、加载地址分别是多少。换句话说,只要 core 文件在,gdb 就能把进程死前最后一瞬间“回放”给你看。
但 core 文件也不是万能的。它强烈依赖可执行文件、共享库的版本和编译参数;哪怕你只是用相同代码重新编了一次,ASLR 和代码段布局变了,旧的 core 也经常出现找不到文件或者栈完全对不上的情况。所以拿到 core 以后,第一件事是确认能不能和现场二进制对得上,而不是一头扎进去翻栈。
1.4 为什么你经常找不到 core 文件
很多人在网上问“我明明崩了,怎么 /tmp 下没有 core”,原因就那么几类,按顺序排查基本不会漏:
ulimit -c 0:shell 或父进程把 core 大小限制成了 0。先执行ulimit -c看当前值。core_pattern指向了 systemd-coredump 或某个管道:core 不是“没有生成”,而是被管道程序接管了,要么压缩存进了 journal,要么被丢弃。- 进程工作目录不可写:老内核默认把 core 写到进程的 cwd,如果 cwd 被删掉、目录无权限,core 就落不下来。
- 磁盘满、inode 耗尽:core 文件写入失败,日志里可能只有一句含糊的报错。
- 安全模块或者容器权限限制:容器内经常出现 core_pattern 能看但不能改,core 又想写到宿主不可写的路径。
出问题的时候按这个顺序排查,能省下大量“玄学排障”的时间。后面第 2 节我会直接把整套配置方法给出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先装上“死亡记录仪”:core 生成与保存的完整配置路线
我的观点是,core 相关配置属于“平时不起眼、出事救命”的项目,一定在服务上线前就配好,而不是等线上崩了再去临时抱佛脚。
2.1 ulimit -c:第一道闸门
Linux 对 core 大小有两个层面的限制:内核的 RLIMIT_CORE,和 shell、systemd 传下来的值。你光改了系统配置,但启动进程的父进程是 ulimit -c 0,一样不会有 core。
对当前 shell 实验,直接:
bash复制ulimit -c unlimited
然后运行会崩溃的程序,core 就会落在当前工作目录。想永久对交互 shell 生效,就把上面那行加进 ~/.bashrc。但要记住,生产环境多数服务不是 shell 拉起来的,而是 systemd 或 supervisor 拉起来的,系统服务还得在 unit 文件里加:
ini复制[Service]
LimitCORE=infinity
WorkingDirectory=/data/app
这里的坑在于很多人只改了终端里的 ulimit,忘了服务的父进程不是这个终端。改完 unit 要 systemctl daemon-reload 再重启服务,不然不会生效。
2.2 core_pattern:core 文件的最终去向
查看当前规则:
bash复制cat /proc/sys/kernel/core_pattern
现代发行版上,你大概率会看到类似这样的值:
code复制|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
这是一种管道格式,表示 core 内容被送到 systemd-coredump 处理,存入 journal,表面上看不到 .core 文件。想管理它们就用 coredumpctl:
bash复制coredumpctl list
coredumpctl info 1234
coredumpctl gdb 1234
我个人更建议在内网和嵌入式环境里改成直接落文件的格式,方便事后用工具处理:
bash复制echo 'core_%e_%p_%t' > /proc/sys/kernel/core_pattern
这行改的是当前系统,重启会失效。需要在启动时自动配置,把它写进 /etc/sysctl.d/99-core.conf:
code复制kernel.core_pattern=/var/crash/core_%e_%p_%t
kernel.core_uses_pid=1
然后执行 sysctl -p /etc/sysctl.d/99-core.conf。%e 是进程名,%p 是 PID,%t 是时间戳。如果服务重名,进程名会截断,建议自己再带上 PID;如果一台机器上可能同时崩多个模块,时间戳加 PID 能避免互相覆盖。
2.3 容器和 Docker 环境里的特殊处理
容器里追踪崩溃比较麻烦,根源在于 core_pattern 是宿主内核的全局配置,容器内看得到但通常改不了。就算你能改,写入的路径也必须落在容器可写的挂载卷里,否则 core 一样落不下来。
有用的做法有这么几件事:
- 启动容器时显式放开 ulimit:
docker run --ulimit core=-1 -v /data/crash:/var/crash ... - 在容器内把
kernel.core_pattern指向 /var/crash 或共享卷目录,然后在宿主上保留普通用户读权限; - 容器崩溃后,优先去宿主的
/var/crash或 journal 里找 coredump,而不是在容器内“原地等待”;
如果你的容器跑在 Kubernetes 里,配 node 级别 core 采集更现实。另一个思路是绕开系统 core 机制,直接用 Google Breakpad 这类库在应用内生成 minidump,由业务层把 dump 传到对象存储。虽然要改代码,但遇到容器、嵌入式、远程这些场景时可靠得多。
2.4 嵌入式 Linux 和最小化系统里的配置
嵌入式设备上没有 systemd、没有 coredumpctl,甚至没有标准的 /var/crash。一般是在启动脚本里显式设置:
bash复制ulimit -c unlimited
echo '/tmp/core_%e_%p_%t' > /proc/sys/kernel/core_pattern
然后把 /tmp 挂成可写分区。注意嵌入式存储可能很小,core 动不动几十上百 MB,需要配套清理任务,或者干脆用断点日志、远程上报这种轻量方案。目标机的 gdb 不好编时,可以把 core 文件拷回 PC,用交叉编译工具链里的 aarch64-linux-gnu-gdb 打开,配合根文件系统的符号目录分析,效果和在目标机上一样。
2.5 调试符号:决定你是“看图说话”还是“盲猜”
调试符号是追踪崩溃的底气。gdb 之所以能直接显示函数名、行号、局部变量,靠的是二进制里的 DWARF 调试信息。生产环境很多人习惯 strip -s 减小体积,我并不反对 strip,但必须满足两个前提:
- 非 strip 的调试副本在版本管理里留一份;
- 副本的版本要和线上二进制完全一致,最好能用 md5 校验。
发行版软件包可以用 debuginfod 在线拉符号,设置 DEBUGINFOD_URLS 后 gdb 会自动下载调试信息;自研程序没条件就自己做“符号副本目录”。每次发布把 git commit 记录下来,最好把 commit 编进二进制,比如 -DVERSION="$(git rev-parse --short HEAD)",以后拿到 core 至少能确认是哪一次构建。
3. gdb 解剖 core:把栈还原成一条时间线
有 core 文件后,成败就看 gdb 用得对不对。这一节我给一个完整的分析流程,不是背命令,而是拿实际场景讲解为什么要这么做。
3.1 从一条 gdb 命令开始
最基本的启动方式是:
bash复制gdb ./app /var/crash/core_app_1234_1691234567
启动后 gdb 会告诉你 core 是由哪个程序、哪个信号产生的,并且已经自动停在崩溃线程上。接着第一件事永远是 bt:
text复制Program terminated with signal SIGSEGV, Segmentation fault.
#0 main::Process (this=0x55f4..., req=0x7f3d...) at src/pipeline.cpp:87
87 memcpy(out, req->buf, req->len);
(gdb) bt
#0 main::Process (this=0x..., req=0x...) at src/pipeline.cpp:87
#1 0x00007f3d... in main::WorkerLoop (this=0x...) at src/worker.cpp:102
#2 0x00007f3d... in start_thread (arg=0x...) at pthread_create.c:477
#3 0x00007f3d... in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
看栈的时候,我的习惯是:
- 先看
#0是什么调用,它就是进程死前正在执行的指令; - 再看
#1、#2,判断调用来源; - 用
frame 1或frame 0切到具体帧,再用info args和info locals看参数与局部变量。
比如 #0 是 memcpy(out, req->buf, req->len),那下一步就去打印 req->len、req->buf、out 的值。len 若是 0x7fffffff 这种天文数字,大概率是某个字段被踩了或者解析非法协议时没有校验长度。这种判断在 30 秒内就能完成,远比拿着源码一遍遍空想要高效。
3.2 调用栈里的信息不能全信
gdb 给的栈是准确的,但“准确”不等于“直接反映根因”。有几种情况必须警惕:
- 崩溃发生在日志打印函数里:
fprintf、mutex_lock、__run_exit_handlers这些底层调用也可能是顶层帧。它们往往是被动受害者,真正的问题在别的线程破坏了全局状态。 - 编译器优化导致栈帧“残缺”:
-O2下内联函数可能不出现在栈里,局部变量被优化进寄存器,info locals可能显示<optimized out>; - 共享库版本不对:gdb 用“当前系统里的 .so”去解释 core 里的地址,如果库升级过,栈会看到无符号表信息甚至直接错乱。
所以我拿到栈之后,会先确认它符不符合业务直觉。如果 #0 在一个库函数里,而 #1 是我们自己的回调,那要重点看这个回调的入参是怎么来的;如果栈里出现连环递归几千层,那基本是栈溢出,先把调用深度记下来。
3.3 没有符号表:addr2line 和 objdump 的艰难模式
没有符号表时,gdb 只能显示地址,不能显示函数名和行号。这时可以用 dmesg 或 gdb 给出的指令地址,配合 addr2line:
bash复制addr2line -e ./app -f -C 0x1234
这个 0x1234 应该是模块内的偏移。在 core 里,info proc mappings 能列出崩溃时每个模块的加载基址;用崩溃指令的 ip 减去对应模块基址,就能得到模块偏移。共享库也一样,都是先把 ip - base,再做 addr2line -e /path/to/libfoo.so -f -C 0xoffset。
如果连 addr2line 都没有完整符号,就用 objdump -d 把目标模块反汇编,在偏移附近人工读一下指令:
bash复制objdump -d -M intel ./app | grep -A 20 -B 5 'call.*memcpy'
这条路肯定比带符号慢,但足以解开“到底是 memcpy 崩的还是 crypto 崩的”这类问题。
3.4 多线程进程:先找到“谁在死”,再看“谁在等”
多线程崩溃时,只有一个线程收到信号,但其余线程都停在各自的栈上。分析的第一步是用:
text复制(gdb) thread apply all bt
把所有线程的栈都打出来。接着:
- 崩溃线程停在信号帧,通常就是
#0有具体崩因; - 其他线程如果在
futex、pthread_cond_wait、poll等地方,说明它们在等锁或等 IO,和本次崩溃没有直接因果; - 但如果你看到另一个线程停在
malloc里的_int_malloc或free的校验处,而且主崩溃线程的栈上刚好也在操作同一个堆对象,那就要考虑堆踩踏或并发释放。
典型的情况是:线程 A 释放了一个对象,线程 B 仍然持有同一个指针,当 A 再次分配并写坏堆元数据后,B 在某次 free 时触发 SIGABRT。只看 B 的栈永远找不到 A 的错,必须把所有线程的栈交叉对比。
4. strace 和日志:把崩溃前几分钟补成一条时间线
core 文件和 gdb 能告诉我们“死在哪”,但很多时候我们还想知道“死之前它做了什么”。这正是 strace 和业务日志能补上的拼图。
4.1 用 strace 抓“临终遗言”
strace 跟踪进程的系统调用,能看到打开过哪些文件、连接过哪里、最后的 write 是否成功。对能复现的崩溃,直接这样跑:
bash复制strace -f -tt -o /tmp/trace.log ./app
崩溃后打开日志,重点看最后几十行:
- 如果最后几个系统调用是
connect、read、write返回-1 E...,那说明在崩溃前 IO 就异常了; - 如果看到连续多次
futex(..., FUTEX_WAIT)没有对应FUTEX_WAKE,说明线程可能在锁上长时间等待; - 如果看到进程尝试
mmap或brk失败,内存分配异常,崩溃前的堆状态大概率已经不稳。
strace 本身有开销,生产环境不适合长时间挂,适合做短时间采集或者本地复现。偶发崩溃想抓现场时,也可以 strace -p <pid> 附着到已经运行的服务上,但内核 ptrace_scope 和容器权限要先满足。
4.2 日志缓冲:让你误判时间的坑
这是我很想重点讲的一个坑。C/C++ 里标准输出默认是全缓冲,也就是数据先攒在用户态缓冲区,只有缓冲区满、显式 flush 或进程正常退出时才真正写出来。如果一个进程崩溃在日志 flush 之前,那么“最后一条日志”很可能是“好几句话之前的日志”,真正的最后输出还躺在缓冲区里没机会落地。
多线程程序更糟:不同线程抢同一把日志锁,打印顺序和实际事件顺序不一定一致。所以我给长期运行的守护进程几条强制建议:
- 日志代码里主动
fflush(NULL),或者改用 syslog、stdbuf -oL这类行缓冲方式; - 每条日志带时间戳、线程 id,最好还带可枚举的日志序号;
- 不要把“最后一条日志”当成“死前最后一瞬间发生的事”,要结合 strace 和 core 的栈来对齐时间。
4.3 用 gdb 直接等崩溃信号
有时候 core 老是配置不上,或者你怀疑是特定输入才崩,那就用 gdb 交互模式直接跑,让 gdb 在收到信号那一刻接管:
bash复制gdb ./app
(gdb) catch signal SIGSEGV
(gdb) run < input.txt
程序崩的时候 gdb 会停下,你可以立刻 bt、看寄存器、看变量,还能继续用 continue 观察。这个办法适合复现,也适合带着测试数据一步步逼近问题。如果是信号偶发,gdb -p PID 附着再等也行,但注意附着后进程会短暂暂停,别在生产核心服务上这么做。
对于不想被 gdb 拦下的一类信号,比如网络程序常碰到的 SIGPIPE,可以在 gdb 里显式设置:
text复制(gdb) handle SIGPIPE pass noprint nostop
否则可能程序本来只想忽略 SIGPIPE,却被 gdb 反复中断,干扰你对崩溃点的判断。
4.4 复现策略:别让偶发问题大摇大摆跑掉
偶发崩溃最耗时间,我的做法是“人肉压测 + 工具放大”。比如怀疑是并发问题,就写脚本同时起几十个客户端实例,离开跑上一个小时,配合 ASAN、valgrind 重新构建的二进制,让潜在问题提前爆出来。如果崩溃和某个库的版本升级相关,先单独跑一个只调用该库的小程序,把第三方库的嫌疑先摘干净。复现这一步未必能立刻复现,但坚持跑,很多所谓“偶发”最终都会变成“必然”。
5. 高频崩溃“作案手法”盘点与定位口诀
分析过的崩溃多了,会发现套路其实有限。我把最常遇到的几类列出来,配合各自的定位思路,算是给读者一份尽可能实用的“破案手册”。
5.1 空指针和野指针:崩溃地址是开卷答案
空指针崩溃时,at 0x... 的地址通常极小,比如 0x0、0x8、0x10,因为访问的是 NULL 指针加上结构体某个字段的偏移。栈里的表现是两个局面:要么直接解引用 NULL,要么调用一个 NULL 对象的成员函数,C++ 里表现为 this=0x0。定位时直接打印出错的指针是谁传进来的,顺藤摸瓜看上层有没有判空。野指针则复杂些,指针变量本身可能是脏值,崩溃地址要么极其奇怪,要么恰好落在某个库的映射区间。
5.2 越界和踩踏:破坏往往是异步的
缓冲区越界最折磨人的地方在于,写越界那一刻程序可能没崩,被踩的内存要等到被别人使用才爆。比如你写越界把某个对象的虚表指针改了,等程序再次调用虚函数时就飞到了非法地址。这种崩溃的特征是:栈上崩在毫不相关的调用点,地址看起来没有规律,而且重建 core 后难以稳定复现。这就要靠 ASAN 或者 valgrind 才能在源头抓到。在没条件上工具的老项目里,我一般先怀疑 memcpy、sprintf、recv 这类函数,再看给它们传入的 len 是否经过校验。
5.3 释放后使用(UAF)和悬垂指针
释放后使用是并发和高性能程序的重灾区。core 里看到 malloc_consolidate、_int_free 之类的堆管理函数频繁出现,或者崩溃点在某个对象的方法里而对象地址其实已经被释放,就要高度怀疑 UAF。定位方法:一是用 MALLOC_PERTURB_ 填充已释放内存,让二次使用更容易崩出明显特征;二是在重编译时开 ASAN,它会在访问已释放内存时直接报 AddressSanitizer 错误。需要强调,UAF 的根因往往不在崩溃线程,而在最开始释放那个对象的位置。
5.4 栈爆炸:看深度和局部变量
栈溢出通常有两种:递归调用失控、局部变量太大。gdb 里 bt 打出成百上千层,基本就是递归失控;另一种是大数组放在栈上,比如往函数里放一个 4MB 的局部缓冲,栈往往就几 MB,一次性爆掉。Linux 默认栈大小通常 8MB,可以用 ulimit -s 查。定位后一般就是把递归条件修好、把大缓冲改成堆分配,顺带考虑把 -fstack-protector-strong 打开。
5.5 多线程竞争:崩溃地址永远在变
真正的 data race 是“影像漂移”的:今天崩在这,明天崩在那,而且很难稳定复现。特征是你用 gdb 看栈,发现是一个看似合理但随机性很强的地址。原因是共享对象的生命周期没有同步好。此时除线程栈交叉对比外,ThreadSanitizer 是最高效的工具。即便不能在生产开,至少在测试环境开着一跑,race 报告会直接指出两个线程各自的访问位置。
6. 没有 core、没有 gdb 的环境怎么破:兜底方法与长期打法
到了这一节,是假定你已经把该配的都配了,但某台机器就是不能生成 core、没有 gdb、甚至不允许上传二进制。那就得靠另外几条路。
6.1 给程序装上自带“黑匣子”
在程序里注册崩溃信号处理器,自己把栈抓下来。C/C++ 里最直接的是这套:
c复制#include <execinfo.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
static void crash_handler(int sig) {
void* frames[64];
int n = backtrace(frames, 64);
int fd = open("/var/log/app_crash.log", O_CREAT|O_WRONLY|O_APPEND, 0644);
if (fd >= 0) {
dprintf(fd, "signal %d\n", sig);
backtrace_symbols_fd(frames, n, fd);
close(fd);
}
_exit(1);
}
但必须强调,信号处理器里能安全调用的函数非常有限。backtrace 一族在信号上下文里用起来要谨慎,更不能在 handler 里调用 malloc、加锁、打完整日志。真实生产里,我会让 handler 只负责“快速落一个原始框架”,复杂的事交给启动脚本和监控系统。比 execinfo 更工业化的方案是用 Google Breakpad 生成 minidump,minidump 可以脱离系统 core 机制,在容器、嵌入式、远程环境里可靠采集和上传。
6.2 systemd 自动重启 + 现场采集组合拳
没有管理员权限无法改 core_pattern 时,至少能把重启策略配好。systemd unit 里:
ini复制[Service]
Restart=on-failure
RestartSec=3
LimitCORE=infinity
RuntimeDirectory=app
Environment=CORE_DIR=/tmp
再配一个定时任务周期性 ls -l /var/crash、coredumpctl list,超过 N 小时的 core 自动清理或转存。这套组合的意义在于:崩溃发生的那一刻我们可能抓不住,但崩溃恢复后系统还有机会留下痕迹。长期运维下来,至少不会出现“崩了跟没崩一样”。
6.3 部署时把“救援包”备好
前面反复提到符号版本,这里说具体操作。我建议每个发布版本做三件事:
- 可执行文件不 strip 的副本存到单独的 release 目录,和镜像 tag、git commit 绑定;
- 二进制内写入版本字符串,崩溃时日志能看到 commit;
- 把对应容器的依赖库
.so也存一份,因为 gdb 解析 core 需要原始 .so。
很多项目崩了很多次找不到原因,最后发现是“core 是昨天的二进制,符号是今天的二进制”,地址错位导致栈面目全非。把救援包准备好,能让分析时间从几天缩到几小时。
6.4 提前拦截:让崩溃别那么容易发生
追踪崩溃很重要,但更划算的是让崩溃根本不发生。对 C/C++ 项目,我在 CI 里固定跑 ASAN、valgrind;Linux 用户态还能开启一些编译期加固:
-D_FORTIFY_SOURCE=2:对 sprintf、memcpy 这类函数做更多检查;-fstack-protector-strong:检测栈被踩;-D_GLIBCXX_ASSERTIONS:标准库容器断言;
运行时也可以设置 MALLOC_CHECK_=3、MALLOC_PERTURB_ 让 glibc 的堆检查更敏感。这类手段会把一部分“隐疾”提前变成可捕获的崩溃或明确报错,比事后从 core 里猜要高效得多。
最后再分享一个实践体会:追了这么多崩溃,真正花时间的从来不是拆栈,而是“没有 core、没有符号、没有日志”这三大缺失。很多人把崩溃追踪想成高深的逆向工程,实际上绝大多数问题,都发生在基础配置没做好、发布留档不完整、日志刷掉了关键现场这三件事上。如果现在你的服务还没有把 core 采集、符号留档、日志 flush 这三件事做齐,我强烈建议你在下一次迭代里顺手补上。等哪天半夜收到告警,你会发现当初那半小时配置,值回不止一个安稳觉。
