2. GDB核心概念:断点、观察点、捕获点、程序状态、栈帧、线程、进程

调试嵌入式程序,说白了就是跟程序的状态打交道。你想想看,一个程序跑飞了、死机了、数据不对了,本质上都是某个时刻的状态出了问题。GDB 给了我们一套工具,让我们能「冻结」时间,查看现场。这套工具的核心,就是今天要聊的几个概念。

我个人习惯把 GDB 的调试能力分成两类:一类是「主动打断」,一类是「被动观察」。断点属于前者,观察点和捕获点属于后者。而栈帧、线程、进程,则是我们观察程序状态时的不同维度。

2.1 断点(Breakpoint)—— 最常用的「暂停键」

断点,说白了就是告诉 GDB:「程序执行到这一行,给我停下来。」这是最基础、最常用的调试手段。

设置断点的方式有很多,我列几个常用的:

  • 按行号设置:b main.c:42 —— 在 main.c 的第 42 行停下
  • 按函数名设置:b my_function —— 进入 my_function 时停下
  • 按条件设置:b main.c:42 if count > 5 —— 只有 count 大于 5 时才停
我的经验:条件断点非常有用,但要注意性能。如果你在循环里设条件断点,每次循环 GDB 都要检查条件,程序会跑得很慢。我曾经在一个 10 万次的循环里设条件断点,结果等了半分钟才停下来。后来我改成手动在循环里加 if (count == 50000) { asm("int3"); },瞬间就快了。

断点还有一个容易被忽略的特性:断点编号。每个断点都有一个编号,你可以用 info breakpoints 查看所有断点,然后用 disable 1 临时禁用某个断点,或者用 delete 1 删除它。

注意:在嵌入式调试中,断点数量是有限的。硬件断点通常只有 2-4 个,软件断点虽然理论上无限,但会修改指令内存,在某些只读内存区域(如 Flash)上无法使用。我曾经在调试一个 Bootloader 时,发现断点设不上,折腾了半天才意识到代码在 Flash 里跑。

2.2 观察点(Watchpoint)—— 数据变化的「监视器」

断点关注的是「代码执行到哪里」,而观察点关注的是「数据什么时候变了」。当你怀疑某个变量被意外修改时,观察点就是你的救星。

设置观察点的语法:

watch my_variable      // 当 my_variable 被写入时停下
rwatch my_variable     // 当 my_variable 被读取时停下
awatch my_variable     // 当 my_variable 被读取或写入时停下

我记得有一次,一个同事调试了三天,发现一个全局变量莫名其妙被改成了 0xDEADBEEF。他翻遍了所有代码,都没找到谁在写这个变量。我让他用 watch 观察这个变量,结果程序一跑就停下来了——停在一个中断服务函数里,那个函数里有个野指针,刚好写到了这个变量的地址上。

核心要点:观察点依赖硬件调试模块(如 ARM 的 DWT 单元),数量非常有限(通常只有 1-4 个)。如果你设了多个观察点,GDB 会用软件模拟,但那样程序会慢得像蜗牛。

2.3 捕获点(Catchpoint)—— 事件的「触发器」

捕获点比断点更高级一些。它不关心具体的行号或变量,而是关心「事件」。比如:

  • catch syscall —— 捕获系统调用
  • catch throw —— 捕获 C++ 异常抛出
  • catch signal —— 捕获信号
  • catch fork —— 捕获进程创建

在嵌入式开发中,我最常用的是 catch signal。比如程序莫名其妙收到 SIGSEGV(段错误),用 catch signal SIGSEGV 就能在信号发生的那一刻停下来,然后查看栈帧,找到是谁触发了非法内存访问。

避坑指南:我曾经在调试一个多线程程序时,用 catch throw 捕获 C++ 异常。结果发现程序在正常流程里也会抛出并捕获异常(比如 STL 容器的一些操作),导致 GDB 频繁停下。后来我加了条件 catch throw if $_exception != 0,才过滤掉无关的异常。

2.4 程序状态 —— 调试时的「快照」

当程序停在断点或观察点时,我们可以查看程序的「快照」。这个快照包含:

  • 寄存器值:info registers 查看所有寄存器
  • 内存内容:x/10x 0x20001000 查看内存
  • 变量值:print my_var 或 p my_var
  • 调用栈:backtrace 或 bt

我个人最常用的组合是:bt 看调用栈,然后 frame N 切换到某一层,再用 info locals 看局部变量。这样一套下来,基本能定位 80% 的问题。

2.5 栈帧(Stack Frame)—— 函数的「现场记录」

栈帧,说白了就是函数调用时在栈上分配的一块区域。它保存了函数的局部变量、参数、返回地址等信息。每次函数调用,都会创建一个新的栈帧。

用 backtrace 可以看到当前线程的栈帧列表:

(gdb) bt
#0  my_function (arg=42) at main.c:10
#1  0x08001234 in main () at main.c:5

这里 #0 是当前正在执行的函数,#1 是它的调用者。你可以用 frame 0 切换到第 0 帧,用 frame 1 切换到第 1 帧。

重要:栈帧是调试的「地图」。当你看到一个崩溃现场,第一件事就是看 bt。如果 bt 显示的是乱码或者地址,那大概率是栈被破坏了——比如局部数组越界写,把返回地址给覆盖了。

我曾经遇到一个案例:程序跑飞后 bt 显示的全是 0xDEADBEEF 这样的地址。我一看就知道是栈溢出。后来查出来是一个函数里定义了一个 4KB 的局部数组,而任务栈总共才 2KB,一调用就爆了。

2.6 线程(Thread)—— 并行的「执行流」

在多线程程序中,每个线程都有自己的栈帧、寄存器和执行流。GDB 默认调试的是「当前线程」,你可以用 info threads 查看所有线程:

(gdb) info threads
  Id   Target Id         Frame
* 1    Thread 1           my_function (arg=42) at main.c:10
  2    Thread 2           other_function () at other.c:20

用 thread 2 切换到线程 2,然后就可以查看它的栈帧和变量了。

我的习惯:调试多线程程序时,我通常先 set scheduler-locking on。这样当程序停在断点时,只有当前线程会暂停,其他线程继续运行。否则,你切到线程 2 时,线程 1 可能已经跑远了,导致状态不一致。

2.7 进程(Process)—— 独立的「运行实例」

进程比线程更「重」一些。每个进程有独立的内存空间、文件描述符等资源。GDB 默认调试一个进程,但你可以用 attach 附加到正在运行的进程,或者用 follow-fork-mode 控制调试哪个子进程。

在嵌入式开发中,进程的概念用得不多(因为很多 RTOS 没有进程概念),但在 Linux 嵌入式系统中很常见。比如调试一个 daemon 进程,你可以:

gdb -p 1234   // 附加到 PID 为 1234 的进程

或者用 set follow-fork-mode child 让 GDB 在 fork 后自动跟踪子进程。

注意:在嵌入式 Linux 中,attach 到进程需要 root 权限,而且可能会让被调试进程暂停一段时间。我曾经在生产环境上 attach 到一个关键进程,结果它停了 5 秒钟,导致业务中断。从那以后,我都在测试环境调试。

2.8 知识体系图

下面这张图展示了本章的核心概念之间的关系:

GDB 调试核心 主动打断 被动观察 状态查看 断点 (Breakpoint) 捕获点 (Catchpoint) 观察点 (Watchpoint) 程序状态 栈帧 (Stack Frame) 线程 (Thread) 进程 (Process) 断点控制「何时停」,观察点监控「数据变」,栈帧/线程/进程描述「在哪看」 三者配合,覆盖 90% 的调试场景

2.9 小结

这一章我们聊了 GDB 的七个核心概念。断点让你在指定位置停下,观察点让你监控数据变化,捕获点让你捕获事件。而程序状态、栈帧、线程、进程,则是你停下后查看现场的不同维度。

嗯,这里要注意:这些概念不是孤立的。调试一个多线程的栈溢出问题,你可能同时用到断点(在可疑函数设断)、观察点(监控栈指针)、线程切换(查看不同线程的栈帧)。把它们组合起来用,才是真正的工程化调试。

下一章,我们会深入聊断点的实现原理,以及如何用 GDB 脚本自动化断点操作。到时候你会发现,GDB 远不止「设断点、看变量」这么简单。