一、调试基础:GDB入门与核心概念
调试这件事,说白了就是让程序告诉你它到底在想什么。我刚开始写嵌入式程序那会儿,最常用的调试手段就是往代码里塞printf,然后重新编译、烧录、看串口输出。嗯,现在回想起来,那真是又慢又痛苦。直到我真正学会了GDB,才感觉像是从石器时代走进了工业革命。
这一章,我们就来聊聊GDB这个工具。它不是万能的,但如果你不会用,调试效率至少打五折。
1.1 什么是GDB?为什么嵌入式开发离不开它?
GDB,全称GNU Debugger,是GNU项目下的一个调试器。你想想看,一个程序跑起来之后,你没法直接看到它的内部状态——变量值是多少?程序执行到哪一行了?栈空间还剩多少?这些问题,GDB都能回答。
我个人习惯把GDB比作程序的“CT机”。它不改变程序的运行逻辑,但能让你“透视”程序的内部结构。我在项目中遇到过好几次,程序跑着跑着就死机了,printf加了一堆也没找到原因。后来用GDB挂上去,单步执行了几步,立马发现是一个全局变量被意外修改了。这种问题,靠printf是几乎不可能定位的。
1.2 GDB的两种工作模式
GDB有两种工作模式,你得先搞清楚它们的区别:
| 模式 | 适用场景 | 特点 |
|---|---|---|
| 本地调试 | 在开发机上直接调试本地程序 | 简单直接,适合纯软件调试 |
| 远程调试 | 调试嵌入式目标板上的程序 | 需要gdbserver配合,跨平台调试 |
嵌入式开发中,我们绝大多数情况用的是远程调试模式。因为程序跑在ARM、RISC-V这类芯片上,而GDB运行在PC端。两者通过网络或JTAG连接。
1.3 GDB的核心概念:断点、单步、观察点
GDB的功能很多,但最核心的就三个概念。你把这几个搞明白了,80%的调试场景都能应付。
1.3.1 断点(Breakpoint)
断点就是告诉GDB:“程序执行到这一行,停下来等我”。这是最常用的功能。
(gdb) break main # 在main函数入口设断点
(gdb) break 42 # 在第42行设断点
(gdb) break file.c:100 # 在file.c的第100行设断点
(gdb) info breakpoints # 查看所有断点
(gdb) delete 1 # 删除编号为1的断点
我曾经犯过一个低级错误:在循环体里设了断点,然后疯狂按c继续执行,结果按了上百次才跳出循环。后来才知道,可以用condition给断点加条件,比如只在循环变量等于某个值时触发。
1.3.2 单步执行(Step & Next)
单步执行让你能一行一行地看程序是怎么跑的。这里有两个容易混淆的命令:
- step (s):进入函数内部
- next (n):不进入函数,直接执行完当前函数调用
举个例子:
int main() {
int a = 10;
int b = 20;
int c = add(a, b); // 如果这里用next,直接得到结果;用step,会进入add函数内部
printf("%d\n", c);
return 0;
}
我个人习惯是:如果怀疑某个函数内部有问题,就用step进去看;如果只是想跳过,就用next。别小看这个选择,调试效率差很多。
1.3.3 观察点(Watchpoint)
观察点是个好东西。它监控某个变量或内存地址,一旦值发生变化,程序就停下来。
(gdb) watch my_var # 监控my_var变量
(gdb) watch *(int*)0x20001000 # 监控某个内存地址
(gdb) info watchpoints # 查看所有观察点
我记得有一次排查一个内存被踩的问题,程序跑着跑着某个结构体成员就变成乱码了。用watch监控那个成员,很快就找到了是哪个函数在非法写入。这种问题,用断点根本没法查。
1.4 GDB调试的基本流程
一个典型的GDB调试会话,流程大概是这样的:
- 编译带调试信息的程序:编译时加上
-g选项 - 启动GDB:
gdb ./my_program - 设置断点:在怀疑出问题的位置设断点
- 运行程序:
run命令启动 - 程序停在断点处:检查变量、寄存器、栈信息
- 单步或继续执行:直到找到问题
- 修bug,重新编译,再调试
嗯,这里要注意一点:编译时一定要加-g选项,否则GDB看不到源代码和行号信息。我见过有人编译时忘了加-g,然后对着GDB的汇编代码发呆半天。
1.5 常用GDB命令速查
下面这些命令,是我日常用得最多的。建议你记下来:
| 命令 | 缩写 | 作用 |
|---|---|---|
| break | b | 设置断点 |
| run | r | 运行程序 |
| continue | c | 继续执行到下一个断点 |
| next | n | 单步执行(不进入函数) |
| step | s | 单步执行(进入函数) |
| p | 打印变量值 | |
| backtrace | bt | 查看函数调用栈 |
| info locals | 无 | 查看当前函数所有局部变量 |
| list | l | 显示源代码 |
| quit | q | 退出GDB |
b然后按Tab,它会列出所有以b开头的命令。这个功能能省不少打字时间。
1.6 一个完整的调试示例
光说不练假把式。我们来看一个实际例子。假设有下面这个程序:
// test.c
#include <stdio.h>
int divide(int a, int b) {
return a / b; // 如果b为0,这里会崩溃
}
int main() {
int x = 10;
int y = 0;
int result = divide(x, y);
printf("result = %d\n", result);
return 0;
}
编译并启动GDB:
$ gcc -g -o test test.c
$ gdb ./test
在GDB中:
(gdb) break divide
(gdb) run
Breakpoint 1, divide (a=10, b=0) at test.c:4
4 return a / b;
(gdb) print a
$1 = 10
(gdb) print b
$2 = 0
(gdb) print a / b
$3 = 0 // 这里GDB其实已经做了除法,但不会崩溃,因为GDB内部处理了异常
(gdb) continue
Program received signal SIGFPE, Arithmetic exception.
0x0000555555555149 in divide (a=10, b=0) at test.c:4
4 return a / b;
看到了吗?GDB不仅帮你定位到了崩溃的位置,还让你在崩溃前看到了变量的值。这就是调试器的威力。
1.7 本章知识体系
下面这张图,帮你梳理了本章的核心内容:
这张图把GDB调试的核心要素都串起来了。你从工作模式入手,掌握核心概念,再按照调试流程一步步操作,就能解决大部分问题。
调试是一门手艺,光看书是学不会的。打开终端,写个带bug的程序,然后用GDB把它揪出来。这个过程重复十次,你就入门了。
公众号:蓝海资料掘金营,微信deep3321