ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux core文件调试实战:从崩溃现场到根因定位

Linux core文件调试实战:从崩溃现场到根因定位

1. 从一个凌晨三点的崩溃现场说起

进程莫名其妙没了,日志停在半截,重启之后一切正常,但问题还在那里悬着。这种场景做后端或者系统开发的人多少都遇到过。大多数时候我们靠日志、监控、指标三板斧去猜,但有些崩溃是猜不出来的——内存越界、野指针、栈溢出、多线程竞争,这些问题在日志里往往只留下一句"进程已退出",什么线索都没有。

core 文件就是为这种时刻准备的。它本质上是进程在崩溃瞬间的一份内存快照,包含寄存器状态、调用栈、堆内存、加载的共享库列表等等。有了它,你可以在进程已经死掉之后,用调试器把现场"复活",一步步回溯到出问题的那一行代码。这篇内容就是围绕怎么拿到 core 文件、怎么用调试器读它、怎么从里面挖出真正有用的信息来展开的,适合写过 C/C++、Rust、Go 这类需要和底层内存打交道的开发者,也适合运维同学排查线上服务异常退出。

我自己的经验是,core 文件这东西,会看的人觉得它是救命稻草,不会看的人觉得它是一堆没用的二进制垃圾。差别就在于有没有掌握那套"从崩溃现场倒推根因"的方法。下面我把自己这些年踩过的坑、总结的流程、以及一些容易被忽略的细节,完整地摊开讲一遍。

2. core 文件到底是怎么产生的

2.1 内核在进程死亡那一刻做了什么

当一个进程因为信号而异常终止时,操作系统内核会做一件事:检查这个进程是否被允许产生 core dump。如果允许,内核会把进程当前的虚拟内存空间、寄存器组、信号信息等打包写入一个文件,这个文件就是 core 文件。触发 core dump 的信号通常是 SIGSEGV(段错误)、SIGABRT(abort 调用)、SIGBUS(总线错误)、SIGFPE(浮点异常)、SIGILL(非法指令)这几类。

这里有个关键点很多人不知道:core 文件的生成不是"进程自己写的",而是内核代劳的。进程在收到致命信号的那一刻已经失去了执行能力,是内核接管了后续的转储工作。这也解释了为什么 core 文件的内容是"崩溃瞬间"的状态,而不是崩溃之后的状态——因为崩溃之后进程根本没有机会再执行任何代码。

内核转储的内容大致包括这几块:

  • 寄存器上下文:包括程序计数器、栈指针、通用寄存器等,这是定位崩溃点的核心依据
  • 栈内存:函数调用链、局部变量都在这里,是回溯调用栈的关键
  • 堆内存:动态分配的数据,排查内存破坏问题时要看
  • 代码段映射信息:哪些共享库被加载到了什么地址
  • 信号信息:是哪个信号、什么地址触发的

2.2 为什么你的机器上默认没有 core 文件

很多人第一次尝试找 core 文件时会发现根本找不到,这不是因为程序没崩溃,而是因为系统默认把 core dump 关掉了或者限制成了 0。控制这个行为的机制有两层。

第一层是ulimit 的 core file size 限制。用ulimit -c查看,如果返回 0,说明当前 shell 会话不允许产生 core 文件。临时打开可以执行:

ulimit -c unlimited

但要注意,这个设置只对当前 shell 及其子进程生效,换个终端就没了。要永久生效得写进 shell 的配置文件,或者通过系统的 limits 配置来设置。

第二层是core pattern 的配置。在 Linux 上,/proc/sys/kernel/core_pattern决定了 core 文件写到哪里、叫什么名字。默认值可能只是一个简单的core,也可能被某些发行版配置成了通过管道交给别的程序处理(比如交给系统崩溃收集服务)。如果这个文件里写的是|/usr/bin/something,那 core 文件就不会以普通文件形式出现在当前目录,而是被管道传走了。

cat /proc/sys/kernel/core_pattern

如果输出是core或者core.%p这类格式,说明会生成普通文件。%p是进程号,%e是可执行文件名,%t是时间戳。我一般会设成这样的格式,方便区分:

echo 'core.%e.%p.%t' > /proc/sys/kernel/core_pattern

注意:修改 core_pattern 需要 root 权限,而且在容器环境里这个文件往往是只读的,得在宿主机层面配置。

2.3 容器和 systemd 环境下的特殊处理

现在很多服务跑在容器里,容器默认的 core dump 行为经常让人抓狂。Docker 默认的 ulimit core size 是 0,而且即使你设了 unlimited,core 文件也可能写到容器内部某个你找不到的地方,或者因为存储驱动的问题根本写不出来。

我的做法是在启动容器时显式指定:

docker run --ulimit core=-1 --mount type=bind,source=/host/coredump,target=/coredump ...

然后在容器内把 core_pattern 指向/coredump目录。这样 core 文件会直接落到宿主机的挂载点上,不会因为容器销毁而丢失。

如果是 systemd 管理的服务,情况又不一样。systemd 有自己的 core dump 处理机制,默认会把 core 文件交给 systemd-coredump 服务,你可以用coredumpctl命令来查看和管理:

coredumpctl list coredumpctl dump <PID> -o /tmp/core.xxx

这个工具其实挺好用的,它自动帮你保存了每次崩溃的 core 文件,还带了元信息。但如果你习惯了直接拿 core 文件丢给 gdb,也可以把 systemd 的转储处理关掉,让它走传统的 core_pattern 路径。

3. 拿到 core 文件之后的第一步:别急着 gdb

3.1 先确认 core 文件和可执行文件是否匹配

我见过太多次这样的场景:同事兴冲冲跑过来说"core 文件拿到了,快帮我看看",结果 gdb 一加载,调用栈全是问号,符号表对不上。原因通常是 core 文件对应的可执行文件版本不对——可能是崩溃后重新编译过,也可能是部署时用了不同的构建产物。

所以拿到 core 文件的第一件事,是确认它和哪个可执行文件、哪个版本的共享库匹配。有几个办法可以交叉验证:

  • 用file命令看 core 文件的基本信息,它会告诉你这个 core 是由哪个可执行文件产生的
  • 用readelf -n core查看 core 文件里记录的 NT_FILE 信息,里面列出了崩溃时加载的所有映射文件路径
  • 对比可执行文件的构建 ID(build ID),readelf -n在可执行文件和 core 文件上都能看到
file core.12345 readelf -n core.12345 | grep -A2 "NT_FILE"

如果发现版本对不上,别浪费时间硬调,赶紧去找对应版本的二进制和符号文件。生产环境我强烈建议每次发布都把带符号的二进制归档保存,不然出了事只能干瞪眼。

3.2 符号表:没有它,core 文件就是天书

gdb 能读懂 core 文件的前提是有符号信息。编译时如果没加-g,调用栈里只能看到函数地址,看不到函数名、行号、变量名。对于线上发布版本,通常会用-O2 -g编译,然后用strip把符号剥掉减小体积,但一定要把剥掉的符号文件单独保存。

# 编译时保留符号 gcc -O2 -g -o myapp myapp.c # 发布时剥离符号,但保存符号文件 objcopy --only-keep-debug myapp myapp.debug strip myapp objcopy --add-gnu-debuglink=myapp.debug myapp

这样发布出去的myapp体积小,但调试时 gdb 能通过 debuglink 自动找到myapp.debug,符号信息就回来了。如果连 debuglink 都没做,也可以在 gdb 里手动指定符号文件:

gdb -ex "symbol-file myapp.debug" -ex "core-file core.12345" myapp

3.3 一个容易被忽略的坑:共享库版本

可执行文件的符号对上了,不代表共享库的符号也对得上。如果你的程序链接了某个第三方库,崩溃发生在那个库里面,而线上环境的库版本和本地不一致,调用栈照样是乱的。

我的习惯是在 gdb 里加载 core 之后,先执行info sharedlibrary看看加载了哪些库、路径是什么,然后和本地环境逐一比对。如果发现某个库版本不对,就去找到对应版本的库文件,在 gdb 里用set solib-search-path指定搜索路径。

(gdb) info sharedlibrary (gdb) set solib-search-path /path/to/correct/libs (gdb) sharedlibrary

4. 用 gdb 解剖 core 文件的完整流程

4.1 加载与初步观察

假设你手上有一个 core 文件core.myapp.12345和对应的可执行文件myapp,加载命令很简单:

gdb myapp core.myapp.12345

加载完成后,gdb 会直接停在崩溃点,并打印出触发崩溃的信号和地址。这时候不要急着敲bt,先做几件事。

第一,确认信号类型。SIGSEGV通常是访问了非法内存地址,SIGABRT往往是程序自己调了 abort 或者断言失败,SIGFPE是除零之类的算术异常。不同信号指向不同方向的问题。

第二,看崩溃地址。gdb 会显示类似Program terminated with signal SIGSEGV, Segmentation fault.后面跟着出错的内存地址。如果地址是0x0或者很小的值,多半是空指针解引用;如果是很大的随机值,可能是野指针或者内存被踩了。

第三,执行bt看调用栈。这是最核心的一步,调用栈会告诉你崩溃发生在哪个函数、被谁调用、一路回溯到 main。如果栈被破坏了,bt可能会显示不完整或者乱掉,这时候需要更高级的技巧,后面会讲。

4.2 读懂调用栈里的每一层

一个典型的调用栈长这样:

#0 0x00007f8b2c3d4e5f in process_data (buf=0x0, len=100) at data.c:42 #1 0x0000000000401234 in handle_request (req=0x7fff5a3b4c00) at server.c:88 #2 0x0000000000400abc in main (argc=2, argv=0x7fff5a3b4e08) at main.c:15

每一层包含的信息量很大:帧号、返回地址、函数名、参数值、源文件和行号。#0是崩溃发生的当前帧,#1是调用者,以此类推。

看调用栈的时候要重点关注几个东西:

  • 参数值:比如上面buf=0x0,说明传进来一个空指针,而process_data里大概率没做空指针检查就直接用了,这就是根因
  • 行号:data.c:42直接指向出问题的代码行,去翻源码就能看到具体逻辑
  • 帧的完整性:如果中间某一层显示??或者地址明显不对,说明栈可能被破坏了

我一般会配合frame命令切换帧,然后用info locals看局部变量,用info args看参数,用print打印具体值。比如:

(gdb) frame 0 (gdb) info args (gdb) info locals (gdb) print *buf

4.3 当调用栈被破坏时怎么办

栈被破坏是 core 调试里最头疼的情况。常见于缓冲区溢出、栈上数组越界写、格式化字符串漏洞等。表现就是bt出来的栈乱七八糟,或者 gdb 直接报Backtrace stopped: previous frame inner to this frame。

这时候有几个应对策略。

第一个是看寄存器和栈内存的原始数据。即使符号化的调用栈没了,栈上的返回地址还在,你可以手动去栈内存里找。用info registers看rsp和rbp(x86-64 架构),然后x/64gx $rsp把栈顶的内存 dump 出来,里面会有返回地址。拿到返回地址后用info symbol <address>反查是哪个函数。

第二个是用bt的替代命令。gdb 有个bt full会打印更多信息,还有bt -frame-info之类的选项。另外可以试试thread apply all bt看所有线程的栈,有时候崩溃线程的栈坏了,但别的线程栈是好的,能提供线索。

第三个是借助外部工具。gdb之外,eu-stack(elfutils 工具集里的)有时候对损坏栈的解析能力更强。还有crash工具,不过那个更多用于内核 core。

4.4 多线程 core 的调试要点

多线程程序的 core 文件里包含所有线程的状态。gdb 加载后会默认停在崩溃的那个线程,但其他线程的信息同样重要,尤其是死锁或者竞争条件导致的崩溃。

(gdb) info threads (gdb) thread apply all bt

info threads列出所有线程,前面带*的是当前线程。thread apply all bt一次性打印所有线程的调用栈,这个输出通常很长,但值得仔细看。我排查过的一个案例是主线程在等锁,工作线程在持锁状态下崩溃了,如果不看所有线程的栈,根本发现不了这个关系。

切换线程用thread <编号>,然后就可以像单线程一样查看那个线程的局部变量和栈。

5. 从 core 文件里挖出根因的实战思路

5.1 空指针与野指针的区分方法

空指针解引用和野指针访问在 core 文件里的表现有细微差别。空指针的崩溃地址通常是0x0或者一个很小的偏移量(比如访问结构体成员ptr->field,而ptr是 NULL,地址可能就是0x8这种)。野指针的地址则往往是看起来"像那么回事"的地址,比如0xdeadbeef、0x5a5a5a5a,或者一个已经释放的堆地址。

判断方法:看崩溃地址,然后用info symbol查这个地址属于哪个映射区域。

(gdb) info symbol 0xdeadbeef (gdb) info proc mappings

如果地址不在任何合法映射范围内,基本可以确定是野指针。如果地址在堆区但内容明显不对,可能是 use-after-free。

5.2 内存踩踏问题的定位

内存踩踏(memory corruption)是最难查的一类问题,因为崩溃点往往不是破坏点。比如 A 处代码写越界了,破坏了 B 处的数据结构,等到 B 处被访问时才崩溃。core 文件只能告诉你 B 处崩了,但真正的凶手在 A 处。

这时候需要结合其他手段。如果能在测试环境复现,可以用 AddressSanitizer 重新编译跑一遍,它能直接定位到越界写的那一行。如果只能靠 core 文件,那就得仔细看崩溃点附近的内存布局,看看有没有明显的"被覆盖"痕迹,比如本该是函数指针的地方变成了一个奇怪的数值。

我遇到过一个典型案例:一个结构体里的函数指针被覆盖成了0x41414141(就是 "AAAA" 的 ASCII),一看就是字符串拷贝越界写进去的。顺着这个线索去找哪里往这个结构体附近拷贝了字符串,很快就定位到了问题。

5.3 堆内存的检查技巧

gdb 提供了一些堆检查命令,虽然不如专门的内存检查工具强大,但在 core 调试里也能用。

(gdb) info malloc (gdb) heap

不过这些命令依赖 glibc 的调试支持,不一定在所有环境都可用。更通用的办法是手动检查堆块。glibc 的 malloc 在每个堆块前面有一个 chunk header,包含 size 和标志位。如果这个 header 被破坏了,free的时候就会报invalid pointer或者corrupted size vs. prev_size之类的错误。

在 core 文件里,你可以用x命令查看堆块附近的内存,看看 chunk header 是否合理。正常的 size 字段应该是 16 的倍数加上标志位,如果看到一个明显不合理的值,说明这里被踩过。

5.4 寄存器与汇编层面的分析

有时候源码级别的信息不够,需要下沉到汇编层面。gdb 的disassemble命令可以反汇编当前函数,x/i $pc可以看当前指令。

(gdb) disassemble /s (gdb) x/10i $pc-20

/s选项会把源码和汇编混在一起显示,非常直观。看汇编的时候重点关注崩溃指令的操作数:它在访问哪个寄存器指向的内存?那个寄存器的值是从哪里来的?顺着数据流往回追,往往能找到问题源头。

6. 那些年我在 core 调试上踩过的坑

6.1 core 文件太大,加载慢得让人崩溃

一个跑了很久的服务,内存占用几个 G,core 文件也是几个 G。gdb 加载这种 core 文件可能要几分钟,而且吃内存。我的应对办法是:

  • 用gdb的--readnow选项控制符号加载时机,默认是懒加载,其实还好
  • 如果只是看调用栈,可以用eu-stack或者gdb的-batch模式配合bt命令,不加载全部符号
  • 实在太大就先用gdb的dump功能把关键内存区域单独 dump 出来分析
gdb -batch -ex "bt" myapp core.12345

这个命令不进入交互界面,直接打印调用栈就退出,速度快很多。

6.2 优化过的代码导致变量"消失"

-O2优化会把很多局部变量优化到寄存器里,或者直接消除掉。在 gdb 里print一个变量,可能得到<optimized out>。这不是 gdb 的问题,是编译器优化的结果。

应对方法:如果某个变量特别关键,可以在源码里加volatile修饰,或者用__attribute__((used))防止被优化掉。但更实际的做法是,接受这个现实,转而从寄存器或者内存里手动推算。比如变量被优化到rax寄存器里了,那就print $rax。

6.3 容器里的 core 文件路径找不到

前面提过容器 core dump 的问题,这里再补充一个细节:即使你配置了 core_pattern,容器里的进程崩溃后 core 文件可能出现在宿主机的/var/lib/systemd/coredump或者别的什么地方,而不是你期望的目录。这时候用coredumpctl在宿主机上查是最快的。

另外,如果容器用的是只读文件系统,core 文件根本写不进去。这种情况要么把 core_pattern 指向一个可写的挂载卷,要么用管道方式把 core 传给一个处理程序。

6.4 符号服务器与自动化分析

在大规模生产环境里,手动拿 core 文件、找符号、开 gdb 效率太低。成熟的做法是搭建符号服务器,配合自动化的 core 收集和分析流水线。进程崩溃后 core 文件自动上传,后台服务自动匹配符号、生成调用栈报告、甚至做去重和聚类。

这套东西搭建起来有成本,但对于服务数量多、崩溃频率高的团队来说,收益很大。我见过的最小可用方案是:一个脚本监听 core 目录,发现新文件就调用 gdb 批处理生成报告,然后把报告发到告警群。虽然简陋,但比人工处理快得多。

7. 让 core 文件真正成为你的调试利器

core 文件的价值不在于它本身,而在于你能不能快速从中提取出有用信息。我自己的习惯是,每次遇到新的崩溃类型,都把分析过程记录下来,形成一套自己的"症状-排查-根因"对照表。时间长了,看到崩溃地址和调用栈的特征,基本就能猜个八九不离十。

还有一点很重要:不要等到崩溃了才去准备。符号文件的归档、core_pattern 的配置、调试工具的安装,这些都应该在服务上线前就做好。我见过太多团队出事之后才发现符号文件没保存、core 文件没生成,白白浪费了定位问题的最佳时机。

最后分享一个我常用的小技巧:在 gdb 里用define命令定义一套自己的快捷命令,把常用的排查步骤串起来。比如一键打印所有线程的栈、一键查看崩溃点附近的源码和寄存器。这样每次调试不用重复敲一堆命令,效率能提升不少。

(gdb) define crashinfo >bt full >info registers >info threads >thread apply all bt >end

定义好之后,下次加载 core 文件,直接敲crashinfo就能把关键信息一次性拉出来。这种小工具积累多了,调试 core 文件就从"苦差事"变成了"按流程走"的常规操作。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进