前面我们一路从变量、指针、数据结构写到树。代码越来越复杂,bug 也越来越狡猾,不再是简单漏个分号,而是段错误、内存泄漏、逻辑诡异这些"看不见的敌人"。
很多人学 C 语言时,调试全靠 printf。打印变量、打印"到这里了",然后一遍遍重新编译运行。这在小程序里还凑合,但代码上千行之后,printf 大法就捉襟见肘了------你没法在运行中停下来检查状态,也没法追溯崩溃瞬间的调用栈。
今天,我们就来装备真正的调试工具和方法。学会它们,你会从一个"靠运气改 bug"的程序员,变成"靠推理定位 bug"的工程师。
一、先认识敌人:C 语言常见错误分类
在学工具之前,先搞清楚我们到底在对付什么。
1. 编译错误(Compile Error)
语法错误、类型不匹配、缺少分号......编译器直接报错,拒绝生成可执行文件。这类最好解决,仔细读错误信息即可。
2. 链接错误(Link Error)
undefined reference to 'foo'------函数声明了但没找到实现。通常是忘记编译某个 .c 文件,或者拼错了函数名。
3. 运行时错误(Runtime Error)
程序编译通过,但运行到一半崩溃了。典型的就是段错误(Segmentation Fault,信号 SIGSEGV),原因几乎总是指针问题:
- 解引用了
NULL指针。 - 解引用了野指针(未初始化、或已释放的悬垂指针)。
- 数组越界写坏了返回地址。
4. 逻辑错误(Logic Error)
程序运行完,没有崩溃,但结果不对。排序没排好,计算结果差一位。这类错误最难找,因为程序"看起来正常"。
二、防御式编程:assert 让错误早暴露
在讲调试工具之前,先学一个最简单却极有用的手段------断言(assert)。
c
#include <assert.h>
int divide(int a, int b) {
assert(b != 0); // 如果 b 是 0,程序立刻终止并报错
return a / b;
}
assert 是一个宏,它的参数是一个表达式。如果表达式为假(0),它会:
- 打印出错的文件名、行号、失败的表达式。
- 调用
abort()终止程序。
什么时候用? 用来检查"绝对不应该发生"的情况。比如函数的先决条件、循环不变式、指针非空假设等。它不是用来处理用户输入错误(如文件不存在应该用 if),而是用来抓程序员自己的逻辑错误。
发布版本怎么处理? 在包含 <assert.h> 之前定义 NDEBUG 宏,所有 assert 就会变成空操作,不产生任何运行时代码。
c
#define NDEBUG
#include <assert.h>
防御式编程的核心思想:早崩溃比晚崩溃好,崩溃比静默错误好。一个带着明确错误信息的崩溃,远比一个错误的输出更容易修复。
三、GDB:调试器的力量
GDB(GNU Debugger)是 Linux/macOS 下最常用的命令行调试器。它能让你:
- 在任意一行设置断点暂停程序。
- 查看变量的当前值。
- 逐行执行代码。
- 崩溃时查看调用栈。
Windows 用户可以用 MinGW 自带的 GDB,或在 VS Code 中通过图形界面使用它。
1. 编译时加 -g
要让 GDB 能显示源代码和变量名,编译时必须加 -g 选项:
bash
gcc -g -o program program.c
千万记得开 -g,没有调试信息的程序在 GDB 里只能看到汇编指令。
2. 启动和基本命令
bash
gdb ./program
进入 GDB 后,常用命令:
| 命令 | 缩写 | 功能 |
|---|---|---|
run |
r |
运行程序(可带命令行参数) |
break main |
b main |
在 main 函数设置断点 |
break 10 |
b 10 |
在当前文件的第 10 行设断点 |
next |
n |
执行下一行(不进入函数内部) |
step |
s |
执行下一行(进入函数内部) |
continue |
c |
继续运行直到下一个断点 |
print x |
p x |
打印变量 x 的值 |
print *p |
p *p |
打印指针指向的内容 |
backtrace |
bt |
显示调用栈 |
frame 1 |
f 1 |
切换到调用栈的第 1 层 |
list |
l |
显示当前位置的源代码 |
quit |
q |
退出 GDB |
3. 实战演示:揪出段错误
考虑一个带 bug 的程序:
c
// buggy.c
// buggy.c
#include <stdio.h>
void buggy_function() {
int *p = NULL; // p 是一个空指针,不指向任何有效内存
*p = 42; // 试图向地址 0x0 写入数据 ------ 非法操作
printf("%d\n", *p);
}
int main(void) {
buggy_function();
return 0;
}
编译并运行:
bash
gcc -g -o buggy buggy.c
./buggy
# Segmentation fault (core dumped)
不知道哪里崩了?用 GDB:
bash
gdb ./buggy
(gdb) run
程序崩溃后,GDB 会停在崩溃处,显示类似:
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555161 in buggy_function () at buggy.c:7
7 *p = 42; // 试图向地址 0x0 写入数据 ------ 非法操作
这告诉我们崩在 buggy_function 里(*p = 42 试图向地址 0x0 写入数据)。输入 bt 看调用栈:
(gdb) bt
#0 0x0000555555555161 in buggy_function () at buggy.c:7
#1 0x0000555555555198 in main () at buggy.c:13
一目了然:main 调了 buggy_function,试图向地址 0x0 写入数据。
我们还可以在 buggy_function 设断点检查参数:
bash
(gdb) break 7
Breakpoint 1 at 0x115d: file buggy.c, line 7.
(gdb) run
Starting program: /home/gugu/cprog/buggy
This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n])
Debuginfod has been disabled.
To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, buggy_function () at buggy.c:7
7 *p = 42; // 试图向地址 0x0 写入数据 ------ 非法操作
(gdb) print p
$1 = (int *) 0x0
(gdb) print *p
Cannot access memory at address 0x0
有了 GDB,段错误从"玄学"变成了可追溯的事件。
4. VS Code 图形化调试
如果你不习惯命令行,VS Code 的调试功能是对 GDB 的封装。核心步骤:
- 在代码行号左侧单击设断点(红点)。
- 按 F5 启动调试。
- 用顶部的调试工具栏控制步进。
- 左侧面板查看变量、调用栈。
底层的原理和 GDB 完全一样,只是把命令变成了图形按钮。
如果你的系统中还没有 GDB,可以通过 pacman 包管理器安装。
对于 MINGW64 环境:
pacman -S mingw-w64-x86_64-gdb对于 UCRT64 环境 (推荐):
pacman -S mingw-w64-ucrt-x86_64-gdb
四、Valgrind:内存泄漏侦探
Valgrind 是 Linux/macOS 下的内存分析神器,它能在程序运行时检测:
- 内存泄漏 (忘
free的内存)。 - 非法访问(越界读写、使用已释放的内存)。
- 未初始化的值用于判断。
最常用的工具是 memcheck,也是 Valgrind 的默认工具。
1. 基本用法
bash
valgrind --leak-check=full ./program
注意:程序编译时最好也加 -g,这样 Valgrind 能指出源码行号。
2. 示例:检测泄漏
c
// leaky.c
#include <stdlib.h>
int main(void) {
int *p = (int*)malloc(100 * sizeof(int));
p[0] = 42;
// 忘了 free(p)
return 0;
}
运行 Valgrind:
bash
gcc -g -o leaky leaky.c
valgrind --leak-check=full ./leaky
输出中关键信息:
==12345== HEAP SUMMARY:
==12345== in use at exit: 400 bytes in 1 blocks
==12345== total heap usage: 1 allocs, 0 frees, 400 bytes allocated
==12345==
==12345== 400 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x1086B4: main (leaky.c:5)
它精确告诉你:
- 泄漏了 400 字节(1 个块)。
- 是第 5 行的
malloc分配的。 definitely lost意味着这块内存确实没人管了。
3. 其他检测能力
Valgrind 还能抓住:
-
使用未初始化的值:
Conditional jump or move depends on uninitialised value(s) -
越界读写:
Invalid write of size 4 -
使用已释放的内存。
Valgrind 的缺点是会让程序慢 10~20 倍,但测试时完全值得。Windows 用户如果没有 WSL,可以尝试 Dr. Memory 或 Visual Studio 的内置内存诊断工具。
五、调试策略:不是碰运气,而是有章法
工具是手,策略是脑。遇到 bug 时,用以下流程代替"到处加 printf":
1. 复现
首先确认能稳定复现。如果 bug 随机出现,记下出现时输入数据的特征、操作步骤,尝试找到规律。
2. 缩小范围
用"二分法"定位。比如数据处理链条很长,中间打印关键变量,看是哪个环节开始不对。或者注释掉一半代码,看 bug 是否还在,逐步逼近。
3. 断点定位
在可疑代码行设断点,查看变量值是否符合预期。不符合------往前追;符合------往后查。这就是"科学方法"在调试中的体现。
4. 检查假设
bug 往往源于一个错误的假设:"这里传进来的一定不是 NULL""循环次数肯定是够的""这个变量应该已经初始化了"。用 assert 或断点逐一验证这些假设。
5. 橡皮鸭调试法
把代码逻辑讲给别人听(或一只橡皮鸭),逐行解释。很多时候你讲到一半自己就发现问题了。
六、常见问题速查
段错误 Segmentation Fault
- 用 GDB 运行,崩后
bt看调用栈。 - 检查栈上所有的指针操作------有没有
NULL解引用、越界、释放后使用。
内存泄漏
- Valgrind 跑一遍,看
definitely lost和indirectly lost。 - 成对检查
malloc/free、fopen/fclose。
输出结果不对
- 在关键计算节点打印中间值。
- 检查类型转换(尤其是整数除法、符号转换)。
- 检查循环边界(
<vs<=)。
程序偶发崩溃
- 极可能是未初始化变量或竞态条件(多线程,后续会讲)。
- 使用
valgrind检查未初始化值。 - 检查所有路径是否给变量赋了值。
七、小结
今天你告别了"printf 调试时代":
- 认识了常见错误分类:编译错、链接错、运行时错、逻辑错。
assert让不该发生的事尽早暴露,是防御式编程的基石。- GDB 让你在程序运行时暂停、查看、追溯,段错误不再可怕。
- Valgrind 是内存泄漏的照妖镜,把每一块没归还的内存都揪出来。
- 调试策略比工具更重要:复现 → 缩小 → 断点 → 验证假设。
这些工具和思维,会在后面写更复杂的系统级程序时成为你的左右手。下一篇文章,我们将进入一个新的意识领域------性能优化意识启蒙。不是去学复杂的算法,而是理解程序为什么慢,编译器能帮你做什么,以及你自己应该怎么写高效的 C 代码。
课后小练习
- 写一个会产生段错误的程序(比如解引用
NULL指针),用 GDB 运行并找到崩溃位置和原因。 - 写一个有内存泄漏的程序(
malloc后不free),用 Valgrind 检测并观察报告。 - 在之前的链表或二叉树代码中加入
assert,检查关键指针是否非空、索引是否越界。然后尝试传入非法的数据(比如NULL指针),观察assert的行为。 - (小挑战)用 GDB 的
break和print命令跟踪二叉搜索树的查找过程:设断点、打印当前结点值、观察递归深入路径。总结你的体验,把它写成一段"GDB 使用笔记"供自己以后查阅。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。