一、调试基础:GDB入门与核心概念

调试这件事,说白了就是让程序告诉你它到底在想什么。我刚开始写嵌入式程序那会儿,最常用的调试手段就是往代码里塞printf,然后重新编译、烧录、看串口输出。嗯,现在回想起来,那真是又慢又痛苦。直到我真正学会了GDB,才感觉像是从石器时代走进了工业革命。

这一章,我们就来聊聊GDB这个工具。它不是万能的,但如果你不会用,调试效率至少打五折。

1.1 什么是GDB?为什么嵌入式开发离不开它?

GDB,全称GNU Debugger,是GNU项目下的一个调试器。你想想看,一个程序跑起来之后,你没法直接看到它的内部状态——变量值是多少?程序执行到哪一行了?栈空间还剩多少?这些问题,GDB都能回答。

我个人习惯把GDB比作程序的“CT机”。它不改变程序的运行逻辑,但能让你“透视”程序的内部结构。我在项目中遇到过好几次,程序跑着跑着就死机了,printf加了一堆也没找到原因。后来用GDB挂上去,单步执行了几步,立马发现是一个全局变量被意外修改了。这种问题,靠printf是几乎不可能定位的。

核心要点:GDB让你能够控制程序的执行过程,并观察程序内部的数据变化。这是printf调试法无法替代的能力。

1.2 GDB的两种工作模式

GDB有两种工作模式,你得先搞清楚它们的区别:

模式 适用场景 特点
本地调试 在开发机上直接调试本地程序 简单直接,适合纯软件调试
远程调试 调试嵌入式目标板上的程序 需要gdbserver配合,跨平台调试

嵌入式开发中,我们绝大多数情况用的是远程调试模式。因为程序跑在ARM、RISC-V这类芯片上,而GDB运行在PC端。两者通过网络或JTAG连接。

小提示:如果你刚开始学,建议先在本地用GDB调试一个简单的C程序。等熟悉了基本命令,再切换到远程调试模式。我当年就是直接上远程调试,结果被一堆网络配置问题搞得头大。

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监控那个成员,很快就找到了是哪个函数在非法写入。这种问题,用断点根本没法查。

注意:观察点会显著降低程序执行速度,因为GDB需要每次内存写入时都检查条件。在实时性要求高的嵌入式系统中,慎用大量观察点。

1.4 GDB调试的基本流程

一个典型的GDB调试会话,流程大概是这样的:

  1. 编译带调试信息的程序:编译时加上-g选项
  2. 启动GDBgdb ./my_program
  3. 设置断点:在怀疑出问题的位置设断点
  4. 运行程序run命令启动
  5. 程序停在断点处:检查变量、寄存器、栈信息
  6. 单步或继续执行:直到找到问题
  7. 修bug,重新编译,再调试

嗯,这里要注意一点:编译时一定要加-g选项,否则GDB看不到源代码和行号信息。我见过有人编译时忘了加-g,然后对着GDB的汇编代码发呆半天。

1.5 常用GDB命令速查

下面这些命令,是我日常用得最多的。建议你记下来:

命令 缩写 作用
break b 设置断点
run r 运行程序
continue c 继续执行到下一个断点
next n 单步执行(不进入函数)
step s 单步执行(进入函数)
print p 打印变量值
backtrace bt 查看函数调用栈
info locals 查看当前函数所有局部变量
list l 显示源代码
quit q 退出GDB
技巧:GDB支持Tab键自动补全命令。比如你输入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不仅帮你定位到了崩溃的位置,还让你在崩溃前看到了变量的值。这就是调试器的威力。

总结一下:GDB的核心价值在于“可控”和“可见”。可控,是指你能控制程序的执行节奏;可见,是指你能看到程序内部的所有状态。这两个能力,是printf调试法永远无法替代的。

1.7 本章知识体系

下面这张图,帮你梳理了本章的核心内容:

GDB调试基础 工作模式 本地调试 远程调试 核心概念 断点 (break) 单步 (step/next) 观察点 (watch) 调试流程 编译(-g) → 启动 设断点 → 运行 检查 → 修复 核心价值:可控 + 可见

这张图把GDB调试的核心要素都串起来了。你从工作模式入手,掌握核心概念,再按照调试流程一步步操作,就能解决大部分问题。

我的建议:别急着背命令。先拿一个简单的程序,打开GDB,把break、next、print、continue这几个命令用熟。用熟了之后,其他命令自然就记住了。

调试是一门手艺,光看书是学不会的。打开终端,写个带bug的程序,然后用GDB把它揪出来。这个过程重复十次,你就入门了。


公众号:蓝海资料掘金营,微信deep3321