多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Core Dump与GDB实战:定位线上进程崩溃的完整指南

Core Dump与GDB实战:定位线上进程崩溃的完整指南 1. 一次线上事故把我逼进了Core Dump的世界先讲一个我自己的经历。几年前接手过一个C写的网关服务平时跑得挺稳结果一到高峰期就抽风偶尔直接进程消失连日志都来不及写。起初我以为是OOM翻系统日志也没看到明显的内存杀进程记录。最让人崩溃的是这个bug一周才出现一两次完全没有规律手动加日志重新部署得等好几天才能复现一次。后来一个老同事提醒了一句“别瞎猜了开core dump等它崩崩完看现场。” 说实话当时我对core dump的理解也就停留在“进程挂了会生成一个core文件”这个层面真到排查问题的时候根本不知道该怎么下手。那位同事花了一个下午带我过了一遍完整的分析流程从那以后我再也没有怕过疑难杂症式的崩溃问题——恰恰相反每次线上崩溃我都像刑侦人员拿到案发现场照片一样兴奋因为我知道只要是程序崩溃core dump里几乎就藏着完整的答案。这篇文章就把我这些年积累的调试技巧和core dump分析经验写透了从怎么让系统把崩溃现场留下来到怎么用gdb挖出崩溃的真凶再到那些gdb文档里不会教你的实战细节一次讲清楚。不管你是刚入行的研发新人还是正在跟难缠崩溃问题搏斗的老手这篇文章都值得你收藏下来反复读。2. Core Dump到底是什么又是怎么产生的2.1 它不是“日志”是进程的“遗照”很多人会把core dump和日志混为一谈其实差别特别大。日志是你程序自己写的里面记录的是程序“想让你知道”的东西core dump则是操作系统在进程异常终止时把进程的内存镜像整个转储到磁盘上的一个文件。你可以把它当成一张“遗照”——不只是户外的表情而是连内脏结构、血液样本全都保留下来。具体来说core文件里保存了进程终止时的完整的内存地址空间内容包括堆、栈、代码段、数据段的内存快照所有线程的寄存器上下文和栈回溯信息打开的文件描述符信息、信号处理状态进程的PID、父进程PID、用户ID等辅助信息全局变量、局部变量在那一刻的值只要没被优化掉有了这些数据你基本就等于把崩溃那几毫秒的整个“案发现场”给冻结住了。写日志就像是目击证人的口供而core dump则是现场的照片、指纹、血迹分析哪一个证据效力更大不用我多说。2.2 触发崩溃的那些常见信号进程为什么会生成core dump本质上是收到了某些特定的信号且默认动作是“终止进程并产生核心转储”。我整理了一张高频信号表大家对照着看信号数字触发场景常见原因SIGSEGV11无效内存访问空指针解引用、越界访问、访问已释放内存SIGABRT6主动终止assert失败、C异常未捕获、abort()调用SIGFPE8非法算术运算整数除以零、浮点异常SIGBUS10总线错误内存对齐错误、访问mmap映射区越界SIGILL4非法指令CPU指令集不支持、代码段损坏SIGTRAP5断点陷阱assert触发、gdb断点、int3指令这里有个容易踩坑的点不是所有崩溃都会默认生成core文件这是有开关控制的后面讲到系统配置时我会详细展开。3. 让系统把“案发现场”完整留下来3.1 排查前的第一步确认core开关都打开了我见过不少同事真到要排查问题的时候发现系统压根没开core dump那个绝望啊。所以在讨论任何分析技巧之前咱们先把“案发现场”的保全机制搞定。首先看core文件大小限制。Linux下默认是0意味着即使进程崩溃系统也不会生成任何core文件。你有两种方式查看当前限制# 查看当前用户的core文件大小限制 ulimit -c如果输出是0说明被禁用了。临时启用的方法# 不限制core文件大小或指定具体大小如 ulimit -c 102400 表示100MB ulimit -c unlimited注意这种方式只在当前shell会话里有效你的服务如果是由systemd、supervisor这类进程托管工具拉起的还得在对应服务的配置里加上limit设置。举个例子systemd服务需要在service文件里加[Service] LimitCOREinfinity修改后执行systemctl daemon-reload重启服务才生效。另外还有一种情况要留意如果服务进程自己调用了setrlimit修改了core大小限制那系统配置再大也没用这属于程序层面的主动行为。3.2 设置core文件怎么命名、存到哪默认情况下core文件会生成在进程的当前工作目录里名字就叫core。问题是如果服务启了多个实例或者工作目录不可写core文件就会互相覆盖甚至生成不出来。我建议在生产环境里通过/proc/sys/kernel/core_pattern统一规划好core文件的生成位置和命名规则# 常用配置示例将core文件统一放到 /data/corefiles 目录 echo /data/corefiles/core-%e-%p-%t /proc/sys/kernel/core_pattern这个配置里的格式符含义如下%e可执行文件名%p进程PID%t崩溃时间戳Unix时间%u用户ID%g组ID%s触发崩溃的信号编号比如配置成core-%e-%p-%t后生成的文件名可能是core-gateway-23456-1699999999。一个文件就包含了程序名、PID和时间戳排查多实例问题的时候一眼就能分清哪个core对应哪个进程。注意写入/proc/sys/kernel/core_pattern需要root权限且重启后配置会丢失需要写入/etc/sysctl.conf持久化。可以执行sysctl -w kernel.core_pattern/data/corefiles/core-%e-%p-%t写入生效再加到/etc/sysctl.conf里永久保留。还有一个进阶玩法可以把core通过管道直接交给systemd-coredump或apport处理在较为现代的发行版里这是默认行为。比如Ubuntu默认的core_pattern经常指向apport导致你看到的不是裸core文件而是一堆经过压缩的.crash文件。排查的时候注意先识别清楚你拿到的到底是原始core文件还是已经被额外包装过的格式。3.3 附加的两个实用配置除了core_pattern还有两个参数在生产环境里也建议检查# 1. core文件是否包含完整内存映射信息 cat /proc/sys/kernel/core_uses_pid当值为1时core文件名会自动加上PID后缀配合上面的%p效果一样但要注意两者同时用可能造成双重后缀。我的习惯是直接用core_pattern里的%pcore_uses_pid保持默认即可。另一个是/proc/sys/fs/suid_dumpable。这条针对的是设置了SUID权限的程序如ping这类出于安全考虑Linux默认禁止SUID程序生成core文件。如果你们的服务恰好以SUID方式运行不多见但确实有排查的时候被这一条坑过记得检查这个值是否需要调整。4. 手把手用GDB解剖Core文件的全流程4.1 装载Core文件进入案发现场拿到core文件之后第一步就是用gdb把它和对应的可执行文件一起加载。注意gdb版本和编译时用的编译器要匹配否则可能无法解析调试信息。基本命令是gdb /path/to/your/binary /path/to/core-file加载成功后gdb会直接定位到进程接收到信号的那一刻通常会自动打印出类似下面的信息Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00007f8a2c3d4a12 in std::string::assign (this0x0, ...) at /usr/include/c/9/bits/basic_string.h:1127 #1 0x0000556b1d2f34ab in LoginService::handleRequest (this0x0, ...) at login_service.cpp:88 #2 0x0000556b1d2f78c2 in WorkerThread::process (this0x556b1e5a2c40) at worker_thread.cpp:156看到这段输出你的第一直觉应该是LoginService::handleRequest的this指针居然是空指针那多半是上层调用的时候对象已经被释放了或者压根没创建成功。但别急着下结论这只是栈回溯给我们的第一印象要真正确认还需要往下挖。4.2 定位崩溃现场的必用命令进入gdb交互模式后我基本按这个顺序操作Step 1查看完整的栈回溯bt # 查看当前线程的完整调用栈 bt full # 显示调用栈的同时打印每个栈帧里的局部变量bt是backtrace的缩写。使用bt full可以一次性看到每个栈帧的局部变量值省得一层层切换帧去手动查看。Step 2切换栈帧frame 1 # 切到第1层栈帧就是上面#1那一层很多时候崩在最内层的系统函数比如memcpy、std::string操作这些函数本身没问题真正的问题在它外面的几层调用关系里。所以需要一层层往上翻找到“最有嫌疑”的那一层。Step 3查看寄存器状态info registers # 查看所有寄存器的值重点关注rip指令指针、rsp栈顶指针、rbp栈底指针。如果是x86-64架构函数参数通常按照rdi、rsi、rdx、rcx、r8、r9的顺序传递看看这几个寄存器里藏了哪些关键值。Step 4反汇编当前指令disassemble $pc这条命令能告诉你崩溃时CPU正在执行什么指令。比如mov (%rdi), %rax这条指令都会段错误那十有八九是rdi指向的地址非法配合info registers里的寄存器值就能快速锁定是哪个指针出了问题。Step 5打印关键变量p this p req p *req # 如果 req 是指针用 * 解引用查看内容对于能够解析到符号信息的变量p命令可以直接打印值。遇到指针要记得加上*号解引用同时注意区分程序是C还是C调用方式会有细微差别。4.3 多线程崩溃时的线程定位现代服务基本逃不开多线程。进程崩溃后core文件里保存了所有线程的信息而gdb默认只停在触发信号的那一个线程上。如果遇到崩溃线程不是主线程需要主动切到其他线程去排查info threads # 查看所有线程列表 thread 5 # 切换到编号为5的线程 bt # 在这个线程里查看调用栈多线程排查有个很常见的规律真正崩溃的线程未必是问题源头其他线程的状态往往能揭示真相。举个例子线程A可能在等待一把锁而持有锁的线程B已经因为另一个bug死掉了导致线程A在超时后走到了一条从未预期的路径上最终崩溃。这种情况光盯着崩溃线程看是看不出所以然的。5. 实战拆解一个线上SIGSEGV的完整追踪5.1 环境与配置先还原现场假设我们的服务叫order-service是C写的跑在常见的CentOS 7环境下。某天凌晨它崩溃了好在之前就配好了core_pattern/data/cores/core-order_service-12345-1698765432崩溃后我们第一时间执行gdb /usr/local/bin/order_service /data/cores/core-order_service-12345-1698765432gdb加载后显示Core was generated by /usr/local/bin/order_service --config/etc/order_service/config.yaml. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00007f2918308b10 in memcpy () from /lib64/libc.so.6 #1 0x0000000000406a20 in OrderManager::serialize (this0x7f2904ab2c40, buf0x7ffc92d4e0b0, len512) at order_manager.cpp:214 #2 0x0000000000405c9d in OrderService::handleCreateOrder (this0x7f2908d1a340, request0x7f2904ab2c10, response0x7ffc92d4e0b0) at order_service.cpp:342 #3 0x0000000000405a1a in RpcDispatcher::dispatch (this0x7f2908d1a020, msg0x7f2904ab2c10) at rpc_dispatcher.cpp:118 #4 0x0000000000405678 in TcpServer::workerLoop (this0x7f2908d1a000) at tcp_server.cpp:76 #5 0x00007f2918a7ea0d in start_thread () from /lib64/libpthread.so.0 #6 0x00007f29183b3d6d in clone () from /lib64/libc.so.65.2 逐步排查从现象到根因首先映入眼帘的是崩溃在memcpy而调用它的是OrderManager::serialize的第214行。这是一个典型的“表面现象”memcpy本身不会无缘无故段错误必是参数有问题。用frame 1切到OrderManager::serialize这一层然后查看局部变量和参数(gdb) frame 1 (gdb) info args this 0x7f2904ab2c40 buf 0x7ffc92d4e0b0 len 512 (gdb) info locals data 0x7f2904ab2c60 offset 0看起来buf指向栈地址0x7ffc开头的地址空间data指向堆地址逻辑上还正常。接着反汇编崩溃指令附近看看(gdb) disassemble /r $pc-16,$pc16能到memcpy内部了直接看寄存器(gdb) info registers rdi rsi rdx rdi 0x7ffc92d4e0b0 rsi 0x1 rdx 0x200看到这里问题几乎已经浮出水面了rsi是源地址它的值是0x1一个明显非法的地址可是info args里显示的data 0x7f2904ab2c60传给memcpy的源怎么变成了0x1我立刻回到上一层帧去查(gdb) frame 2 (gdb) p request $1 (OrderRequest *) 0x7f2904ab2c10 (gdb) p *request $2 {order_id 12345, user_id 0, items {size 3, capacity 8, data 0x7f2904ab2c60}, ...}看起来request对象本身还正常于是我又查了serialize内部的实现逻辑。关键问题出在OrderManager::serialize里有一段指针偏移计算char* source reinterpret_castchar*(data) offset * sizeof(Item); memcpy(buf, source, len);如果offset被错误设置到一个极大值指针运算就可能变成很小的非对齐值比如1。我们继续查offset的来源发现它被赋值为offset request-items.size() - request-items.capacity();这两个值都是无符号整数size()3capacity8减出来的结果不是-5而是巨型无符号数导致指针偏移越过地址空间最终落到0x1这个非法值上一访问就段错误。5.3 从看似复杂的现象里提炼出简单的教训这个案例本身不算特别复杂但它非常典型表面上是memcpy崩了实际上是变量类型转换引发指针运算异常。如果没有core dump这个问题可能要花很长时间才能复现和定位。而有了core dump前后不到半小时就锁定了根因。排查完这类问题后我通常会在代码注释和团队文档里记录几条教训涉及指针偏移、下标计算的时候务必警惕无符号整数的下溢问题序列化这类底层通用函数里增加必要的入参校验和调试断言不要相信任何层级的“直接原因”永远要追问数据是怎么变成这个样子的6. 当你拿不到符号表Core文件的高级分析技巧6.1 没有符号表的尴尬与对策生产环境的可执行二进制文件为了性能和安全考虑往往不带-g调试符号甚至经过strip处理。这种情况下bt命令看到的全是十六进制地址像这样#0 0x00007f2918308b10 in ?? () from /lib64/libc.so.6 #1 0x0000000000406a20 in ?? () #2 0x0000000000405c9d in ?? ()别急着放弃有几个招可以用方法一用info sharedlibrary查看动态库加载基址info sharedlibrary配合nm和objdump对可执行文件做离线分析能把地址和函数对应关系大致还原出来。方法二利用addr2line把地址翻译成文件名和行号addr2line -e /usr/local/bin/order_service -f 0x406a20如果文件包含足够的调试信息即使被strip了部分符号这个方法仍然可能给出函数名或文件行号。方法三保留符号文件独立部署我推荐一种更稳妥的做法生产环境部署时用strip过的二进制但把带完整符号的版本归档保存到专门的符号服务器上。这样定位问题时先用生产二进制gdb跟踪再用带符号的版本复现分析。6.2 手动解析调用栈的心法没有符号表时栈回溯信息不完整但栈上的数据还是靠谱的。可以用x/20gx $rsp或者说x/20gx $sp来逐个查看栈内存寻找返回地址的踪迹——返回地址通常表现为可执行的代码段地址特征是比较接近二进制的.text段基址。我自己在无符号表场景下更常做的是查崩溃点所在动态库的基地址和偏移量拿到偏移后用objdump -d反汇编对应的共享库定位具体函数用info proc mappings看进程的内存映射了解哪些地址属于哪个文件这些方法不能保证每次都能完美还原但在缺少符号的情况下至少能给出一个大致定位方向。6.3 别忘了一个免费工具eu-stack如果你用的发行版装了elfutilsCentOS/RHEL系基本都自带还有一个比gdb更轻量的方式eu-stack -c /data/cores/core-order_service-12345-1698765432这个命令会直接解析core文件中的栈信息输出简明的调用栈。虽然没有gdb灵活但在快速判断崩溃线程大致方向时非常高效特别是当你要在一堆core文件里快速摸清共性的时候。7. 常见问题与排查技巧实录7.1 遇到的典型问题速查手册我在不同项目里排查core dump时攒了一些规律性的经验整理成表供大家参考现象可能原因排查方向崩溃在free/delete处重复释放、释放栈内存、内存越界写检查被释放指针的合法性用valgrind复现收到SIGABRT且栈顶是abortassert失败或C异常未捕获往上翻调用栈找断言条件用catch throw重现异常栈std::string相关函数内崩溃字符串未初始化、传入了非法迭代器查this的合法性配合p *this观察内部状态动态库中的崩溃地址无法解析库版本不一致确认加载库和文件系统里的库版本一致必要时比对info sharedlibrary进程被OOM Killer杀但不是SIGSEGV内存不足查dmesg的Out of memory记录优化内存占用栈溢出导致SIGSEGV无限递归或大缓冲分配看栈回溯是否重复出现某一帧用info proc mappings看栈大小7.2 补充两个能救命的小工具gdb的core-file命令如果你已经用gdb打开了二进制但还没加载core可以直接在gdb里执行core-file /path/to/core免去开两个gdb会话的麻烦。coredumpctlsystemd系发行版如果你的机器用systemd管理很多情况下core文件已经被systemd-coredump接管了。可以用coredumpctl list # 列出所有core dump coredumpctl info order_service # 查看具体信息 coredumpctl gdb order_service # 直接进入gdb调试这套命令特别适合开发机省去了手动找core文件的麻烦。7.3 我踩过的坑和总结的独家心得坑一重定向核心转储到第三方工具导致core_pattern失效。一次在某个云环境部署我把core_pattern配置成了管道模式给自家监控工具处理结果监控工具的脚本写得有问题core文件根本没打包成功线上崩溃后连现场都丢了。从那以后我都是先保证裸core文件的落盘监控系统可以另开一条旁路来处理。坑二不同服务器内核版本生成core文件格式不完全一致。同架构下通常没事但内核版本差异过大比如跨大版本可能导致gdb解析异常。如果你们维护多套环境最好把分析环境统一起来避免在一台老机器上分析新内核产生的core。坑三不要在gdb里随便执行run。打开core文件时进程是“死了”的此时按run就是重新执行程序会把原来的core状态完全覆盖掉。新手经常误操作记好了分析core时用的是bt、frame、p这些静态查看类命令不是run、continue这类执行类命令。8. 让Core Dump分析变成日常武器库的一部分我一直认为调试能力是区分“会用语言写代码”和“真正能扛住线上故障”的关键分水岭。日志只能告诉你“发生了什么”而core dump能告诉你“在哪个字节上、哪条指令执行到一半”发生了问题这两者的信息量完全不是一个维度。如果你所在的项目还没有开启core dump我强烈建议你今天就行动起来把配置从开发环境延伸到测试环境、预发环境、生产环境。别等到线上出了紧急事故才开始临时抱佛脚。跑一两次完整的“崩溃-生成-分析”演练把这个流程变成肌肉记忆下一次真实故障来临时你就不会慌了。最后分享一个小技巧我每次分析完一个core dump都会把结论和排查过程整理成几行备注存到一个专门的文件里。久而久之这就成了一笔宝贵的“问题模式库”。很多看起来面目全非的崩溃仔细一比对往往和之前处理过的问题有千丝万缕的联系——所谓经验就是这么一点点攒下来的。
返回列表