调试技巧:GDB基本使用,条件断点,watchpoint,backtrace
说实话,写C语言代码这么多年,我最怕的不是编译不过,而是程序跑起来之后莫名其妙地崩溃或者结果不对。你想想看,一个嵌入式程序,可能跑了几个小时才出问题,这时候靠printf打日志?效率太低了。我个人习惯是,遇到这种疑难杂症,直接上GDB。
GDB的全称是GNU Debugger,说白了就是Linux下最经典的调试器。它不需要图形界面,一个命令行就能搞定。今天我就把GDB最核心的几个功能——条件断点、watchpoint、backtrace——给你讲透。
核心观点:GDB不是万能的,但如果你不会用GDB,调试复杂bug的效率至少低三倍。
24.1 GDB基本使用:启动、运行、暂停
先说说怎么启动GDB。假设你有一个程序叫app,编译的时候记得加-g选项:
gcc -g -o app app.c
gdb ./app
进入GDB之后,常用的命令就这几个:
| 命令 | 作用 | 我常用的场景 |
|---|---|---|
run 或 r |
运行程序 | 第一次启动,看看能不能跑起来 |
break 或 b |
设置断点 | 在可疑函数入口停住 |
next 或 n |
单步执行(不进入函数) | 跳过库函数调用 |
step 或 s |
单步执行(进入函数) | 查看自定义函数内部逻辑 |
continue 或 c |
继续运行 | 跳过当前断点,跑到下一个 |
print 或 p |
打印变量值 | 检查变量是否异常 |
quit 或 q |
退出GDB | 调试结束 |
举个例子,假设你有一个函数process_data,你想看看它执行时参数对不对:
(gdb) b process_data
(gdb) r
(gdb) p input_value
$1 = 42
(gdb) n
(gdb) p result
$2 = 0
嗯,这里要注意:p命令打印出来的$1、$2是GDB自动给的值历史编号,你可以直接用$1引用之前的结果。
24.2 条件断点:只在特定条件下停住
我在项目中遇到过最头疼的情况:一个循环跑了10000次,第9999次才出错。你总不能按n按到手抽筋吧?这时候条件断点就派上用场了。
条件断点的语法很简单:
(gdb) b 行号 if 条件表达式
比如:
// 代码片段
for (int i = 0; i < 10000; i++) {
process_item(i);
}
你可以在循环体里设置条件断点:
(gdb) b 10 if i == 9999
(gdb) r
这样程序只在i等于9999时停住。省时省力。
小技巧:条件断点也可以用在函数参数上。比如b process_item if value < 0,当传入负数时自动停住。我曾经用这个技巧抓到一个罕见的参数越界bug。
还有一个常用场景:调试内存泄漏时,你可以在malloc上设置条件断点,只关注特定大小的分配:
(gdb) b malloc if size == 1024
这样每次分配1024字节时都会停住,你可以查看调用栈,找到是谁在分配。
24.3 Watchpoint:监视变量的变化
断点是在特定位置停住,但有时候你根本不知道变量在哪里被修改了。比如一个全局变量g_counter,莫名其妙变成了负数。你翻遍所有代码也找不到赋值的地方。怎么办?
用watchpoint。它监视一个变量,只要值发生变化,GDB就会自动停住。
(gdb) watch g_counter
然后运行程序,当g_counter的值被修改时,GDB会停住并显示旧值和新值:
Hardware watchpoint 2: g_counter
Old value = 10
New value = -5
main () at app.c:42
42 g_counter = -5; // 原来是你!
watchpoint有三种形式:
| 命令 | 作用 |
|---|---|
watch 变量 |
变量被写入时停住 |
rwatch 变量 |
变量被读取时停住 |
awatch 变量 |
变量被读取或写入时都停住 |
注意:watchpoint依赖硬件支持,嵌入式平台上可能有限制。我曾经在ARM Cortex-M3上调试,硬件watchpoint只有2个,用完了就得手动删除旧的。
删除watchpoint用info watchpoints查看编号,然后用delete 编号删除。
24.4 Backtrace:查看函数调用栈
程序崩溃时,最想知道的就是「谁调了谁,最后死在哪」。backtrace就是干这个的。
当程序停在断点处或者崩溃时,输入backtrace(简写bt):
(gdb) bt
#0 0x08048400 in divide (a=10, b=0) at app.c:15
#1 0x08048450 in calculate (x=10, y=0) at app.c:25
#2 0x080484a0 in main () at app.c:35
这个输出告诉你:
#0:当前停在divide函数,参数b=0,第15行#1:divide被calculate调用,参数y=0#2:calculate被main调用
一眼就能看出,是main传了y=0给calculate,然后calculate传了b=0给divide,导致除零错误。
你还可以用frame 编号切换到指定栈帧,查看局部变量:
(gdb) frame 1
(gdb) p x
$3 = 10
(gdb) p y
$4 = 0
这样就能一层一层往上查,找到问题的根源。
避坑指南:我曾经遇到一个bug,程序在某个回调函数里崩溃,backtrace显示调用栈只有一层。后来发现是栈被破坏了,GDB无法回溯。这种情况,先检查是不是有局部数组越界或者栈溢出。
24.5 综合实战:用GDB定位一个段错误
光说不练假把式。我们来看一个真实场景。
假设你有这样一段代码:
#include <stdio.h>
#include <stdlib.h>
void fill_data(int *arr, int len) {
for (int i = 0; i <= len; i++) { // 注意:这里是 <=,越界了
arr[i] = i * 2;
}
}
int main() {
int *data = (int *)malloc(10 * sizeof(int));
if (!data) return -1;
fill_data(data, 10);
printf("%d\n", data[0]);
free(data);
return 0;
}
运行后段错误。用GDB调试:
gdb ./app
(gdb) r
Starting program: ./app
Program received signal SIGSEGV, Segmentation fault.
0x08048410 in fill_data (arr=0x804a008, len=10) at app.c:6
6 arr[i] = i * 2;
先看backtrace:
(gdb) bt
#0 0x08048410 in fill_data (arr=0x804a008, len=10) at app.c:6
#1 0x08048460 in main () at app.c:14
嗯,死在fill_data的第6行。看看i的值:
(gdb) p i
$1 = 10
数组长度是10,下标最大是9,这里i=10,明显越界了。再看arr[10]写到了哪里:
(gdb) p &arr[10]
$2 = (int *) 0x804a030
这个地址可能属于堆管理结构或者另一个变量,写进去就崩了。
修复方法很简单:把<=改成<。但如果没有GDB,你可能要花半小时一行一行看代码。
24.6 本章知识体系
下面这张图总结了GDB调试的核心流程和命令关系:
这张图展示了GDB调试的完整链路:启动程序 → 设置断点 → 运行 → 执行调试操作。右侧的三个特殊功能——条件断点、watchpoint、backtrace——是解决复杂问题的利器。
24.7 避坑总结
最后,我把自己这些年用GDB踩过的坑总结一下:
- 编译时一定要加
-g:不加的话,GDB看不到源码行号和变量名,只能看到汇编。我曾经有一次忘了加,调试了一下午才发现。 - 优化级别不要太高:
-O2或-O3会改变代码执行顺序,导致单步执行跳来跳去。调试时用-O0或-Og。 - watchpoint有数量限制:嵌入式平台通常只有2-4个硬件watchpoint。用
info watchpoints查看当前有哪些。 - 多线程调试要加
set follow-fork-mode child:如果程序fork了子进程,默认GDB只跟踪父进程。我踩过这个坑,子进程崩溃了GDB毫无反应。 - 段错误先看backtrace:不要一上来就到处设断点。先
bt看调用栈,定位到具体函数,再设断点细查。
GDB这东西,说白了就是熟能生巧。刚开始可能觉得命令行麻烦,但用顺手之后,你会发现它比任何IDE调试器都灵活。下次遇到程序崩溃,别急着加printf,试试GDB,你会感谢自己的。
公众号:蓝海资料掘金营,微信deep3321