
周五晚上十一点隔壁组同事发来一条消息“兄弟帮忙看个段错误跑了一周的进程突然崩了core文件在这我用gdb bt看了下全是问号。”这种场景对搞Linux开发的人来说太熟悉了。段错误Segmentation Fault几乎是C/C程序员在Linux下遇到最多的崩溃类型它不像业务逻辑错误那样有日志可查也不像编译错误那样直接报出位置它就是一个SIGSEGV信号给你一个core文件或者一句“段错误”然后一切都停在崩溃瞬间。这篇文章我就围绕Linux下的段错误调试把整个排查思路和工具用法摊开讲。从段错误产生的底层机制到core文件的配置、gdb的常用排查技巧再到栈溢出、野指针、use-after-free等隐蔽场景的定位方法最后补充一个完整的案例复盘。不管你是刚接触Linux开发的新手还是被段错误折磨过的老手这篇文章里的内容都能直接拿来用。1. 段错误不是“段错乱了”底层机制与常见表象要会修段错误先得搞清楚段错误到底是什么。很多新手第一次遇到“Segmentation fault”时一脸懵以为是内存里的“段”乱了其实这个名字是有历史渊源的理解它背后的机制对你后续定位问题有很大帮助。1.1 从虚拟内存到“非法访问”Linux下每个进程都有自己的虚拟地址空间这个空间被划分成不同的区域比如代码段、数据段、堆、栈、内存映射段等。这里的“段”指的就是这些区域。CPU访问内存地址时通过MMU内存管理单元把虚拟地址翻译成物理地址同时检查这个地址是否落在当前进程合法的地址范围内、访问权限是否匹配可读、可写、可执行。一旦CPU发现某个地址非法——比如地址越界、访问了只读区域、或者对NULL地址做了解引用——硬件就会触发一个页错误异常操作系统把这个异常转化为SIGSEGV信号发给进程。默认情况下进程收到SIGSEGV后就直接终止这就是你看到“段错误”三个字的过程。所以段错误本质上是进程访问了虚拟地址空间中不存在的、或者没有权限的地址。这句话是整篇调试思路的基石你后面做的所有操作都是在回答一个问题崩溃那一刻进程访问了哪个非法地址以及为什么会访问它。1.2 段错误的常见触发场景清单虽然本质相同但触发段错误的代码形态千奇百怪。我在实际工作中总结下来最常见的就这几类对空指针或野指针解引用比如*p 10而p是NULL或者未初始化。数组越界访问尤其是写越界把内存破坏后过一阵子才崩溃这种最难查。栈溢出一般是递归没有终止条件或者声明了大块栈上数组。字符串操作出错比如strcpy往一个没分配足够空间的缓冲里写数据。use-after-free指针指向的内存已经被释放还被继续使用。函数指针错误调用调了一个非法地址。动态库加载或卸载导致的符号地址失效。类型强转后的对齐问题在某些架构上会触发总线错误表现为段错误。了解这些场景的意义在于拿到一个段错误时你可以先根据崩溃现场的特征快速缩小范围。比如崩溃在字符串库函数里就优先怀疑缓冲区溢出崩溃在递归函数里就优先怀疑栈溢出。这个“先预判再验证”的思路能帮你省下大量时间。2. 让崩溃现场完整保留core文件配置与生成调试段错误最理想的情况是拿到崩溃那一刻进程的内存快照也就是core文件。但很多开发者的Linux环境默认不生成core文件或者生成了却找不到导致崩溃现场白白丢失。这一节我把core文件的完整配置流程讲清楚。2.1 检查并开启core文件生成首先检查当前shell的限制。Core文件大小受ulimit -c控制如果显示为0说明被禁止生成ulimit -c # 结果为 0 时需要放开限制 ulimit -c unlimited注意这个命令只对当前shell会话有效如果你想永久生效需要写到shell配置里。以bash为例编辑~/.bashrc或/etc/profile加入ulimit -c unlimited然后source ~/.bashrc或重新登录。这里有个细节对于守护进程或者通过systemd管理的服务ulimit可能还需要在service文件里用LimitCOREinfinity设置否则即使你设置了全局配置服务进程依然不生成core。2.2 配置core文件的输出位置和命名方式core文件默认生成在进程的工作目录名字通常是core或core.pid。但如果你的程序经常切换工作目录或者同时跑多个实例默认配置很容易让core文件丢失或相互覆盖。建议通过内核参数控制# 查看当前配置 cat /proc/sys/kernel/core_pattern # 设置为带pid和时间戳的命名输出到指定目录 echo /var/core/core.%e.%p.%t /proc/sys/kernel/core_pattern其中%e是程序名%p是pid%t是时间戳。注意直接echo写入是临时的重启后失效。想永久生效写入/etc/sysctl.confkernel.core_pattern /var/core/core.%e.%p.%t kernel.core_uses_pid 1然后sysctl -p生效。另外确保/var/core目录存在并且对运行程序的用户有写权限。这一步很多人忽略结果配置了半天core文件还是没生成其实就是目录权限问题。实测经验我建议把core文件统一放到一个独立目录而不是放在程序工作目录。因为程序的工作目录可能被chdir改过甚至可能已经被删除或者没有写权限。统一目录配合%e.%p.%t命名一是方便查找二是多个崩溃实例不会互相覆盖这对排查“偶发性”段错误特别重要。2.3 确认core文件可用性程序崩溃后检查core文件是否生成file /var/core/core.*输出里应该能看到ELF 64-bit LSB core file之类的信息同时会标注这个core文件对应的可执行程序名和信号类型SIGSEGV。如果file命令提示not a core file可能是core文件被其他进程截断或者磁盘空间不足需要检查df -h和生成时的日志。3. gdb三板斧在崩溃现场翻出真凶拿到core文件之后真正的重头戏才开始。gdb是Linux下调试段错误最核心的工具我把最常用的排查套路整理成“三板斧”加载现场、查看调用栈、定位问题行。3.1 加载core文件并查看调用栈在你编译程序时如果要能调试记得加上-g选项gcc -g -o myapp myapp.c然后加载core文件gdb ./myapp /var/core/core.myapp.12345.1700000000进入gdb后第一件事是查看崩溃时的函数调用栈(gdb) bt #0 0x00007f4a1c3e5678 in __strlen_avx2 () from /lib64/libc.so.6 #1 0x0000000000401234 in process_name (name0x0) at main.c:45 #2 0x0000000000401295 in main () at main.c:78输出会告诉你崩溃发生在哪个函数、哪一行、参数是什么。上面的例子中process_name函数在main.c:45调用了strlen而传给它的指针是0x0NULL一眼就能锁定是空指针问题。如果bt输出全是问号说明程序在编译时没有加-g或者core文件和程序版本不匹配。遇到这种情况先确认是不是同一个二进制重新编译带上调试信息再试。3.2 查看崩溃帧的详细信息只看到调用栈还不够你得知道崩溃那一刻变量的值。用frame切换到指定栈帧然后查看变量(gdb) frame 1 #1 0x0000000000401234 in process_name (name0x0) at main.c:45 45 size_t len strlen(name); (gdb) info locals len 0 (gdb) info args name 0x0info args显示函数参数info locals显示局部变量。通过这两个命令你能判断是参数本身就没传对还是函数内部把变量改坏了。我见过不少情况是崩溃的函数本身没问题是上层调用方传入了非法指针。所以一定要结合bt多切换几个栈帧看。往上层的帧看(gdb) frame 2 #2 0x0000000000401295 in main () at main.c:78 78 process_name(input); (gdb) print input $1 (char *) 0x0这样一追发现main里的input就是空的问题根源在main.c:78之前的初始化逻辑而不是process_name函数本身。这个“向上追溯”的习惯非常关键很多新手看到崩溃点就开始改那个函数改了半天发现没用因为根因根本不在那。3.3 用断点、watch和单步执行缩小范围有时候崩溃不是必现的或者崩溃点距离真正的错误写入点已经很远了。比如你在函数A里释放了一块内存函数B里又使用它崩溃发生在B但根源在A。这种case靠一次bt不够需要打配合用break在可疑位置下断点比如怀疑某块内存被写坏就在写入它的函数入口断下来。用watch监控某个地址或变量一旦值被修改gdb立即停下。(gdb) break main.c:100 (gdb) watch p_buffer[10] (gdb) continuewatch是排查内存被写坏的利器。比如你怀疑一个结构体的某个字段被越界写覆盖在崩溃前先watch这个字段gdb会在它被修改的那一刻停下来你就能看到是谁写的。这个操作比事后分析崩溃现场高效得多。单步执行next不进入函数、step进入函数配合print输出中间值适用于复现路径比较短的情况。但单步调试段错误有个前提你得能稳定复现。如果问题几小时才出现一次老老实实靠core文件和watch排查别指望单步。3.4 动态库和优化选项带来的干扰调试时还有一个容易被绊倒的点如果程序链接了动态库bt里经常看到一堆??符号。这时候先加载动态库的符号(gdb) info sharedlibrary或者直接用set solib-search-path指定动态库路径。如果是发布版本编译时开了-O2之类的优化变量可能被优化掉、代码行号对应不准这种情况建议保留一个带-g且不优化或低优化的版本作为调试专用。4. 隐蔽的段错误场景栈溢出、野指针与迷之优化很多段错误不是那种一眼能看出来的。4.1 栈溢出递归失控与超大局部变量栈溢出导致的段错误有个特点崩溃点不确定有时候在递归函数内部有时候在某个完全不相关的函数里因为栈被耗尽后任何一次函数调用都可能触发SIGSEGV。经典的例子void recursive(int n) { char buf[4096]; printf(%d\n, n); recursive(n 1); }这段代码没有终止条件每次递归都会在栈上分配4KB栈空间用完后进程崩溃。排查这类问题我在gdb里最常用的是看bt的输出数量——如果bt打印了几百上千帧基本可以确定是栈溢出(gdb) bt #0 recursive (n1048576) at stack.c:5 #1 recursive (n1048575) at stack.c:6 #2 recursive (n1048574) at stack.c:6 ...还有一种栈溢出是函数里声明了超大数组比如char buffer[16 * 1024 * 1024]在栈上直接分配16MB。默认栈大小一般是8MB这种数组一声明就崩。排查方法是用info frame看看当前栈帧大小或者直接审视代码里有没有大的栈上分配。修复手段也很简单把大数组改成堆分配malloc或者用static修饰让它放到全局数据段再或者缩小缓冲区尺寸。4.2 野指针与未初始化指针野指针比空指针难查因为它的值是不确定的可能指向随机地址也可能指向某个合法的但你不该碰的地址。使用未初始化的局部指针是最常见的野指针来源void foo() { int *p; *p 100; // p 未初始化值是栈上残留的随机数据 }这类问题在release版本里表现尤其诡异因为栈内容是不确定的有时候不崩有时候崩每次崩溃位置还不一样。调试时最有效的手段是info locals查看变量值如果发现指针变量的值是一个明显“不合理”的地址比如没有映射到任何模块地址范围的数基本就是野指针。补充一个实用技巧对于这种不确定的随机地址问题可以用info proc mappings查看进程的合法地址映射范围然后拿指针的值去对比如果指针落在这个范围之外那它一定是个非法指针。4.3 use-after-free 和 double free这类问题简直是段错误里的“慢性毒药”。内存释放后指针并没有置空后续代码继续使用这块内存。可能当时内存没有被重新分配数据还在程序还能正常运行但等到另一处代码malloc复用了这块内存数据被改写那可以说崩溃只是时间问题。排查use-after-free我的建议是重点审查代码中所有free或delete之后还有没有continue使用该指针的情况。另外Linux下有个神器叫MALLOC_PERTURB_它可以在malloc和free时用特定字节填充内存让“已释放”的内存内容变得可识别MALLOC_PERTURB_165 ./myapp如果设置这个环境变量后程序必现崩溃或者崩溃行为改变那问题指向use-after-free或未初始化内存的概率非常大。这个方法成本极低建议加入你的调试“武器库”。double free重复释放相对好查一些tcmalloc或glibc的malloc检测在double free时经常直接报错但有些场景下不一定。用valgrind扫一遍能快速定位。4.4 编译优化触发的未定义行为还有一种段错误是你代码写得有问题但只在特定优化级别下才暴露。比如C语言里的未定义行为UB——有符号整数溢出、使用未初始化的变量等。因为未定义行为在标准里“编译器想干嘛就干嘛”-O0下编译器可能保留了某些写入到了-O2或者-O3开启了严格别名规则strict aliasing后编译器把变量优化掉、调整指令顺序代码的执行逻辑就“歪了”。遇到这种问题最简单的排查方法是对比不同优化级别下的行为。如果-O0正常、-O2必崩先别急着怀疑编译器大概率是你的代码触碰了UB。用-fsanitizeundefined重新编译一遍跑起来后编译器会精准指出哪一行触发了未定义行为这是最快的方式。gcc -g -O2 -fsanitizeundefined -o myapp_ubsan myapp.c ./myapp_ubsan5. 用AddressSanitizer把隐藏问题一次性揪出来如果前面的手段还没定位到问题那就该请出ASan这尊大佛了。AddressSanitizer是编译器内置的内存错误检测工具它能检测越界访问、use-after-free、栈溢出、内存泄漏等问题。它的原理是在编译时插入内存访问检查代码用“影子内存”来记录哪些地址是合法的每次访问都检查一遍。5.1 ASan的编译和使用在gcc/clang里启用ASan很简单gcc -g -fsanitizeaddress -o myapp_asan myapp.c ./myapp_asan不需要改任何业务代码也不需要额外配置。程序跑起来后一旦发生非法内存访问ASan会直接打印详细的错误报告。5.2 读一份ASan报告ASan的报告格式对中文开发者很友好信息量非常足。我拿一个数组越界的例子说明ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000eff5 at pc 0x0000004007e8 bp ... WRITE of size 1 at 0x60200000eff5 thread T0 #0 0x4007e7 in main /path/to/main.c:10 #1 0x7f0b3b1b6b96 in __libc_start_main ... #2 0x4006c9 in _start ... 0x60200000eff5 is located 0 bytes to the right of 5-byte region ... allocated by thread T0 here: #0 0x7f0b3b3e5e80 in __interceptor_malloc #1 0x4007c5 in main /path/to/main.c:9关键信息一目了然错误类型heap-buffer-overflow操作WRITE of size 1写了一个字节崩溃位置main.c:10被访问的内存在哪分配的main.c:9的malloc也就是说在main.c:9分配了5字节的堆内存然后在main.c:10向这块内存的末尾右侧写了一个字节。这比光看gdb的崩溃点强太多了它直接把“写入的源头”和“哪块内存被写破”都标出来了。5.3 ASan、Valgrind怎么选Valgrindmemcheck是另一个老牌的检测工具它的原理是二进制插桩不需要重新编译但性能开销远大于ASan通常慢10-50倍。我的选择逻辑很简单能改编译环境、能复现问题优先用ASan速度快、报告精准。程序是现成的二进制不方便重新编译用Valgrind直接跑valgrind --toolmemcheck --leak-checkfull ./myapp要检测内存泄漏Valgrind的泄漏检测做得更细能区分“definitely lost”“indirectly lost”“possibly lost”ASan的泄漏检测LSan也能做但输出风格不同。两个工具结合使用的场景很常见先用ASan快速扫雷再用Valgrind复核可疑场景这样可以兼顾速度和全面性。6. 一个真实案例的完整排查复盘前面讲了工具和方法这一节我用一个曾经排查过的真实案例把整个链路串起来。这个问题的特征非常典型偶发崩溃、gdb看崩溃点毫无规律、调度频率越高越容易崩。6.1 故障现象一个网络服务程序负责接收客户端请求并做处理后返回结果。上线后每隔几小时到几天不等会出现一次进程崩溃崩溃时系统日志记录segfault at ... ip ... sp ... error 4。gdb加载core文件后bt显示崩溃点在某个字符串处理函数里但每次bt的内容都不一样有时候在memcpy有时候在strlen有时候在业务回调函数里。6.2 初步排查看到这种“崩溃点不固定”的现象我第一反应不是去分析那个字符串函数而是怀疑内存已经被早先的非法写入破坏。因为如果每次崩溃在同一个固定位置那大概率是单纯的逻辑错误如果每次都在不同位置往往是同一个坏指针或者坏内存被不同函数“命中”。按这个思路我用gdb加载core重点查看了崩溃帧的寄存器信息(gdb) info registers rdi 0xffffffffffffff rsi 0x8cfardi寄存器在x86_64上通常是第一个参数0xffffffffffffff 这个值明显非法。这说明是一个函数被传入了一个极不正常的指针参数。但这还只能说明“这个函数的调用方传参有问题”不能定位到根因。6.3 用ASan揪出真正的元凶由于可以比较方便地复现客户环境跑一段时间就会崩我决定用ASan编译出来让它在测试环境跑。第一次跑ASan大概十几分钟后ASan就输出了报告而且问题的定位出乎意料地快ERROR: AddressSanitizer: heap-buffer-overflow on address ... WRITE of size 100 at ... #0 0x4a3b21 in parse_command /src/parse.c:220 #1 0x4a3c44 in process_request /src/process.c:156 ... 0x602000010000 is located 0 bytes to the right of 80-byte region ... allocated by thread T0 here: #0 ... in __interceptor_malloc #1 0x4a2c10 in build_response /src/process.c:98真相大白parse.c:220向build_response中分配的一块80字节的缓冲区执行了100字节的写操作缓冲区被越界写破了。问题在于我最初看gdb bt时只盯着崩溃点而崩溃点其实是“踩雷”的位置真正的“埋雷”位置在另一个模块的越界写。ASan的“allocated by”信息直接把分配位置和越界位置都列出来省掉了大量猜测。6.4 修复与反思修复方法是把parse.c:220处的写入改成带长度限制的安全版本比如用snprintf或strncpy并确认目标缓冲区的剩余空间足够。这个case最后反思下来最值得记住的一条经验是段错误的崩溃点不一定等于错误点越界写入的“凶手”和“受害者”往往在两个完全不同的函数里。如果我在第一步就该用ASan而不是在gdb里分析看似随机崩溃的调用栈整个排查过程会从一整天缩短到几十分钟。7. 项目里提前设防编译选项与编码习惯调试段错误总是被动的更好的策略是让段错误尽量少出现。这一节我从“防”的角度做一些可落地的建议。7.1 编译选项的防守配置开启编译器的警告选项很多隐患在编译阶段就能发现gcc -Wall -Wextra -Wformat2 -Wshadow -Wconversion -Wstrict-prototypes -Werror其中-Wall -Wextra是基础-Wformat2能检查printf/scanf系列函数参数不匹配-Wshadow能检查局部变量遮蔽全局变量的情况。如果项目可以接受建议加上-Werror把警告当错误强制消灭隐患。栈保护相关选项也可以开启gcc -fstack-protector-strong这个选项会在函数入口和出口处插入栈完整性校验代码一旦发生栈缓冲区溢出程序会在函数返回时触发__stack_chk_fail并终止运行。虽然它不能阻止溢出本身但能把“静默破坏”变成“立即崩溃”配合core文件定位难度降低一个量级。7.2 编码习惯的防守策略我个人的体会是减少段错误的核心不是技巧而是让代码里“出现非法指针”的概率尽可能低指针声明时立即初始化要么赋值为NULL要么赋值为合法地址绝不放空。malloc/calloc后立即检查返回值空指针不过夜。free之后马上把指针置NULL即使重复free也只是崩溃在可控位置而不是在千万里之外爆炸。所有外部输入、协议解析类代码一律使用带长度限制的函数如snprintf、strncpy、memcpy_s或自行检查长度。函数的边界条件用断言守护尤其是在入口处校验指针参数非空。这些都属于“老生常谈”但说实话我处理过的段错误案例里至少有八成以上翻来覆去都是这几个原因。提示不要把assert当作线上防御手段assert在NDEBUG定义下会被编译掉。线上对指针参数的合法性检查应该用显式的if判断而不是assert。7.3 把调试能力内建到产品中对于长时间运行的服务程序我强烈建议做两件事一是进程崩溃时自动生成core文件并配合监控脚本把core文件归档到独立目录方便事后分析二是在关键模块入口增加可选的日志开关记录指针、缓冲区大小等关键参数这样即使崩溃发生也能通过最后一条日志缩小排查范围。有的团队会用systemd的Restarton-failure让崩溃进程自动拉起这当然是运维上的兜底但要注意别让自动重启掩盖了问题。我见过某些服务靠自动重启“硬撑”了几个月结果内存越界写的bug在某个高峰期彻底爆炸数据损坏后才发现问题。自动重启是止血不是治病。8. 最后分享几个我常用的调试小技巧这几条是我自己在实践中反复用到的单独列出来作为补充。8.1 用系统dmesg快速确认崩溃信号程序崩溃后先看内核日志dmesg | tail -20能看到类似这样的记录myapp[12345]: segfault at 7f4a1c3e5000 ip 00007f4a1c3e5678 sp 00007fff12345678 error 4 in libc-2.31.so其中error 4表示引起异常的访问是“读操作”bit 2为1表示读为0表示写segfault at后面是触发异常的地址。这个信息可以用来快速判断是读还是写导致的段错误对缩小范围很有帮助。8.2 崩溃地址和映射区域对比把崩溃地址和进程的内存映射对比一下cat /proc/pid/maps看看崩溃地址属于哪个区域。如果地址不在任何映射区域内那就是完全非法的指针如果在某个映射区域内但访问权限不匹配那就是权限问题比如往只读段写入。这个操作其实是内核日志的延伸配合gdb的info proc mappings效果一样。8.3 静态分析工具作为补充除了运行时工具静态分析工具能在代码层面提前发现问题。C/C项目可以定期跑一下clang-tidy或cppcheck它们能识别出很多类型的空指针解引用、危险的类型转换和资源管理问题。工具不是万能的但确实能在代码审查之外多一道关卡。关于段错误调试说再多都不如实际动手排查一次。我自己的体会是遇到段错误时保持冷静按套路走——先看core文件和dmesg确认现场再用gdb看调用栈根据崩溃特征决定是直接定位还是上ASan/Valgrind深挖。这套流程走熟了九成的段错误都能在合理时间内解决。剩下那一成大概率是并发场景下内存竞态导致的破坏那就要靠ThreadSanitizer和数据竞态分析去解决了那是另一个话题。