排查实战:从core dump到GDB定位)
段错误Segmentation Fault绝对是Linux下C/C程序员最常碰见的崩溃类型没有之一。我见过不少同事一看到./app直接吐出一句Segmentation fault (core dumped)就头皮发麻有的甚至直接重启大法重新编译一遍然后发现根本没用。其实段错误是整个崩溃调试里最规范、最套路化的一种只要掌握好工具链和排查顺序大多数问题都能在十几分钟内定位到具体哪一行代码。这篇文章就把我自己多年排查段错误的完整思路、工具用法和踩坑经验整理出来希望能帮你在下次面对SIGSEGV的时候少走弯路。我会从段错误的底层原理讲起一步步带你走完全流程从开启core dump、用GDB抓调用栈、分析崩溃现场到常见坏代码模式复盘最后再给一套不用GDB也能活下来的兜底方案。无论你是刚入门Linux开发的新手还是被线上崩溃折磨的运维/嵌入式工程师这篇文章都值得你花十分钟看完。1. 段错误到底是什么很多人把段错误理解成“程序乱写内存”这个说法不算错但不够准确。要真正把段错误调试明白得先搞清楚它在操作系统层面是怎么发生的。1.1 段错误的技术本质段错误本质上是一个非法内存访问问题。现代CPU都带有MMU内存管理单元进程访问的每个虚拟内存地址都会经过MMU翻译成物理地址。如果这个地址所在的页没有被映射到进程的地址空间里MMU就会触发一个缺页异常的硬件中断内核拿到这个异常后会检查这个地址是不是真的非法——如果是非法访问内核就会向进程发送SIGSEGV信号信号编号是11。进程收到SIGSEGV后默认动作就是终止进程并生成core dump。所以你会看到程序打印Segmentation fault或者段错误然后退出shell提示符前面还会多一个core dumped的字样。从开发者视角来看触发段错误的高发原因一般就这几类空指针或野指针解引用访问了地址为0或者已经释放的内存。栈溢出递归层数太深或者栈上分配了超大数组把栈空间耗尽。堆内存越界通过malloc分配的内存不够用memcpy/strcpy写超了。数组下标越界访问了数组边界之外的地址。对只读内存做写操作比如尝试修改字符串字面量。释放内存后再次使用use-after-free对象已经被delete/free但仍然通过悬空指针访问。上面任何一条最后的检查结果都是同一个MMU发现你访问了一个不属于你的地址然后把你干掉。反过来讲你在GDB里看到的“崩溃在哪一行”往往不一定是造成问题的根源在哪一行这个后面细说。1.2 段错误和普通崩溃的区分在实际排查之前先得确认这个崩溃确实是段错误而不是别的崩溃类型。Linux下的常见崩溃信号可以列一下信号编号中文名常见触发场景SIGSEGV11段错误非法内存访问SIGABRT6中止调用了abort()常见于断言失败、C异常未捕获SIGBUS10总线错误非对齐的内存访问或者访问不存在的物理地址mmap相关SIGFPE8算术异常整数除以0SIGILL4非法指令执行了无效CPU指令常见于编译架构不匹配它们的现象很相似都是进程瞬间终止但排查方向完全不同。比如SIGABRT通常意味着断言失败或者glibc检测到了堆内存损坏这种时候GDB的bt大概率会停在一个类似malloc_printerr的函数里而SIGSEGV则往往停在你自己业务代码的某一行。如果看到的是SIGBUS那就得优先怀疑对齐问题。判断方法很简单在shell里先看退出码或者直接用dmesg | tail看内核日志内核会把进程崩溃时的信号原因写得明明白白。2. 调试前的准备拿到第一手崩溃信息遇到段错误第一反应不是重新编译也不是改代码碰运气而是先想办法拿到崩溃现场的完整快照。这个快照就是core dump文件。2.1 把core dump打开很多Linux发行版默认是关闭core dump的因为core文件可能很大而且还包含敏感内存数据。但本地调试时一定要开不然就只能靠复现猜。调试时先执行ulimit -c unlimited这个命令只在当前shell会话里生效ulimit -c 0就是关闭。如果希望永久开启需要去改/etc/security/limits.conf加上* soft core unlimited * hard core unlimited有一点要注意ulimit -c unlimited只是把限制提到最大但最终要不要生成core文件、生成在哪里、命名成什么都由/proc/sys/kernel/core_pattern决定。2.2 看懂core_pattern配置先看一眼当前系统的core文件命名规则cat /proc/sys/kernel/core_pattern常见的几种输出core表示生成名为core的文件放在进程当前工作目录。core.%p生成的core文件带PID后缀例如core.12345。|/usr/share/apport/apport这是Ubuntu桌面版常见的写法表示出现崩溃后将core丢给管道程序处理不一定直接落盘成文件。如果你发现ulimit -c unlimited之后程序崩溃却找不到core文件多半就是core_pattern被配成了|开头的管道方式。最好的做法是直接清空重设echo core.%e.%p /proc/sys/kernel/core_pattern这样生成的文件名形如core.app.12345%e是程序名%p是PID很直观。临时改一下重启后会被重置想永久修改需要配置sysctl.conf。另外嵌入式板子上经常会有个坑/proc/sys/kernel/core_uses_pid如果设置为0core文件名就不带PID多个进程崩溃时容易互相覆盖。建议设为1或者直接在core_pattern里用%p一劳永逸。2.3 编译时把符号信息留全core文件里只有程序的内存镜像要把崩溃地址映射回代码行号和函数名必须依赖符号表。因此编译时要加上-g选项gcc -g -O0 -o app app.c重点是不要strip。很多发布流程喜欢strip掉二进制里的符号表来减小体积但一旦去掉了符号GDB面对只有一个地址的调用栈会非常痛苦。如果线上已经strip了又不想重新编译可以提前留一份带符号的副本崩溃后用带符号的副本去调试core文件这是我最推荐的做法。还有一个细节-O2开启优化后GDB看到的代码行号和变量状态偶尔会和源码对不上因为编译器做了一些指令重排和变量优化。如果完全无法复现可以试-O0 -g编译一个调试版本再跑一遍很多优化引起的“诡异崩溃”立马现出原形。3. GDB定位段错误的完整流程拿到core文件后剩下的工作就是让GDB帮我们还原案发现场。GDB的用法很多但定位段错误的核心就三步加载、看栈、查变量。3.1 让程序在崩溃点停下来GDB有两种打开方式直接运行程序并等待崩溃gdb ./app (gdb) run带core文件分析推荐gdb ./app core.app.12345用第二种方式时GDB会直接停在崩溃发生的那个线程和那一条指令上并且通常会自动打印出类似下面这样的信息Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00000000004005f8 in parse_packet (req0x0) at tcp_server.c:128 128 memcpy(dst, req-data, req-len);看到#0这一行你就知道程序崩在tcp_server.c第128行的memcpy里。这是整个排查的起点但往往不是终点。3.2 bt拿到调用栈紧接着输入(gdb) bt它的作用是打印完整的函数调用栈。输出长这样#0 0x00000000004005f8 in parse_packet (req0x0) at tcp_server.c:128 #1 0x0000000000400687 in worker_main (arg0x0) at tcp_server.c:89 #2 0x00007f3234c4424a in start_thread () from /lib/x86_64-linux-gnu/libpthread.so.0 #3 0x00007f32349a10c3 in clone () from /lib/x86_64-linux-gnu/libc.so.6看一下#0到#1的调用关系再对比一下req0x0这个信息——parse_packet传入了一个空指针req然后在函数里直接req-len不崩才怪。如果编译时没带-gbt只会显示一堆地址#0 0x00000000004005f8 in ?? () #1 0x0000000000400687 in ?? ()这种情况下就只能用addr2line把地址转换成文件名和行号了addr2line -e app -f 0x400687它会输出函数名和对应的源码位置。当然这要求文件没有被strip得太干净至少保留着.symtab和.debug_line段才行。3.3 查验关键变量知道崩溃行之后先别急着改代码。因为崩溃行只是最后一根稻草真正的bug可能藏在更早的地方。比如上面的例子崩溃在memcpy但根因可能是握手阶段某个状态机没处理好导致req指针根本就是空的。我们需要转向查变量的实际值(gdb) frame 1 (gdb) info locals (gdb) print req (gdb) print *reqframe 1是切到上一层调用info locals看局部变量print可以打印指针指向的内容。如果req在parse_packet里是空的那往上翻看看worker_main里它从哪里来、为什么是空思路就清晰了。另外GDB里还有一个很实用的快捷键组合在无法复现的段错误现场可以先run程序让它在崩溃点停下来然后用up/down一层层翻栈帧再配合list命令查看当前帧对应的源码行。(gdb) list这个命令会显示当前行的前后几行源码能帮你更快确认上下文。如果你的程序是多线程还要用(gdb) thread apply all bt这条命令把所有线程的调用栈一次性打出来。段错误有时候不在主线程而在某个后台工作线程里只看主线程会完全摸不着头脑。4. 最常见的几种段错误场景与排查手法GDB教会了我们怎么看崩溃现场但要真正“处变不惊”还得对各种典型的段错误场景有肌肉记忆。这里把我在实际项目里遇见最多的几类问题拆开讲一遍每一类都有对应的特征和排查手法。4.1 空指针解引用最好认但最容易写错空指针是很多人的入门第一课但它依然高发。最常见的是这样struct request *req get_request(); if (req NULL) { /* 这里漏掉了判空 */ } memcpy(dst, req-data, req-len); // 崩在这里GDB里看到#0停在某个-访问处并且变量名后面跟着 0x0基本就是空指针。排查套路很固定先frame 0再看info args确认是哪个参数是零然后往前追这个参数在哪里被赋值、什么时候被置空。比较隐蔽的其实不是“直接解引用”而是结构体内嵌指针为空if (req ! NULL req-body ! NULL) { process(req-body.data); }有人写代码时只判了外层指针没判内层结果req-body是NULL照样段错误。这种问题GDB里看print req-body会直接显示Cannot access memory at address ...或者打印出来是0x0留意一下就能发现。4.2 字符串处理不当strcpy/strcat是重灾区字符串操作引起的段错误有个特点崩溃位置经常和逻辑错误位置离得很远。你明明觉得strcpy没什么问题但它把数据写到了不该写的堆内存里程序可能继续跑了几百行才崩。这类问题很经典的场景是strcatchar buf[32]; strcpy(buf, prefix: ); strcat(buf, user_input); // 如果user_input很长直接溢出user_input几十个字节buf只有32字节写多了就把相邻的栈变量/返回地址覆盖了。这种问题在GDB里表现很怪bt出来的调用栈可能已经面目全非函数名全是乱的——因为栈上的返回地址被字符串覆盖了。排查这类问题的核心不是看崩溃点而是主动检查内存操作的长度。在GDB里可以用(gdb) print strlen(user_input) (gdb) p sizeof(buf)一把就能看出来是不是越界。平时写代码尽量用strncpy/snprintf并且指定目标缓冲区大小这是从源头解决问题。4.3 栈空间被写爆递归太深或者栈上分配超大数组栈溢出和堆溢出在GDB里的表现完全不同。栈溢出时错误往往发生在一个看起来毫无关系的函数里因为栈空间被递归调用或大数组吃光了然后在下一个函数入口处压栈时触碰到栈底边界。看一下典型场景void process_node(struct node *n) { char buffer[1024 * 1024]; // 1MB栈上数组 process_node(n-next); // 无限递归 }栈大小一般是8MBulimit -s可查每次递归都要分配1MB局部数组递归几次就把栈用完了。GDB里看到崩溃点在一个普通函数入口附近比如栈帧创建时的第一条指令并且bt出来的调用栈巨长几百层甚至上千层基本就是栈溢出。排查命令(gdb) bt (gdb) info frameinfo frame可以看到当前栈帧用了多少字节。如果每一帧都是几KB甚至几MB那栈不够用是必然的。解决方法一般是递归改循环、把大数组放到堆上malloc或者必要时用ulimit -s调大栈空间做临时验证。4.4 数组越界之后指针错乱数组越界和字符串溢出有点像都属于内存写越界。区别在于数组越界通常发生在业务代码的循环或索引计算里有很强的“逻辑错误”意味。常见的坑是下标用了无符号整数导致负数变极端大数int idx -1; uint32_t uidx idx; // uidx 4294967295 arr[uidx] 1; // 直接炸穿内存这种问题的GDB特征也很明显崩溃时的地址是一个极其离谱的值比如0x7f00000001或者0xffffffffff...一眼就能看出不是正常地址。排查时除了看崩溃点还可以用info registers看一下CPU寄存器里的地址值(gdb) info registers rdi rsi rax如果某个寄存器里是一个很“整”的地址如0xffffffffxxxx...多半就是负数下标或者未初始化指针。我自己的经验是这类问题预防大于调试循环前做好边界判断编译器开启-Wall -Wextra能挡掉一大半低级越界。5. 一个实战案例从崩溃到定位的全过程光讲理论容易飘我拿一个之前真实处理过的崩溃来演示一遍完整排查链路。为了讲清楚代码做了简化但整个思维流程是实际项目里原汁原味的。5.1 复现现场假设程序是一个简单的TCP服务名字叫tcpsrv对每个客户端连接启动一个线程处理。运行一段时间后发现进程时不时崩溃日志里没有任何异常只是shell里出现Segmentation fault (core dumped)core_pattern已经配置好了生成的文件是core.tcpsrv.8876。先用GDB看现场gdb ./tcpsrv core.tcpsrv.8876GDB输出Core was generated by ./tcpsrv 1234. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00000000004007a1 in process_message (conn0x603260, msg0x0) at tcp_server.c:214 214 snprintf(reply, sizeof(reply), echo:%s, msg-data);崩溃点在process_message里参数msg是空指针。但注意这个函数入口处可能没有对msg做判空所以一进函数就炸了。5.2 GDB追查接着输入bt看完整调用栈(gdb) bt #0 0x00000000004007a1 in process_message (conn0x603260, msg0x0) at tcp_server.c:214 #1 0x0000000000400910 in worker_main (arg0x0) at tcp_server.c:157 #2 0x00007f1d3b21b2ba in start_thread () from /lib/x86_64/libpthread.so.0 #3 0x00007f1d3af4b41d in clone () from /lib/x86_64/libc.so.6看来是worker_main调用了process_message并且传了空指针。切到frame 1看一下worker_main里到底发生了什么(gdb) frame 1 (gdb) info locals输出conn 0x603260 msg 0x0继续往下看源码list一下(gdb) list tcp_server.c:150看到worker_main的代码大致是while (1) { struct message *msg recv_message(conn); process_message(conn, msg); }问题很明显了recv_message在某些情况下会返回NULL比如对端正常关闭连接、接收超时代码没有判空就直接把NULL传给了process_message。5.3 根因与修复修复方式有两种思路。一个是在process_message入口做防御性判断if (msg NULL) { log_error(received NULL message); return; }另一个更合理的是在worker_main里判断返回值如果返回NULL就直接退出当前连接的处理循环struct message *msg recv_message(conn); if (msg NULL) { close(conn); break; } process_message(conn, msg);我更推荐第二种因为NULL在这里代表的是“连接已不可用”继续往下走是没有意义的。防御性判空当然要加但不能只靠process_message硬抗业务逻辑层面也要看清空指针的语义。这个案例很小但已经覆盖了段错误排查的标准流程看崩溃点 → 看调用栈 → 查参数来源 → 找根因 → 修复。大部分段错误问题都是这个思路只不过遇到多线程、复杂结构体时中间会多几步变量追踪而已。6. 没有GDB时的保命手段与日常预防GDB很强但不是所有环境都适合它。嵌入式板子上没core、生产环境不能装GDB、或者畸形崩溃导致core文件损坏这些场景我都遇到过。这时候就需要一些兜底手段和预防策略。6.1 strace观察系统调用strace能跟踪进程的所有系统调用段错误前最后几次系统调用往往能透露线索strace -f -o /tmp/trace.log ./app程序崩溃后打开/tmp/trace.log看到最后几行read(3, ..., 1024) 1024 write(2, Segmentation fault\n, 19) 19 --- SIGSEGV {si_signoSIGSEGV, si_codeSEGV_MAPERR, si_addr0x8} --- killed by SIGSEGV (core dumped) si_addr0x8这个值很有用它表示进程访问了地址0x8。这个地址通常意味着访问了某个结构体偏移量为8的成员而结构体基地址是NULL。看到这种信息直接去查代码里的空指针访问即可。6.2 AddressSanitizer直接报错如果你的代码本地能复现直接用AddressSanitizerASan编译它会比GDB更早、更精确地报告内存错误堪称神器gcc -g -fsanitizeaddress -o app app.c ./appASan在越界访问发生时就能立刻停下并打印出是堆越界还是栈越界、访问了哪个地址、这块内存是在哪里分配的、分配时的大小是多少信息比GDB丰富得多。用ASan跑一遍能定位绝大多数memcpy越界、strcpy溢出、use-after-free问题。代价是程序运行会变慢、内存占用变大所以一般只用于本地调试不上生产。6.3 编程层面的预防思路调试工具再强也不如从源头上少写几个段错误。我个人的习惯是指针定义时立刻初始化不管当前是否需要一律先置NULL。释放内存后顺手置NULL避免悬空指针。所有涉及外部输入长度的地方先检查边界再操作。编译时开-Wall -Wextra -Werror把常见的指针警告直接扼杀在编译期。结构体指针作为函数参数时入口先做NULL检查养成肌肉记忆。用valgrind跑关键路径的内存检查尤其是服务端程序能发现很多潜在问题。这些习惯不会让你的代码瞬间变牛但能在未来省下大量通宵排障的时间。说实话段错误这个问题的难度上限非常高内核、编译优化、硬件对齐、并发竞态都可能成为幕后黑手。但绝大多数日常工作里遇到的段错误都是“指针没判空”“长度没算对”“数据被踩了”这几件事。把这些基本功练扎实已经能覆盖90%以上的崩溃场景。我到现在遇到莫名其妙的段错误还是会先稳住心态按这次讲的流程走一遍开core、看栈、查变量、追来源——因为排障这件事越不慌越快。