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 时才停
if (count == 50000) { asm("int3"); },瞬间就快了。
断点还有一个容易被忽略的特性:断点编号。每个断点都有一个编号,你可以用 info breakpoints 查看所有断点,然后用 disable 1 临时禁用某个断点,或者用 delete 1 删除它。
2.2 观察点(Watchpoint)—— 数据变化的「监视器」
断点关注的是「代码执行到哪里」,而观察点关注的是「数据什么时候变了」。当你怀疑某个变量被意外修改时,观察点就是你的救星。
设置观察点的语法:
watch my_variable // 当 my_variable 被写入时停下
rwatch my_variable // 当 my_variable 被读取时停下
awatch my_variable // 当 my_variable 被读取或写入时停下
我记得有一次,一个同事调试了三天,发现一个全局变量莫名其妙被改成了 0xDEADBEEF。他翻遍了所有代码,都没找到谁在写这个变量。我让他用 watch 观察这个变量,结果程序一跑就停下来了——停在一个中断服务函数里,那个函数里有个野指针,刚好写到了这个变量的地址上。
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 后自动跟踪子进程。
attach 到进程需要 root 权限,而且可能会让被调试进程暂停一段时间。我曾经在生产环境上 attach 到一个关键进程,结果它停了 5 秒钟,导致业务中断。从那以后,我都在测试环境调试。
2.8 知识体系图
下面这张图展示了本章的核心概念之间的关系:
2.9 小结
这一章我们聊了 GDB 的七个核心概念。断点让你在指定位置停下,观察点让你监控数据变化,捕获点让你捕获事件。而程序状态、栈帧、线程、进程,则是你停下后查看现场的不同维度。
嗯,这里要注意:这些概念不是孤立的。调试一个多线程的栈溢出问题,你可能同时用到断点(在可疑函数设断)、观察点(监控栈指针)、线程切换(查看不同线程的栈帧)。把它们组合起来用,才是真正的工程化调试。
下一章,我们会深入聊断点的实现原理,以及如何用 GDB 脚本自动化断点操作。到时候你会发现,GDB 远不止「设断点、看变量」这么简单。