GDB 高级调试技巧:条件断点、观察点、回溯追踪与自动化脚本
说实话,很多嵌入式工程师用 GDB 还停留在「设个断点,跑一下,看看变量」的阶段。但真正遇到棘手问题——比如跑了几万次才崩一次、某个全局变量被谁改了、或者现场已经飞了——这时候基础操作根本不够用。
我个人习惯把 GDB 的高级功能分成四块:条件断点、观察点、回溯追踪、自动化脚本。这四块用熟了,调试效率至少翻一倍。今天咱们就一个一个说透。
条件断点:别让 CPU 陪你空转
先问个问题:你设过普通断点,然后一次次按 continue 吗?我以前就这么干过。有一次调试一个串口驱动,循环发送 10000 个包,第 7893 个包会出错。我手动按了 7892 次 continue……嗯,那是我职业生涯最蠢的一个下午。
条件断点就是干这个的。它只在条件满足时才停下,CPU 不陪你空转。
(gdb) break serial_send if packet_id == 7893
(gdb) break file.c:42 if len > 256 && flag == 0xAA
条件可以是任何 C 表达式,支持 &&、||、!= 这些运算符。甚至能调用函数——不过要小心,函数调用会改变程序状态。
count++),调试结果就不可信了。我曾经因为这个查了三天 bug,最后发现是条件表达式把变量改了。
还有个小技巧:用 ignore 跳过前 N 次命中。
(gdb) break serial_send
(gdb) ignore 1 7892
这样前 7892 次直接跳过,第 7893 次才停。比条件断点更轻量,适合已知偏移量的场景。
观察点:谁动了我的变量?
这是我最常用的功能之一。嵌入式里经常遇到「全局变量莫名其妙被改了」的情况。你设断点?不知道在哪设。你打印日志?改的地方太多了。
观察点(Watchpoint)就是硬件版的「变量监控器」。它监视内存地址,一旦值变化就停下。
(gdb) watch my_global_var
(gdb) watch *(int*)0x20000100
硬件观察点有数量限制。ARM Cortex-M 系列通常只有 2-4 个。用多了 GDB 会退化成软件观察点——每执行一条指令都检查一次,慢到怀疑人生。
| 类型 | 命令 | 触发条件 | 典型场景 |
|---|---|---|---|
| 写观察点 | watch |
变量被写入 | 全局变量被篡改 |
| 读观察点 | rwatch |
变量被读取 | 谁在访问共享数据 |
| 读写观察点 | awatch |
读或写都触发 | 排查竞态条件 |
回溯追踪:从崩溃现场往回挖
程序崩了,停在某个地址。你看到 PC 指针飞到了 0x0800FFFE,但那是 Flash 末尾,根本不是代码区。怎么办?
回溯追踪(Backtrace)就是你的「时间机器」。它告诉你:我是怎么走到这一步的。
(gdb) backtrace
#0 hard_fault_handler () at startup.c:42
#1 <signal handler called>
#2 task_send (msg=0x20001000) at task.c:128
#3 main_loop () at main.c:67
#4 main () at main.c:23
看到没?从 #0 到 #4,调用链清清楚楚。问题出在 task_send 里传了个野指针 0x20001000,然后触发了 HardFault。
我一般会配合 frame 命令切到具体层级看局部变量:
(gdb) frame 2
(gdb) info locals
msg = 0x20001000
len = 0
长度是 0?那肯定是上层传参传错了。再切到 #3 看看:
(gdb) frame 3
(gdb) print msg_ptr
$1 = (uint8_t *) 0x20001000
嗯,指针没初始化。问题定位完成。
自动化脚本:让 GDB 替你干活
重复性操作最消耗耐心。每次崩了都要设同样的断点、打印同样的变量、执行同样的步骤?写个脚本吧。
GDB 支持两种脚本:一种是 .gdbinit 启动脚本,一种是运行时用 source 加载的脚本。
# debug_helpers.gdb
define dump_task
printf "Task: %s, State: %d, Stack: %p\n", $arg0, $arg1, $arg2
end
document dump_task
打印任务控制块信息
用法: dump_task <name> <state> <stack_ptr>
end
在 GDB 里加载:
(gdb) source debug_helpers.gdb
(gdb) dump_task "uart_rx" 1 0x20001000
Task: uart_rx, State: 1, Stack: 0x20001000
更高级的用法是结合 Python。GDB 7.0 之后内置了 Python 解释器,你可以写 Python 脚本来解析复杂数据结构、生成报告、甚至自动分析堆栈。
# gdb_pretty_print.py
import gdb
class TaskPrinter(gdb.PrettyPrinter):
def __init__(self, val):
super().__init__("task")
self.val = val
def to_string(self):
name = self.val["name"].string()
state = self.val["state"]
return f"Task '{name}' [state={state}]"
gdb.pretty_printers.append(TaskPrinter)
加载后,GDB 打印任务结构体时自动调用这个美化器,再也不用手动 print task->name 了。
.gdbinit 文件,放在项目根目录。里面放项目专用的宏、别名、断点预设。团队共享,新人上手直接 gdb 就能用,不用问「断点设在哪」。
知识结构总览
下面这张图把 GDB 高级调试的核心脉络串起来了。你可以把它当作调试时的「作战地图」。
实战组合拳:一个完整的调试流程
光讲理论没意思。咱们走一个真实场景:系统跑着跑着就 HardFault,但不是每次都复现。
- 先设条件断点:在 HardFault_Handler 入口设断点,条件设为
fault_reason == 0x02(只关注某种错误类型)。 - 等触发后回溯:
backtrace看调用链,frame 2切到业务代码层。 - 设观察点:发现某个全局 buffer 的指针可疑,
watch buffer_ptr,重新运行看谁改了它。 - 写脚本固化:把上面步骤写成 GDB 脚本,下次直接
source debug_hardfault.gdb,一键复现调试流程。
嗯,这四块内容够你消化一阵子了。下次遇到难缠的 bug,别急着加打印,先想想:能不能用条件断点?能不能用观察点?能不能写个脚本?