好多年前我第一次认真研究崩溃分析时,干过一件现在看来有点蠢的事:写了一个必崩的C程序,满怀期待地跑完,却发现当前目录里干干净净,连个core文件影子都没有。当时我还以为内核转储失败,折腾半天才明白,真正的问题出在shell里那个默认的ulimit -c 0。后来养成了习惯,凡是做coredump实验,先把“现场保留能力”检查一遍,再谈分析。
这也是这篇“coredump的实验及原理分析二”我想聊的起点。第一篇文章往往讲“core是什么、怎么看栈”,这次我想往更深处走一步:把生成链路、信号机制、文件内部结构,以及现代systemd和容器环境下core文件“消失”的原因串起来,配合一组可控实验,让你以后遇到崩溃问题,能按图索骥地拿到想要的那份现场。文章面向已经知道gdb和core是怎么回事、但还想搞清楚底层逻辑,或者正在被“为什么没core”折磨的读者。
1. 先把崩溃现场“逼出来”:coredump实验前的三道关卡
1.1 一个看似简单却没产出core的失败实验
先从一个最小例子开始。写一段必然崩溃的代码,随便叫什么crash_null.c:
#include <stdio.h> int main(void) { int *p = NULL; *p = 1; return 0; }编译时注意关掉优化、保留符号和栈帧:
gcc -O0 -g -fno-omit-frame-pointer -o crash_null crash_null.c运行一下:
./crash_null大概率会看到类似“Segmentation fault (core dumped)”的输出,也可能没有后面括号里的那句“core dumped”。如果当前目录里没有生成core文件,就先别急着怀疑内核,绝大多数情况下是下面的某个开关没开。
1.2 三个必须确认的开关:ulimit、core_pattern、目录权限
第一道关卡是资源限制。内核在决定要不要生成core文件时,会先检查进程的RLIMIT_CORE。为0时直接放弃转储,连文件都不会创建。查看和修改方法:
ulimit -c # 查看当前限制,0 表示禁止 ulimit -c unlimited这个设置只影响当前shell及其子进程。想要对登录用户长期生效,可以写在/etc/security/limits.conf里,比如:
* soft core unlimited第二道关卡是/proc/sys/kernel/core_pattern。这个文件决定了core文件生成到哪个路径、叫什么名字,甚至决定是否交给外部程序处理。查看方式:
cat /proc/sys/kernel/core_pattern如果输出类似:
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h说明core文件根本不会直接出现在当前目录,而是被管道交给systemd-coredump接管了。实验期想简单粗暴一点,可以直接改成显式路径:
mkdir -p /tmp/cores sudo sysctl -w kernel.core_pattern=/tmp/cores/core.%e.%p.%t这里的%e是可执行文件名,%p是PID,%t是时间戳。
第三道关卡是写权限。core文件是内核代表崩溃进程去写的,目录必须对崩溃进程的uid可写。有些同学把core_pattern指向/proc或系统根目录,结果因为权限问题拿不到文件。
提示:如果程序由systemd服务管理,还要检查单元配置里的
LimitCORE。不少服务默认把core限制为0,需要在unit文件的[Service]段里加上LimitCORE=infinity。
1.3 我习惯的标准化实验环境
为了不浪费每次崩溃现场,我的实验环境一般固定这样配置:
mkdir -p /tmp/cores sudo sysctl -w kernel.core_pattern=/tmp/cores/core.%e.%p.%t ulimit -c unlimited这样每个崩溃程序都会在/tmp/cores下生成带程序名、PID、时间戳的独立文件,后续用脚本批量分析也方便。生产环境不要照抄这个配置,但实验环境里它足够简单可控。
2. 从SIGSEGV到ELF文件:一次崩溃在内核里的完整旅程
2.1 从CPU访存异常到信号投递
刚才的空指针解引用,在x86-64平台上实际上是CPU执行movl $0x1, (%rax),而%rax寄存器里是0。CPU去访问地址0对应的物理页时,MMU发现页表项不合法,产生一个page fault异常,CPU陷入内核。
内核的异常处理流程会检查这个地址是否属于某个用户态进程的虚拟内存区域(VMA)。结果发现虚拟地址0根本不在进程的地址空间映射里,或者映射本身禁止写操作,于是内核决定把这个异常解释为进程访问了非法地址,进而给当前线程投递SIGSEGV信号。
每种信号都有自己的“默认动作表”。SIGSEGV、SIGABRT、SIGBUS、SIGILL、SIGFPE这些信号的默认动作除了终止进程之外,还带一个“生成core文件”的标志。所以不是所有信号都能产生core,比如SIGTERM默认只是终止进程,不会留下镜像。
这里有个细节值得注意:如果进程给SIGSEGV注册了自定义处理函数,内核会把控制权交回用户态,跳到处理函数执行。处理函数返回后,如果程序又回到了那条非法指令,CPU会再次触发同样的异常。这时通常会发生第二次SIGSEGV,而由于信号处理函数没有覆盖新的信号,最终还是会走默认的core路径。这也是调试时偶尔看到“信号处理函数本身崩溃”的原因之一。
2.2 do_coredump:核心转储的流水线
当前线程没有可用的信号处理函数、信号动作进入默认core路径后,内核会调用内部的核心转储逻辑。它做的事情大致有几件:
- 检查
RLIMIT_CORE是否允许转储,以及当前进程是否有转储权限; - 根据
core_pattern生成文件路径和文件名,支持%e、%p、%u、%g、%s、%t等占位符; - 打开目标文件;如果是管道模式,则把内核数据喂给管道另一端的外部程序;
- 遍历进程的虚拟内存区域,按
coredump_filter过滤后,把需要转储的页面写入文件; - 同时把崩溃线程(其实也包括进程组内其他线程)的寄存器状态、信号信息、环境变量、辅助向量等打包成ELF的note段写进去。
最终得到的core文件本质就是一个ELF格式的目标文件,类型是ET_CORE。它里面的PT_LOAD段描述了当时进程地址空间的镜像,而PT_NOTE段里保存了寄存器、信号号、线程状态等元信息。
2.3 为什么core文件里不是“全部内存”:coredump_filter
很多人拿到core后第一反应是“文件好小”,或者“怎么才几十KB”。这其实是正常的。内核默认不会把进程的全部虚拟地址空间都倒进core文件,很多文件映射的内存页(比如可执行文件本身、共享库的代码段)是可以从磁盘重新加载的,没必要完整复制。
控制这个行为的开关是/proc/PID/coredump_filter,它是一个位图:
- 第0位:匿名私有页
- 第1位:匿名共享页
- 第2位:文件私有页
- 第3位:文件共享页
- 第4位:ELF文件头页
- 第5位:hugetlb页
常见的默认值0x33表示只转储匿名私有页、匿名共享页和ELF头部页,所以核心文件不会包含全部代码段,自然也不会大到离谱。当需要完整转储时,可以在进程运行期间往/proc/PID/coredump_filter写更大的掩码。这一点在分析某些依赖共享库状态的问题时有价值,但大多数排障用默认值就够。
3. 五种崩溃信号对照实验:core文件里藏着哪些证据
3.1 实验设计:制造五种不同的“死法”
与其死记信号表,不如亲手跑一组实验。我在同一个源文件里用子命令区分五种崩溃方式,关键代码片段如下:
// mode=null:空指针解引用 int *p = NULL; *p = 1; // mode=fpe:整数除零 volatile int z = 0; (void)(100 / z); // mode=ill:非法指令(x86-64) __asm__ volatile("ud2"); // mode=bus:访问mmap文件末尾后的地址 int fd = open("empty.bin", O_CREAT | O_RDWR, 0600); char *m = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); m[0] = 'x'; // mode=abort:主动中止 abort();编译成同一个多模式程序:
gcc -O0 -g -fno-omit-frame-pointer -o crash_modes crash_modes.c然后分别运行./crash_modes null、./crash_modes fpe……每个都会在/tmp/cores下生成对应的core文件。
注意:
volatile在除零实验里很重要,否则某些编译器可能在编译期就算出结果、把除法优化掉,实验就没意义了。
3.2 实验现象记录与core签名
我把实验现象整理成一张表,方便对照:
| 崩溃模式 | 信号 | si_signo | 进程退出码 | dmesg典型信息 |
|---|---|---|---|---|
| null | SIGSEGV | 11 | 139 | segfault at 0 ... in crash_modes |
| fpe | SIGFPE | 8 | 136 | divide error |
| ill | SIGILL | 4 | 132 | trap invalid opcode |
| bus | SIGBUS | 7 | 135 | bus error ... |
| abort | SIGABRT | 6 | 134 | 无独立内核故障日志 |
这里的进程退出码等于128 + si_signo,也是shell判断一个进程是否死于信号的方式。dmesg那列很有意思:硬件异常类(SIGSEGV、SIGFPE、SIGILL、SIGBUS)内核会留一条日志,而abort()是进程主动通过系统调用给自己发信号,内核没有对应的硬件故障可记录,dmesg里往往什么都没有。
用较新版本的binutils读core文件的note段,可以看到类似:
CORE NT_SIGINFO (si_signo, si_pid, si_uid)si_signo就是信号编号。比如SIGSEGV对应11,SIGABRT对应6。如果还想进一步区分是硬件触发还是软件主动发送,可以看note里的si_code字段:硬件异常通常是SEGV_MAPERR、SEGV_ACCERR这类非法访问原因,软件raise或tgkill发送则是SI_TKILL这类用户态来源。
3.3 从差异里能看出什么
这组实验告诉我们一件事:core文件不是万能“黑匣子”,它忠实记录的是“进程死在哪”,至于“为什么会死”得靠信号类型、栈、寄存器一起反推。
比如同样以“段错误”结尾的崩溃,原因可能是空指针解引用,也可能是栈被局部变量覆盖后函数返回地址损坏,还可能是传给某库函数一个已经释放的指针。信号名只是第一层分类,真正定位要靠下一节的取证手段。
4. core文件的取证顺序:别急着敲gdb
4.1 先用gdb固定“案发现场”
拿到core文件后,我的固定动作是先打开gdb,稍作全局观察再动手:
gdb ./crash_modes /tmp/cores/core.crash_modes.12345.1700000000进入gdb后第一件事不是bt,而是先看gdb自动加载崩溃摘要,类似:
Core was generated by `./crash_modes null'. Program terminated with signal SIGSEGV, Segmentation fault.然后执行:
(gdb) bt (gdb) info registers (gdb) x/20i $pc-20bt看调用栈,info registers看寄存器现场,x/20i看崩溃点附近的反汇编。这三组信息足够回答“在哪一行、什么条件下崩的”。
如果程序是多线程,一定要跑一下:
(gdb) thread apply all bt很多时候崩溃线程只是“受害者”,真正改坏内存的可能是后台线程。只盯着崩溃线程的栈,容易被误导。
4.2 readelf能从core文件里读出什么
gdb用来读解析后的世界,readelf则用来读文件本身的结构。我经常查这几项:
file /tmp/cores/core.crash_modes.12345.1700000000 readelf -h /tmp/cores/core.crash_modes.12345.1700000000 readelf -l /tmp/cores/core.crash_modes.12345.1700000000 readelf -n /tmp/cores/core.crash_modes.12345.1700000000file会告诉你这确实是一个ELF core文件;readelf -l能看到所有PT_LOAD段和PT_NOTE段的范围;readelf -n能看到信号号、崩溃PC、进程信息等。当gdb由于缺少符号或者二进制版本不一致无法正常还原栈时,readelf -n里的原始寄存器值反而是最可信的底线证据。
4.3 ASLR与地址随机:稳定定位的关键
现在Linux默认开启ASLR,每次运行程序,加载地址都不一样。所以core文件里的崩溃地址是“随机”的,不能拿这个具体数值直接跟另一个环境比对。
但ASLR改变的是基址,不是偏移。比如libc的某函数崩溃地址是0x7f...a1a,而info sharedlibrary里libc加载基址是0x7f...000,两者相减得到的偏移是稳定的。用这个偏移配合同样版本的libc和debuginfo,才能准确还原栈帧。
如果二进制被strip过,bt很可能全是??。这种情况下优先找对应的debuginfo包,找不到就退而求其次,用readelf -n拿到崩溃PC,再通过addr2line或readelf -s尝试解析符号。能看多少看多少,至少把崩溃函数范围缩小到某个偏移附近。
注意:保存core文件时,最好连同崩溃进程当时的
/proc/PID/maps、二进制文件的md5、共享库版本一起存档。没有这些,事后分析可能被符号和库版本问题卡住。
5. 当core“说不了人话”时:堆破坏现场的间接取证
5.1 堆破坏现场的“模糊栈”
有一类崩溃最让人头疼:core文件确实生成了,栈也打得出来,但栈顶停在free()或malloc()内部,完全看不到真正写坏内存的那一行代码。这就是典型的堆破坏或use-after-free。
构造一个能复现的场景:
#include <stdlib.h> #include <string.h> int main(void) { char *a = malloc(64); char *b = malloc(64); memset(a, 'A', 256); // 越界写,破坏了b的chunk头 free(a); free(b); return 0; }编译运行后,gdb看到栈大致是:
#0 __pthread_kill #1 raise #2 abort #3 malloc_printerr #4 _int_free #5 free #6 main问题来了:明明越界写在memset那一行,为什么崩溃在free()里面?因为glibc的malloc实现只有在free时检查chunk头的一致性,发现size字段被写花,才调用malloc_printerr报告异常并abort。core文件记录的是“报告的现场”,不是“犯罪的现场”。
5.2 反向利用错误的字符串
malloc_printerr会打印一条错误说明,比如:
free(): invalid next size (fast)这条信息本身很有价值,它说明越界写覆盖了某个fastbin中chunk的size字段。你可以把它当作线索去对比代码里的内存操作,但前提是知道堆布局的内存管理策略。
查看core里相关变量的值有时也有帮助:
(gdb) p a (gdb) p b如果a、b的指针本身已经被写坏,那么问题可能不是越界写,而是它们所在的内存区域被更早的溢出覆盖了。指针值本身也是证据。
5.3 用ASAN和环境变量把“延迟崩溃”提前
这类问题的标准解法不是继续在core文件里刨,而是换一个能早报告的检测工具。AddressSanitizer(ASAN)是最直接的一招:
gcc -fsanitize=address -g -o crash_asan crash.c ./crash_asanASAN会直接指认:
ERROR: AddressSanitizer: heap-buffer-overflow on address ... WRITE of size 1 at ... #0 main这比在glibc的free()路径里猜快得多。
如果不想重新编译,传统glibc环境下还可以利用两个环境变量做“毛刺实验”。MALLOC_CHECK_=3会让malloc/free做更严格的元数据检查,往往能让越界写更早爆发;MALLOC_PERTURB_则用固定字节填充分配和释放的内存,让被破坏的数据在core里更容易辨认。这套方法有版本差异,但作为常规思路仍然值得一试。
5.4 “二次崩溃”会让core误导你
还有种情况是,信号处理函数本身出了问题。比如程序为SIGSEGV注册了一个handler,handler里恰好又访问了非法地址,或者handler基于已被破坏的栈状态执行,那么第二次异常会让core文件捕获到的“当前崩溃点”完全不是第一次崩溃的地方。看到bt里的栈顶和dmesg日志不对应、甚至note里信号号与栈明显矛盾时,就要考虑是不是经历了二次崩溃。
6. systemd-coredump与容器:现代环境把core藏在了哪
6.1 systemd-coredump的索引式管理
现在的发行版默认core_pattern很多是管道模式,把core交给systemd-coredump处理。于是你没在当前目录看到core文件,不代表内核没转储,它只是被收编了。
遇到这种情况,别着急改core_pattern,先查一下:
coredumpctl list coredumpctl info 12345 coredumpctl gdb 12345coredumpctl list会列出系统保存过的崩溃记录,info看元数据,gdb是直接在core文件上用gdb调试。systemd-coredump默认把core二进制放在/var/lib/systemd/coredump/,元数据记在journal里。相关配置在/etc/systemd/coredump.conf,其中Storage=可以控制是存文件、存journal还是只报告;Compress=控制压缩;ExternalSizeMax=控制保存上限。
实验阶段我更愿意直接用/tmp/cores/路径,简单原始。生产环境则要看团队的可观测性规划——coredumpctl的好处是自动收集、集中管理,坏处是默认配置可能因大小限制丢数据,或者因为journal日志限速导致元数据缺口。最好的办法是把“core文件应该出现”这个预期写进监控,而不是等到半夜被叫醒。
6.2 容器环境:core文件经常“消失”的另一个重灾区
容器内跑C/C++服务时,进程崩溃虽然显示Segmentation fault (core dumped),但容器工作目录里往往什么都没有。原因在于core文件生成依赖的是宿主机内核和宿主机的core_pattern。
如果宿主机core_pattern是/tmp/cores/core.%e.%p,那么core文件生成在宿主机的/tmp/cores里,容器内当然看不到。如果容器引擎给容器默认设置的RLIMIT_CORE是0,那么内核直接就不转储了。Docker里可以这样覆盖:
docker run --ulimit core=-1 ...docker-compose的写法:
ulimits: core: soft: -1 hard: -1容器场景还有一个隐藏问题:如果core_pattern指向的路径在宿主机上不存在且无法创建,内核转储会静默失败。所以容器化部署下更稳妥的做法是,把core目录设计成宿主机上明确可写的路径,让宿主机和容器都能理解。
6.3 把core能力做成检查项
踩过几次坑之后,我现在的习惯是把“能否产生core文件”当作服务可观测性的一部分,而不是事故后才检查。任何准备长期运行的业务进程,启动脚本里都会做一次检查:
ulimit -c cat /proc/sys/kernel/core_pattern test -w /tmp/cores至少在崩溃发生时,你能快速判断是“没有现场”还是“现场没找到”。这一步往往比后面花几个小时分析栈更有价值。
有条件的话,还可以故意给服务发一个SIGABRT做演练,确认core文件路径、权限、大小限制全都符合预期。这个过程只需要几分钟,但在真正的深夜事故里省下的时间,绝对不是几分钟。