在接手某个无状态网关模块的维护时,我遇到过一次非常典型的崩溃问题:进程半夜挂掉,日志只打了半行,没有异常堆栈,没有崩溃现场。当时第一反应是看监控、看日志、看网络波动,折腾了快两个小时毫无头绪,最后想起核心转储文件(core dump)还开着,拉出来让调试器一分析,五分钟就锁定了罪魁祸首——一处智能指针在异常分支里被提前释放,导致后续线程访问到悬空地址。
那次之后,我对核心转储的态度彻底变了。它看起来只是进程死掉时操作系统拍下来的一张“案发现场快照”,但实际上,它是很多疑难杂症里唯一值得相信的证据。日志可以被业务逻辑误导,监控曲线可以掩盖瞬时状态,核心转储里的寄存器和栈帧却忠实记录了机器视角下的一切。这篇内容,就是围绕调试技巧与核心转储分析整理的实战笔记,适合那些在Linux环境下写过服务端程序、被偶发崩溃折磨过的开发者。我会从如何配置、如何触发、如何用调试器和核心转储一步步还原崩溃原因讲起,尽量把那些文档里不会写清楚的细节一起说透。
1. 核心转储值不值得开?先弄明白它记录了什么
很多人一听到核心转储就觉得它是“调试用的高级功能”,平时很少留意。实际上,它是内核在进程收到特定信号并终止时,把进程的地址空间、寄存器状态、打开的文件描述符、信号处理信息等内容保存到磁盘上的一个文件。可以把核心转储理解为飞机上的黑匣子,它不记录“为什么”的逻辑,但完整记录了“发生了什么”的瞬间状态。
1.1 核心转储里到底有什么
一次完整转储通常包含以下几类关键信息:
- 信号信息:进程是因为什么信号死的,是段错误(SIGSEGV)、非法指令(SIGILL)、断言失败(SIGABRT),还是被 SIGBUS 命中。
- 崩溃线程的调用栈:栈上每一帧的函数地址和局部变量,这是定位问题的第一把钥匙。
- 崩溃瞬间的寄存器值:程序计数器(PC)指向哪条指令,栈指针(SP)在哪,关键寄存器里保存了什么指针。
- 全部线程的状态:不只是崩溃的那个线程,其他线程当时的函数调用关系和栈内容也在。很多死锁、数据竞争问题,恰恰需要看非崩溃线程在干什么。
- 内存中的对象内容:字符串内容、结构体字段、容器的局部快照,可以用来还原崩溃前一刻数据的模样。
它和日志最本质的区别在于:日志是开发者预设的“我认为这里可能出现问题”,核心转储则是内核记录的“客观世界在实际地址处的内容”。遇到难以复现的偶发崩溃,日志可能因为日志代码本身被打断而缺失,核心转储却几乎总是完整的。
1.2 打开核心转储的代价,不只是磁盘空间
既然核心转储信息量这么大,很多人会不加思考地打开它,然后发现磁盘被塞满了。这里需要算一笔账:一个常驻 4GB 内存的服务,如果开启完整核心转储,崩溃一次可能会写入十几 GB 甚至更大的文件。所以决定开启前,必须想清楚几个问题:
- 业务重要程度:数据丢失、服务中断的成本高,还是磁盘成本高。
- 崩溃可复现性:如果问题每次都能稳定复现,其实不一定依赖核心转储;真正离不开的是那种一周只崩一次、日志又看不出任何异常的“薛定谔崩溃”。
- 安全与隐私:核心转储包含内存里的全部数据,如果进程里有用户密码、密钥、会话令牌之类内容,落盘文件就是一个安全风险点,必须配套设置权限和清理策略。
我推荐的做法是:核心转储不是默认开启,但一定保留一个“随时可开启”的通道。把 ulimit 和内核参数写成运维备注或启动脚本,事故发生时能及时打开,平时不浪费磁盘。所谓恰到好处,就是在成本和信息量之间取一个业务能接受的平衡。
2. 明明配了 core 却一个文件都没有,问题出在哪
很多时候,等到真正想要核心转储文件时,却发现系统里干干净净,一个文件都没有。排查这类问题,可能比分析核心转储还要费时间。下面把常见的坑按顺序过一遍。
2.1 第一关:资源限制,ulimit 是最大的拦路虎
Linux 下控制核心转储的第一道闸门是RLIMIT_CORE。Shell 里执行:
ulimit -c如果显示0,就表示当前进程禁止产生 core 文件。
通常在我们服务的启动脚本里加上一句:
ulimit -c unlimited但这只在当前 shell 环境里有效。若是通过 systemd 管理的服务,需要在 service 文件里加:
LimitCORE=infinity很多新手会先在终端里执行ulimit -c unlimited,然后从终端启动服务,发现能出 core;后来把服务托管到 systemd 或容器平台,就发现 core 又没了。原因正是环境不同,LimitCORE的继承链不一样。排查时不要只在 Shell 层面看,要顺着进程的父进程链一路查上去。
2.2 第二关:核心转储文件的去向,core_pattern 的规则
就算资源限制放开了,系统还会根据/proc/sys/kernel/core_pattern决定把文件写到哪、叫什么名字。默认值通常是core,表示写到进程崩溃时的当前工作目录,文件名就是core。但很多线上服务启动时的工作目录是根目录或/tmp,文件写进去却因为权限不足被直接丢弃。
生产环境建议显式设置一个专门的目录,比如:
sysctl -w kernel.core_pattern=/var/core/core_%e_%p_%t这里的%e是进程名,%p是 PID,%t是时间戳。加上这些信息的文件名才不会互相覆盖,尤其适合进程自动重启导致崩溃文件被新实例覆盖的场景。
2.3 第三关:两对“看起来没开”的隐藏开关
主要隐藏开关有两个。第一个是/proc/sys/kernel/core_uses_pid,它决定了 core 文件名是否自动追加 PID。如果设为0且core_pattern里也没有%p,多个进程就写同一个文件,后崩的覆盖先崩的。
第二个是/proc/sys/fs/suid_dumpable。如果进程是设置了 setuid 位的程序,或者以特权进程身份运行,出于安全考虑内核可能禁止转储。默认值0不许转,改成1允许转储,但需要考虑安全后果。
我遇到过一种比较隐蔽的情况:容器内通过docker run启动进程,宿主机core_pattern指向的是 systemd-coredump 这类处理程序,容器内却看不到处理结果;宿主机目录权限又不允许容器进程写入,于是表现为“什么都没有发生”。这类问题需要区分:到底是内核没生成文件,还是文件生成了但被管道式处理程序接管了,还是权限不够写不出来。判断方法是看/proc/sys/kernel/core_pattern,如果它以管道符|开头,说明转储内容交给另一个程序接管,不会直接落盘。
3. 分析核心转储的三板斧:先看栈,再看帧,最后翻内存
拿到 core 文件以后,最常用的工具是 GNU 调试器(gdb)。我习惯把分析流程分成三个递进层次:先是宏观定位,再是微观还原,最后是数据验证。每一步都在前一步的基础上收窄怀疑范围。
3.1 第一板斧:拿到崩溃现场的全貌
进入调试器只需要一句话:
gdb 可执行文件 核心转储文件进入后,第一条命令永远是:
bt它会打印崩溃线程的完整调用栈。但注意,bt默认只打印帧号和函数名,信息有限;我更推荐用:
bt full它会额外显示每个栈帧里的局部变量。这一下子就能判断出崩溃是在哪个函数里发生的,以及函数入口处带了什么参数。
紧接着值得看的还有两处:崩溃的信号源,可以用:
info signals查看当前线程收到的是哪种信号;以及当前线程 ID:
info threads线程 ID 的意义在于,崩溃线程和问题线程不一定相同。举个例子,线程 A 把某个对象释放了,线程 B 后一步访问同一块内存,操作系统报告的崩溃线程是 B,真正的“凶手”在 A。这种情况只盯bt很容易误判。
3.2 第二板斧:切换栈帧,还原函数调用现场
拿到调用栈后,我会逐帧往前翻:
frame 2这样能查看上溯若干层之外,崩溃前一个调用者传给当前函数的参数是什么。还有一个命令组合很好用:
info args info locals ptype 结构体名这三个分别回答三个问题:这个函数收到了什么、内部变量变成了什么、这些变量的数据类型定义是什么。
实际调试里,我经常因为info args里的指针参数看起来异常而直接怀疑调用方。比如一个指针本应指向合法的对象地址,打印出来却是指向已释放内存的特征值,就基本可以判定是指针悬空问题。再配合disassemble查看当前函数的反汇编代码,可以定位到崩溃点对应的源码行,判断是操作哪个字段时出错。
3.3 第三板斧:解剖内存中的数据形态
栈帧信息只能说明“代码走到哪了”,不能直接告诉我们“这里的数据为什么变成这样”。所以第三步是用内存检查命令解剖可疑对象:
x/20uh 地址 x/s 地址 x/gx 地址格式可以理解为:x是 examine 的缩写,斜杠后面的数字表示查看多少个单位,字符表示显示格式(u无符号十进制、x十六进制、s字符串、i汇编指令)。
举个例子,假设程序里有一个消息结构体struct Message,里面包含一个type字段和一个data字符串指针。崩溃时怀疑data指针非法,就可以:
p msg->data x/s msg->data p *msg第一条打印出指针值,第二条尝试把指针指向的内存按字符串解析,第三条打印整个结构体内容。如果data指针值看起来正常但字符串内容乱码,就要考虑是不是越界写破坏了这个对象;如果指针本身指向一个很小的地址,那是空指针或野指针的经典形态。
这一步还有一个容易被忽略的好功能:gdb 支持直接调用程序的表达式求值。比如在调试会话里执行p plus_one(3),只要符号表里有这个函数,调试器就能在线调用它。核心转储分析虽不能执行代码,但有些纯计算函数也可以通过打印表达式求值来完成,省去手工换算地址的麻烦。
4. 一次真实堆缓存越界崩溃:从核心转储到根因的完整链路
前面几节讲的是套路,这一节我完整还原一次线上排查过程,把前面提到的每个环节串起来,也让没有亲手分析过核心转储的人看一下真实场景长什么样。
4.1 现场现象:每周崩一次,debug 时又稳定复现不了
某对接服务的进程每周固定崩溃一次,没有明显规律,既不是高峰时段,也不是某个特定请求触发。重启后一切正常,日志里的最后一条消息是半行“accept new connection”,接着进程就消失了。监控里看不出内存持续增长,也没有死锁迹象。开发团队一开始怀疑是第三方库的偶发异常,加了很多日志还是无法定位。
后来在 core_pattern 指向的目录里发现了一个几天前生成的 core 文件,文件时间正好和一次崩溃时间吻合。当时决定打开它,就是这场排查的转折点。
4.2 第一层证据:backtrace 指向一个完全没怀疑过的函数
用调试器加载后,先执行:
bt full输出里最后几帧是:
free()的崩溃- 某个缓存清理函数
cache_evict_locked handle_write_done
一个处理网络写入完成的函数,为什么会走到缓存淘汰逻辑?而且崩溃点是free(),说明问题不在业务逻辑本身,而在内存管理。这条线索立刻把排查方向从“网络处理”拉回“缓存生命周期”。
4.3 第二层证据:局部变量暴露了数据结构的内部矛盾
接下来执行:
frame 2 info locals看到缓存节点的引用计数值(refcount)为0,但链表指针还在正被遍历。也就是说,这段代码在某个分支判断里只检查了链表成员关系,没有检查引用计数是否已经归零。正常情况下,引用计数归零的节点应该先从链表中摘除,再被释放。这里顺序被颠倒了。
继续向栈顶看,frame 3是一个全局定时器回调,它触发了缓存的周期性清理。整个事件链瞬间清楚了:定时器清理线程把某个超时节点摘出链表并释放,与此同时主线程的网络回调正持有同一个节点的指针准备更新过期时间。竞态窗口非常短,读多写少的场景下几乎不会触发,只有写入恰好和定时清理同时发生时才崩。
4.4 第三层验证:内存打印和对象内容还原了罪证
为了确认指针是否确实悬空,我又执行:
p 崩溃节点指针 x/16gx 崩溃节点指针十六进制输出里能看到这个地址的内存已被凑整清零,且相邻地址已被其他对象复用。这更进一步确认:节点已经被释放过,当前调用拿到的只是游离的内存地址。整个链路的核心转储分析环节,到这里就已经闭环了。修复方案很简单:在释放节点的分支前先判断引用计数,并确保网络回调持有期间引用计数至少为 1;定时清理线程负责摘链,主线程负责更新访问时间,二者改为通过引用计数同步状态。
事后回到日志和监控找证据,才在一些旧日志里发现过几次“free(): invalid pointer”的片段,只是当时没人把这几条日志当回事。
4.5 这起崩溃为什么难复现,核心转储解决了什么
这个场景里,核心转储的价值不是告诉开发者“这里有 bug”这么简单,而是直接把排查范围从“整个系统”缩小到“某个释放顺序错误”。其他工具做不到这一点:日志只能记录开发者想到要记录的东西,监控只能展示宏观趋势;只有核心转储能提供“此刻内存里到底什么样”的底层真相。
类似的竞态崩溃、堆损坏崩溃、内存越界崩溃,最大的共同点是可复现性极差。就算用 Address Sanitizer 重编代码,触发概率不变的话依然要等;而核心转储只需要一次崩溃,就能留下足够还原真相的信息。
5. 整理几个让我少熬好几个通宵的实操习惯
这一节不是泛泛而谈的注意事项,而是我在多次崩溃排查里积累下来的具体习惯。它们本身不复杂,但遇到问题时能节省大量时间。
5.1 编译期留下调试符号,生产环境也要留
很多团队在生产环境用-O2加-g0编译,意图是减少体积、提高性能。但这样做的代价是,一旦崩溃,核心转储里看到的只有一堆地址,无法映射回源码行,分析难度直线上升。
折中的做法是用-g生成调试信息但不影响优化,或者用-g -O2组合。文件体积会增大,但换来的却是每个函数、每个变量都能和源码对应。实在担心体积的,可以用分离调试符号的方式,把调试信息单独放一个文件,发布时拷贝到专门的符号服务器,分析和线上运行互不干扰。
objcopy --only-keep-debug 可执行程序 可执行程序.debug这样发布出去的二进制是裁剪过的,但分析时用gdb 可执行程序.debug core依然能还原源码级信息。
5.2 在容器环境里提前走查 core 落盘链路
容器化之后,/proc/sys下的内核参数大多继承自宿主机,ulimit却可能被容器运行时重置。我曾经在一个集群环境里排查过“所有容器都开了 core,但没有一个文件”的问题,最后发现是容器的入口脚本里少了ulimit -c unlimited。
所以在容器镜像的入口脚本里,应该显式设置资源限制,并检查写入目录是否是挂载出来的持久化卷。不然容器被回收,core 文件也会跟着消失,等于白存。
5.3 用核心转储做崩溃后的回归验证
修复完 bug 之后,我通常不会直接把它当结束。如果 core 文件还在,我会重新加载一次修复后的程序,通过预加载或者调试器把程序的函数替换一套,模拟崩溃前的参数重放一次,发现新逻辑不会再走进错误的释放分支才算真正闭环。
具体做法是:在调试器里用dlopen一个修复版共享库,或者用call命令在调试会话里直接执行修复后的函数,配合原 core 里的局部变量作为输入。这个方法不是所有场景都适用,但在不少修复场景里可以提前暴露“修了 A 又破坏 B”的回归问题。
5.4 给 core 文件建立生命周期管理
最后一项习惯很容易被忽略:core 文件的清理和轮转。线上环境的 core 目录要设置定时任务,按时间和占用空间清理,比如超过 7 天且超过 10GB 就删。否则核心转储会从一个救命工具变成磁盘杀手,等到关键事故想用的时候,反而发现磁盘被历史文件塞满了。
如果使用 systemd-coredump 这类机制,它本身自带日志轮转策略,也要确认策略配置和业务规模匹配。对于大型服务,建议 core 文件只保留最近几次崩溃样本,加上一张“时间、PID、崩溃线程栈摘要”的索引表,方便快速定位值得深入分析的样本。
6. 遇到特殊情况时,这些分析技巧同样有用
除了最常规的段错误和断言失败,核心转储在几种“冷门”场景里也有意想不到的用处。
6.1 死循环导致的服务假死,核心转储如何看
进程没有崩溃,但表现为 CPU 跑满、请求无响应,很多团队第一反应是让运维直接重启。可如果保留现场,通过gcore命令向运行中的进程发送 SIGSTOP 信号并生成转储文件,就可以在不杀进程的情况下拿到快照。
这个模式下,重点不是bt,而是thread apply all bt full,看一眼哪些线程卡在那条指令上。通常会发现多个工作线程卡在同一个锁的等待函数上,再加上主线程的状态,死锁原因一目了然。
6.2 内存泄漏,核心转储怎么辅助定位
定位内存泄漏最常用的是监控 RSS 曲线,但泄漏发生后,用核心转储可以进一步确认是哪个模块在持续分配内存。方法是在不同时间点生成两份 core 文件,用调试器分别打印某个容器的元素数量、链表长度、缓存节点统计值。如果某一类对象数量单调递增,泄漏源头基本就是那类对象。
我处理过的一次协议层泄漏,就是在两份 core 里对比后发现,连接对象的消息队列里积压了大量未处理报文,而这些报文对应的接收缓冲区一直没有被释放,最终才定位到状态机漏了一个清理分支。
6.3 崩溃现场的内存越界,十六进制内容里藏着线索
内存越界是最烦人的一类问题,因为它崩的位置和写坏内存的位置往往隔了十万八千里。这时候不要只盯着崩溃点,要把附近内存导出来用:
x/64bx 可疑地址-32仔细观察溢出区域的十六进制内容,经常能找到写下越界数据的那段特征的痕迹。比如字符串“HTTP/1.1”、某个枚举值、某种序列化协议的魔数,这就是定位写来源的关键指纹。上一次分析里,我就是靠越界区域里的一段POST字符串,把怀疑范围锁定到 HTTP 解析模块。
7. 最后再分享几条策略层面的心得
核心转储分析虽然技术性强,但它能不能发挥作用,很多时候取决于工程习惯。以下几条策略层面的心得,是长期排查经验中体会最深的部分。
第一,核心转储和日志不是替代关系,是互补关系。日志表达能力更强,适合记录业务层面的流转过程;核心转储更底层、更客观,适合还原内存层面的物理真相。合理的结合方式是在日志里记录“我准备做什么”,在核心转储里看“实际上发生了什么”。两者的时间线一对齐,很多悬案都能水落石出。
第二,分析核心转储需要一套属于自己的“假设-验证”循环。拿到 core 文件不要急着到处打印变量,先按这一章的流程走一遍:看栈、看帧、看内存,形成初步假设,再用更多命令去验证或推翻它。盲目的到处翻内存很容易被大量无关信息带偏。
第三,培养“崩溃现场优先”的思维。无论是谁的模块崩溃了,第一反应不是立即重启,也不是马上查日志,而是先确认 core 文件有没有落盘。养成这个习惯后,很多原本可能需要几天的偶发问题,会缩短到几十分钟。
最后一件事,核心转储分析这个技能,平时不用的时候觉得多余,真正用一次,可能就帮自己把一个星期的时间省回来了。建议找一台测试机器,写一个会按计划崩溃的小程序,亲手走一遍完整的配置、转储、分析流程,把这套手艺变成肌肉记忆。等到线上事故真正来的时候,你就已经是一个有备而来的排查者了。