1. 从一个凌晨三点的崩溃现场说起进程莫名其妙没了日志停在半截重启之后一切正常但问题还在那里悬着。这种场景做后端或者系统开发的人多少都遇到过。大多数时候我们靠日志、监控、指标三板斧去猜但有些崩溃是猜不出来的——内存越界、野指针、栈溢出、多线程竞争这些问题在日志里往往只留下一句进程已退出什么线索都没有。core 文件就是为这种时刻准备的。它本质上是进程在崩溃瞬间的一份内存快照包含寄存器状态、调用栈、堆内存、加载的共享库列表等等。有了它你可以在进程已经死掉之后用调试器把现场复活一步步回溯到出问题的那一行代码。这篇内容就是围绕怎么拿到 core 文件、怎么用调试器读它、怎么从里面挖出真正有用的信息来展开的适合写过 C/C、Rust、Go 这类需要和底层内存打交道的开发者也适合运维同学排查线上服务异常退出。我自己的经验是core 文件这东西会看的人觉得它是救命稻草不会看的人觉得它是一堆没用的二进制垃圾。差别就在于有没有掌握那套从崩溃现场倒推根因的方法。下面我把自己这些年踩过的坑、总结的流程、以及一些容易被忽略的细节完整地摊开讲一遍。2. core 文件到底是怎么产生的2.1 内核在进程死亡那一刻做了什么当一个进程因为信号而异常终止时操作系统内核会做一件事检查这个进程是否被允许产生 core dump。如果允许内核会把进程当前的虚拟内存空间、寄存器组、信号信息等打包写入一个文件这个文件就是 core 文件。触发 core dump 的信号通常是 SIGSEGV段错误、SIGABRTabort 调用、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而且即使你设了 unlimitedcore 文件也可能写到容器内部某个你找不到的地方或者因为存储驱动的问题根本写不出来。我的做法是在启动容器时显式指定docker run --ulimit core-1 --mount typebind,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 文件之后的第一步别急着 gdb3.1 先确认 core 文件和可执行文件是否匹配我见过太多次这样的场景同事兴冲冲跑过来说core 文件拿到了快帮我看看结果 gdb 一加载调用栈全是问号符号表对不上。原因通常是 core 文件对应的可执行文件版本不对——可能是崩溃后重新编译过也可能是部署时用了不同的构建产物。所以拿到 core 文件的第一件事是确认它和哪个可执行文件、哪个版本的共享库匹配。有几个办法可以交叉验证用file命令看 core 文件的基本信息它会告诉你这个 core 是由哪个可执行文件产生的用readelf -n core查看 core 文件里记录的 NT_FILE 信息里面列出了崩溃时加载的所有映射文件路径对比可执行文件的构建 IDbuild IDreadelf -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-debuglinkmyapp.debug myapp这样发布出去的myapp体积小但调试时 gdb 能通过 debuglink 自动找到myapp.debug符号信息就回来了。如果连 debuglink 都没做也可以在 gdb 里手动指定符号文件gdb -ex symbol-file myapp.debug -ex core-file core.12345 myapp3.3 一个容易被忽略的坑共享库版本可执行文件的符号对上了不代表共享库的符号也对得上。如果你的程序链接了某个第三方库崩溃发生在那个库里面而线上环境的库版本和本地不一致调用栈照样是乱的。我的习惯是在 gdb 里加载 core 之后先执行info sharedlibrary看看加载了哪些库、路径是什么然后和本地环境逐一比对。如果发现某个库版本不对就去找到对应版本的库文件在 gdb 里用set solib-search-path指定搜索路径。(gdb) info sharedlibrary (gdb) set solib-search-path /path/to/correct/libs (gdb) sharedlibrary4. 用 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 (buf0x0, len100) at data.c:42 #1 0x0000000000401234 in handle_request (req0x7fff5a3b4c00) at server.c:88 #2 0x0000000000400abc in main (argc2, argv0x7fff5a3b4e08) at main.c:15每一层包含的信息量很大帧号、返回地址、函数名、参数值、源文件和行号。#0是崩溃发生的当前帧#1是调用者以此类推。看调用栈的时候要重点关注几个东西参数值比如上面buf0x0说明传进来一个空指针而process_data里大概率没做空指针检查就直接用了这就是根因行号data.c:42直接指向出问题的代码行去翻源码就能看到具体逻辑帧的完整性如果中间某一层显示??或者地址明显不对说明栈可能被破坏了我一般会配合frame命令切换帧然后用info locals看局部变量用info args看参数用print打印具体值。比如(gdb) frame 0 (gdb) info args (gdb) info locals (gdb) print *buf4.3 当调用栈被破坏时怎么办栈被破坏是 core 调试里最头疼的情况。常见于缓冲区溢出、栈上数组越界写、格式化字符串漏洞等。表现就是bt出来的栈乱七八糟或者 gdb 直接报Backtrace stopped: previous frame inner to this frame。这时候有几个应对策略。第一个是看寄存器和栈内存的原始数据。即使符号化的调用栈没了栈上的返回地址还在你可以手动去栈内存里找。用info registers看rsp和rbpx86-64 架构然后x/64gx $rsp把栈顶的内存 dump 出来里面会有返回地址。拿到返回地址后用info symbol address反查是哪个函数。第二个是用bt的替代命令。gdb 有个bt full会打印更多信息还有bt -frame-info之类的选项。另外可以试试thread apply all bt看所有线程的栈有时候崩溃线程的栈坏了但别的线程栈是好的能提供线索。第三个是借助外部工具。gdb之外eu-stackelfutils 工具集里的有时候对损坏栈的解析能力更强。还有crash工具不过那个更多用于内核 core。4.4 多线程 core 的调试要点多线程程序的 core 文件里包含所有线程的状态。gdb 加载后会默认停在崩溃的那个线程但其他线程的信息同样重要尤其是死锁或者竞争条件导致的崩溃。(gdb) info threads (gdb) thread apply all btinfo 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 文件太大加载慢得让人崩溃一个跑了很久的服务内存占用几个 Gcore 文件也是几个 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 文件就从苦差事变成了按流程走的常规操作。
阅读完成 · 觉得有帮助?