凌晨三点,服务器告警骤响。一个运行了三周的生产进程突然退出,日志只剩下冷冰冰的"Segmentation fault (core dumped)"。没有堆栈信息,没有用户请求上下文,一切似乎都随着进程的消亡而烟消云散。

但事实并非如此。那个被悄悄写入磁盘的 core 文件,保存了程序崩溃瞬间的完整内存映像。它不会说话,却记录着一切真相。

一个名字的六十年回响

“Core dump” 这个词在 1959 年首次出现在计算机文献中。那时的"core"并非指 CPU 核心,而是指磁芯存储器(magnetic core memory)——一种由细小磁环编织而成的早期存储技术。

每个磁环只能存储一个比特,通过导线穿过其中进行读写。一个 32×32 的磁芯板只能存储 128 字节。当程序出错时,工程师会把整个磁芯存储器的内容"倾倒"(dump)出来,打印在几米长的折叠纸带上,逐位分析。

磁芯存储器早已退出历史舞台,但这个名字被保留了下来。今天的 core dump 不再打印在纸带上,而是以 ELF 格式写入磁盘,体积也从几百字节膨胀到几 GB。但它的本质没有变:一张程序死亡现场的快照

ELF 格式:用结构化的方式存储混乱

Linux 的 core dump 使用 ELF(Executable and Linkable Format)格式。这并非巧合——ELF 本身就是为描述程序如何加载到内存而设计的,用它来描述程序在内存中的状态顺理成章。

一个 core dump 文件的 ELF 头部会标记 e_type = ET_CORE,表明这是一个核心转储文件。真正的内容存在于两类程序头(Program Header)中:

PT_LOAD 段:描述进程的内存区域。每个段记录了虚拟地址、大小、权限标志,以及实际存储的数据。这些段可能对应:

  • 代码段(只读可执行)
  • 数据段(读写)
  • 堆(动态分配的内存)
  • 栈(每个线程一个)
  • 内存映射文件
  • 共享库

PT_NOTE 段:存储元数据,以"笔记"的形式组织。每条笔记包含名称、类型和数据三部分:

  • NT_PRSTATUS:寄存器状态,包括崩溃时的指令指针、栈指针
  • NT_PRPSINFO:进程信息,如进程名、参数列表
  • NT_SIGINFO:导致崩溃的信号详情
  • NT_AUXV:辅助向量,包含程序加载时的环境信息
  • NT_FILE:内存映射文件列表,记录哪些内存区域对应哪些文件

这种设计的一个精妙之处在于:对于只读的内存映射文件(如共享库的代码段),core dump 不必重复存储其内容,只需记录文件路径和偏移量。调试器会从原始文件中读取。这大大减小了 core dump 的体积。

信号的判决:谁有资格留下遗言

并非所有程序退出都会产生 core dump。内核只会在进程收到特定信号时才触发转储:

信号 编号 含义
SIGSEGV 11 段错误,访问非法内存
SIGABRT 6 程序主动中止(abort)
SIGBUS 7 总线错误,对齐问题
SIGFPE 8 浮点异常,如除零
SIGILL 4 非法指令
SIGQUIT 3 终端退出请求

其中 SIGSEGV 是最常见的"杀手"。当程序尝试访问未映射的内存地址、写入只读区域、或执行没有执行权限的代码时,CPU 触发页面错误异常,内核将其转换为 SIGSEGV 信号发送给进程。

信号的来源决定了能否生成 core dump。可以通过 ulimit -c 命令查看当前限制:

$ ulimit -c
0           # 默认禁用
$ ulimit -c unlimited  # 启用,无大小限制

但即便 ulimit 允许,某些情况下 core dump 仍会被禁止:

  • SUID/SGID 程序:出于安全考虑,默认不生成 core dump(可通过 /proc/sys/kernel/suid_dumpable 调整)
  • 已自定义信号处理:如果程序注册了 SIGSEGV 处理函数且未重新触发默认行为
  • 容器环境:可能在 namespace 隔离后无法写入

内核的死亡证明签发流程

当信号触发 core dump 时,内核会执行一系列精密操作:

第一步:检查权限。查看进程的 dumpable 属性。对于 SUID 程序,这个属性由 /proc/sys/kernel/suid_dumpable 控制,默认为 0(禁止转储)。

第二步:确定输出方式。读取 /proc/sys/kernel/core_pattern

$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %c %h

如果第一个字符是 |,内核会把 core dump 通过管道传给指定程序处理(如 systemd-coredump);否则按路径模板写入文件。模板支持多种展开符:

  • %p:进程 ID
  • %u:用户 ID
  • %s:信号编号
  • %e:程序名

第三步:遍历内存映射。通过 /proc/PID/maps 获取所有 VMA(Virtual Memory Area),根据 /proc/PID/coredump_filter 决定哪些区域需要转储:

$ cat /proc/self/coredump_filter
00000033

这是一个位掩码,每一位代表一类内存:

  • bit 0:匿名私有内存(栈)
  • bit 1:匿名共享内存
  • bit 2:文件映射私有内存
  • bit 3:文件映射共享内存
  • bit 4:ELF 头
  • bit 5:大页私有内存
  • bit 6:大页共享内存

默认值 0x33 表示转储栈、匿名共享内存、ELF 头和大页内存,但不转储文件映射内容——因为后者可以从原始文件恢复。

第四步:写入 ELF 文件。按 PT_NOTE(寄存器等元数据)和 PT_LOAD(内存内容)的顺序组织数据,写入输出。

整个过程对进程是透明的——进程已经死亡,它在内存中的最后一刻被永久定格。

GDB 的现场重建术

拿到 core dump 后,GDB 如何重建崩溃现场?

第一步:加载可执行文件。GDB 需要原始的可执行文件(以及可能的共享库)来获取符号表和调试信息:

$ gdb ./my_program core.12345

为什么需要可执行文件?Core dump 只包含内存内容和寄存器状态,不包含:

  • 函数名和变量名(符号表)
  • 源代码行号映射(调试信息)
  • 类型定义(结构体布局等)

这些信息存储在可执行文件的 .symtab.debug_* 段中。

第二步:重建内存布局。解析 PT_LOAD 段,恢复进程的虚拟地址空间。对于 FileSiz 为 0 的段(只读文件映射),GDB 会从原始文件中读取。

第三步:恢复线程上下文。从 NT_PRSTATUS 笔记中提取每个线程的寄存器值:

  • rip/eip:指令指针,指向崩溃时的代码位置
  • rsp/esp:栈指针
  • rbp/ebp:帧指针(如果使用)
  • 其他通用寄存器

第四步:回溯调用栈。这是最复杂的部分。GDB 需要从当前的栈帧开始,逐帧回溯:

对于帧指针方式:编译器在函数开头会保存旧的帧指针(push %rbp; mov %rsp, %rbp),栈中形成链表结构。GDB 只需跟随这个链表即可。

但现代编译器默认省略帧指针-fomit-frame-pointer)以节省一个寄存器。这时 GDB 必须依赖 DWARF 调试信息

DWARF 的 .eh_frame 段包含"调用帧信息"(Call Frame Information),以字节码形式描述每个指令地址对应的栈帧布局:

.cfi_startproc
pushq   %rbp
.cfi_def_cfa_offset 16    # CFA(规范帧地址)= rsp + 16
.cfi_offset %rbp, -16      # rbp 保存在 CFA - 16
movq    %rsp, %rbp
.cfi_def_cfa_register %rbp # CFA 现在通过 rbp 计算

GDB 会执行这些指令,模拟栈帧的变化,从而计算出返回地址和上一帧的位置。

调试信息的分离与重聚

生产环境的可执行文件通常会 strip 掉调试信息以减小体积:

$ strip --strip-debug ./my_program

这会导致 core dump 分析变成"裸读"——只能看到机器码地址,没有函数名和行号。

解决方案是调试信息分离

# 提取调试信息到单独文件
$ objcopy --only-keep-debug ./my_program ./my_program.debug
# 剥离原文件
$ strip --strip-debug ./my_program
# 添加 .gnu_debuglink 段指向调试文件
$ objcopy --add-gnu-debuglink=./my_program.debug ./my_program

GDB 会自动查找并加载对应的调试文件。更现代的方式是使用 build-id:每个编译产物有唯一的 160 位标识符,调试器可以通过 debuginfod 服务按需下载调试信息。

ASLR:随机化的代价

地址空间布局随机化(ASLR)是一项重要的安全机制,它让栈、堆、共享库的加载地址每次运行都不同。但这给 core dump 分析带来了挑战:

问题一:符号地址不匹配。共享库中的函数地址在编译时未知,需要运行时重定位。Core dump 记录的是运行时地址,调试器需要知道加载偏移量才能映射回符号表。

解决方案NT_FILE 笔记记录了每个共享库的加载地址范围。GDB 结合 .dynamic 段中的 DT_DEBUG 条目(指向 r_debug 结构),可以重建动态链接器的加载信息。

问题二:跨机器调试困难。如果 core dump 生成在 A 机器,在 B 机器上分析,可能遇到:

  • 相同的共享库文件不同版本
  • 内核版本不同
  • 调试信息不匹配

解决方案:使用容器或虚拟机保持环境一致,或者使用 debuginfod 按需获取匹配的调试文件。

体积与信息的权衡

一个使用了大量堆内存的程序,其 core dump 可能达到数 GB。在存储受限的嵌入式设备或容器环境中,这是不可接受的。

策略一:过滤内存类型。通过 coredump_filter 排除堆:

# 只保留栈、共享内存、ELF头
$ echo 0x13 > /proc/self/coredump_filter

策略二:截断栈深度。每个线程的栈可能很大,但只有栈顶部分包含有用信息。可以只保存每个栈的前 8KB 或 16KB。

策略三:设备端回溯。在生成 core dump 之前,直接在设备上进行栈回溯,只输出函数名和行号列表。这需要设备上有足够的符号信息,但输出体积极小。

Memfault 的实践表明,通过这些优化,一个 2.6 MB 的完整 core dump 可以压缩到约 75 KB,体积减少 35 倍,同时仍能获取完整的调用栈。

安全暗流:当诊断工具成为攻击面

2025 年 5 月,Qualys 披露了 CVE-2025-4598,一个影响 systemd-coredump 的 TOCTOU(Time-of-Check-Time-of-Use)漏洞。

攻击原理是利用 PID 重用窗口:当 SUID 程序崩溃时,systemd-coredump 会从 /proc/$PID/auxv 读取进程信息来决定 core dump 的访问权限。如果攻击者能在窗口期内让内核重用该 PID(通过快速创建大量进程),并在 systemd-coredump 读取时用普通进程"抢占"该 PID,systemd-coredump 就会误判进程类型,将本应只有 root 可访问的 SUID 进程 core dump 暴露给普通用户。

这个漏洞揭示了一个深层问题:core dump 系统横跨内核态和用户态,涉及进程生命周期管理,其攻击面比想象中更大

缓解措施包括:

  • 设置 suid_dumpable=0(完全禁止 SUID 程序的 core dump)
  • 升级到使用内核 6.15 的 pidfd 机制的系统(pidfd 不会因 PID 重用而指向不同进程)

超越快照:时间旅行调试

Core dump 是静态的——它只捕获一个瞬间。但有些 bug 需要理解"如何到达这一刻"才能定位。

Mozilla 开发的 rr 项目提供了一种可能:它通过记录所有非确定性事件(系统调用结果、信号、线程调度顺序),实现对程序执行的确定性重放

$ rr record ./my_program
# 程序运行并被记录
$ rr replay
# 可以在任何时刻暂停、反向执行、设置反向断点
(gdb) reverse-continue

rr 的开销很低(通常小于 2x),适合在 CI 环境中使用。但它依赖特定的硬件特性(性能计数器)和内核版本,且无法用于已经在生产环境中崩溃的程序。

对于后者,core dump 仍是不可替代的"黑匣子"。

结语

程序崩溃后的 core dump 就像案发现场的法医证据:它不会主动告诉你真相,但只要你懂得如何解读,它会回答所有问题。

从磁芯存储器时代的纸带打印,到今天基于 ELF 的结构化存储;从简单的寄存器转储,到包含完整内存映像的诊断文件;从依赖帧指针的朴素回溯,到利用 DWARF 字节码的精确重建——core dump 技术在六十年间不断演进,但其核心价值从未改变:让死去的进程开口说话

下次看到"Segmentation fault (core dumped)“时,别急着重启服务。那个默默生成的 core 文件,可能正藏着你需要的答案。

参考资料

  • Linux Coredumps (Part 1-3), Memfault Interrupt Blog
  • Anatomy of an ELF core file, Gabriel Féron
  • What’s Inside a Linux Kernel Core Dump, Oracle Linux Blog
  • Stack unwinding, MaskRay
  • Analysis of CVE-2025-4598: systemd-coredump, Oracle Linux Blog
  • Core dump - Wikipedia
  • proc_pid_coredump_filter(5) - Linux manual page
  • core(5) - Linux manual page
  • rr: lightweight recording & deterministic debugging, rr-project.org
  • Hacker Folklore, Matthias Endler
  • DWARF Debugging Information Format, dwarfstd.org