第39章:常见错误与调试技巧——段错误、内存泄漏、野指针的排查,使用GDB调试
说实话,C语言程序员最怕什么?不是算法难,不是逻辑复杂,而是那些莫名其妙就崩掉的运行时错误。段错误、内存泄漏、野指针——这三个词,我敢说每个写过C的人看到都会心头一紧。
我自己刚入行那会儿,有一次项目上线前夜,程序跑着跑着就Segmentation Fault了。我盯着屏幕看了三个小时,最后发现是一个野指针在作祟。从那以后,我就养成了几个调试习惯,今天全部分享给你。
本章核心:掌握三大常见错误的本质原因,学会用GDB高效定位问题。
39.1 段错误(Segmentation Fault)
段错误说白了就是程序访问了不该访问的内存。操作系统有个内存保护机制,你越界了,它就给你发个SIGSEGV信号,程序直接崩掉。
39.1.1 常见触发场景
- 解引用空指针——最常见,没有之一
- 数组越界——尤其是写操作,踩到别人的地盘
- 栈溢出——递归太深或者局部变量太大
- 访问已释放的内存——use-after-free
// 典型段错误示例
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = NULL;
*p = 42; // 段错误!解引用空指针
int arr[10];
arr[100] = 5; // 越界写入,可能段错误
int *q = malloc(100);
free(q);
*q = 10; // use-after-free,段错误
return 0;
}
注意:数组越界不一定会立即段错误,它可能悄悄覆盖了别的变量。这种bug最难查,因为症状和原因不在同一个地方。
39.2 内存泄漏
内存泄漏不会让程序立刻崩,但它像慢性病。跑一天没事,跑一周就开始卡,跑一个月直接OOM被系统杀掉。
我在项目中遇到过最离谱的一次:一个后台服务跑了28天,内存从50MB涨到了8GB。查到最后,是一个循环里每次malloc都没free。
39.2.1 泄漏的典型模式
- 忘记free——分配了堆内存,但没释放
- 丢失指针——重新赋值前没释放旧内存
- 异常路径未释放——函数中间return了,free没执行到
// 内存泄漏示例
void leak_example() {
char *buf = malloc(1024);
// 用buf做点事...
// 忘记free(buf) — 泄漏了
// 另一种:指针被覆盖
int *data = malloc(100 * sizeof(int));
data = malloc(200 * sizeof(int)); // 第一次分配的内存丢了
free(data); // 只释放了第二次的
}
// 正确做法
void good_example() {
char *buf = malloc(1024);
if (!buf) return;
// 使用buf...
free(buf); // 成对出现
}
个人习惯:我写malloc的时候,会立刻写对应的free。哪怕中间有100行代码,free先占个位置。这叫「配对编程法」,能减少90%的泄漏。
39.3 野指针
野指针就是指向「不确定」内存的指针。它比空指针更危险,因为空指针至少会段错误,野指针可能静默地破坏数据。
39.3.1 野指针的三大来源
| 来源 | 说明 | 示例 |
|---|---|---|
| 未初始化 | 局部指针变量没赋初值 | int *p; *p = 1; |
| 释放后未置空 | free之后指针还在,指向已释放内存 | free(p); // p还是原来的地址 |
| 返回局部变量地址 | 函数返回了栈上变量的地址 | return &local_var; |
// 野指针的典型
int* get_wild() {
int local = 42;
return &local; // 危险!函数返回后local就没了
}
int main() {
int *p; // 未初始化,野指针
*p = 10; // 不知道写到哪里去了
int *q = malloc(4);
free(q);
// q现在是野指针
*q = 100; // 可能崩,也可能不崩,看运气
int *r = get_wild();
printf("%d\n", *r); // 未定义行为
}
我曾经被一个野指针折磨了两天。现象是程序偶尔崩溃,但大部分时间正常。最后发现是free之后没置NULL,另一个线程又去读了这个指针。嗯,从那以后我free完必加一句p = NULL;。
39.4 使用GDB调试
GDB是C程序员的瑞士军刀。说实话,没有GDB我可能早就转行了。它让你能「钻进」程序内部,看变量、看堆栈、看内存。
39.4.1 编译时准备
要用GDB调试,编译时必须加-g选项,保留符号信息。
gcc -g -o myapp myapp.c # 加-g,保留调试信息
gcc -O0 -g -o myapp myapp.c # -O0关闭优化,调试更准确
39.4.2 基本调试流程
# 启动GDB
gdb ./myapp
# 常用命令(我每天用这些)
(gdb) run # 运行程序
(gdb) break main # 在main函数设断点
(gdb) break 42 # 在第42行设断点
(gdb) next # 单步执行(不进入函数)
(gdb) step # 单步执行(进入函数)
(gdb) print x # 打印变量x的值
(gdb) backtrace # 查看调用栈
(gdb) list # 查看源代码
(gdb) quit # 退出
39.4.3 调试段错误
程序崩了之后,用GDB看它「死在哪里」是最快的。
# 方法一:直接运行,等它崩
gdb ./myapp
(gdb) run
...程序崩溃...
(gdb) backtrace # 看调用栈,定位到哪一行崩的
(gdb) print p # 看那个指针的值
# 方法二:用core dump
ulimit -c unlimited # 允许生成core文件
./myapp # 运行,崩溃后生成core
gdb ./myapp core # 用core文件调试
(gdb) backtrace # 直接看到崩溃位置
我建议:开发环境一定要开core dump。有时候bug复现概率低,core文件就是破案的关键证据。
39.4.4 检查内存泄漏
GDB本身不直接检测泄漏,但配合Valgrind就无敌了。
# 用Valgrind检测内存泄漏
valgrind --leak-check=full ./myapp
# 输出示例:
# ==12345== 100 bytes in 1 blocks are definitely lost
# ==12345== at 0x4C2B800: malloc (vg_replace_malloc.c:299)
# ==12345== by 0x4005E4: leak_example (test.c:8)
# ==12345== by 0x4005F8: main (test.c:15)
# 看到没?直接告诉你哪一行泄漏了
39.4.5 实战:用GDB定位野指针
// buggy.c
#include <stdio.h>
#include <stdlib.h>
int main() {
int *p = malloc(4);
*p = 42;
free(p);
// 忘记置NULL
*p = 100; // 野指针写入
printf("Done\n");
return 0;
}
// GDB调试过程
// $ gcc -g -o buggy buggy.c
// $ gdb ./buggy
// (gdb) run
// Program received signal SIGSEGV, Segmentation fault.
// 0x0000000000400527 in main () at buggy.c:9
// 9 *p = 100; // 野指针写入
// (gdb) print p
// $1 = (int *) 0x0 // 或者某个无效地址
// (gdb) info locals
// p = 0x0
// 真相大白:free之后p没置NULL,但内存已经被回收了
39.5 调试心法总结
调试不是瞎猜,是有章法的。我总结了一套流程,你试试看:
- 复现——稳定复现是第一步,不能复现的bug等于不存在
- 二分法——注释掉一半代码,看问题还在不在,缩小范围
- 加日志——关键位置加printf,看变量值的变化
- 用工具——GDB看堆栈,Valgrind查内存,AddressSanitizer抓越界
- 修一个测一个——别一次改一堆,改完立刻验证
记住:段错误、内存泄漏、野指针,本质上都是「内存管理」的问题。C语言给了你自由,但也给了你责任。每次malloc都要问自己:谁来free?每次用指针都要问自己:它指向的内存还活着吗?
调试这件事,说白了就是「大胆假设,小心求证」。别怕bug,每个bug都是你成长的机会。我到现在还记得第一次用GDB定位到段错误时的那种成就感——嗯,比写新代码还爽。