GDB 调试指南(第 2 章):断点与观察点

← 上一章:GDB 快速上手 | 目录 | 下一章:运行与参数传递 →

第 2 章 断点与观察点:精确控制程序在哪里停下

上一章已经用过最简单的 b main。断点的本质很简单:在程序执行流上做一个标记,CPU 跑到这个标记就自动暂停,把控制权交还给你。这一章把断点的各种玩法、以及它的“进阶版”——观察点(watchpoint)——讲完整。

2.1 break 的六种定位方式

break(缩写 b)支持好几种方式告诉 GDB “我要在哪里停下”:

(gdb) b 49                              # 1. 行号(在当前默认文件里)
Breakpoint 1 at 0x15b2: file main.cpp, line 49.

(gdb) b main.cpp:5                      # 2. 文件名:行号(跨文件,明确指定在哪个文件)
Breakpoint 2 at 0x17ac: file main.cpp, line 12.

(gdb) b testFun                         # 3. 函数名(若有重载,会在多个位置下断点)
Breakpoint 3 at 0x1379: testFun. (2 locations)

(gdb) b FUNC::testFun1()                # 4. 类名::成员函数名
Breakpoint 8 at 0x17ac: file main.cpp, line 12.

(gdb) b main.cpp:testFun(std::string)   # 5. 文件名:函数名(跨文件 + 重载消歧)
Breakpoint 6 at 0x555555555379: file main.cpp, line 17.

(gdb) b 61 if sum > 500                 # 6. 条件断点:位置 + if 条件
Breakpoint 7 at 0x555555555662: file main.cpp, line 61.

条件断点命中后,info breakpoints(缩写 i b)会显示 stop only if sum > 500,方便确认条件生效:

(gdb) i b
Num     Type           Disp Enb Address            What
7       breakpoint     keep y   0x0000555555555662 in main(int, char**) at main.cpp:61
        stop only if sum > 500

【新手提示】条件断点非常适合“循环跑一万次,但我只关心第 999 次那一轮”这种场景。不用在代码里写 if (i == 999) { /* 在这里手动打断点 */ },直接 b 61 if i == 999 就行,省得改代码。

【常见误区】为什么我的断点“下在了别的地方”或者“根本不触发”

新手最容易遇到的几种情况:

  1. 行号选到了空行或声明行:GDB 只能在“会生成机器指令”的行上停下。如果你 b 的那一行是空行、注释,或者只是一个变量声明但没有赋值,GDB 会自动把断点挤到下一条真正有代码的行,这是正常行为,不是 bug。
  2. 跨文件没写文件名,断在了同名函数的另一个文件里:如果项目里多个文件都有叫 testFun 的函数,只写 b testFun 可能会在好几个位置同时下断点(见上面示例 3,提示 2 locations),不是你以为的那一个。想精确指定,用“文件名:函数名”的写法(示例 5)。
  3. 开了编译优化:-O2/-O3 优化会把代码顺序打乱、把小函数直接内联展开、把变量直接放进寄存器不再有固定的内存地址。这会导致断点行号和你看到的源码对不上、step 单步时跳着走、甚至某些变量用 print 查不到(提示 <optimized out>)。调试阶段建议用 -O0 编译,调试体验会正常很多。
  4. 二进制和源码对不上:参考第 1 章提到的“改了代码忘记重新编译”。

2.2 break *地址:直接按内存地址下断点

有时候你是在看反汇编、而不是源码(比如没有调试信息的第三方库),这时候可以直接按指令地址下断点:

(gdb) disassemble
Dump of assembler code for function main:
   0x000055555555543b <+0>:     endbr64
   0x000055555555543f <+4>:     push   %rbp
   0x0000555555555440 <+5>:     mov    %rsp,%rbp
=> 0x0000555555555443 <+8>:     push   %rbx
   0x0000555555555444 <+9>:     sub    $0x118,%rsp
   0x000055555555544b <+16>:    mov    %edi,-0x114(%rbp)
   ...
(gdb) b *0x000055555555544b
Breakpoint 2 at 0x55555555544b

【常见误区】现代 Linux 默认开启 PIE(位置无关可执行文件)和 ASLR(地址空间随机化),同一个程序每次运行,实际加载的内存地址都可能不一样。如果你把某次运行看到的绝对地址记下来,下次启动再用 b *0x地址,大概率是错的、或者压根落不到你想要的指令上。按地址下断点只适合“在本次 GDB 会话里、刚用 disassemble 看到地址马上就用”这种场景;只要能用函数名/行号,就优先用符号化的方式,不用关心地址到底是多少。

2.3 tbreak:一次性断点

语法与 break 完全一致,唯一区别是命中一次后自动删除,适合“只想在某处停一次、看完就不想再被反复打断”的场景(比如循环体内某一行,正常断点会每轮循环都停一次,很烦):

(gdb) tbreak 51
Temporary breakpoint 8 at ...: file main.cpp, line 51.
(gdb) i b
8       breakpoint     del  y   0x... in main(...) at main.cpp:51

i b 的 Disp 列显示 del(命中后删除),区别于普通断点的 keep(一直保留)。

2.4 rbreak:按正则表达式批量下断点

对函数名做正则匹配,一次性给所有符合条件的重载/同名函数下断点,省得一个个手动找:

(gdb) rbreak testFun
Breakpoint 1 at 0x1aa8: file FUN2.cpp, line 8.
int FUN2::testFun2();
Breakpoint 2 at 0x17ac: file main.cpp, line 12.
void FUNC::testFun1();
Breakpoint 3 at 0x13f1: file main.cpp, line 26.
int testFun(int);
Breakpoint 4 at 0x1379: file main.cpp, line 17.
void testFun(std::string);

因为本身就是正则匹配,rbreak testFun* 和 rbreak testFun 效果相同——testFun 这个正则已经能匹配所有以它开头的符号了,末尾加不加 * 不影响结果。

【新手提示】rbreak 适合“我想看这个类/这组同名重载函数每次被调用时都发生了什么”这种排查场景,比如怀疑某个函数被意外调用、或者想确认重载决议选对了版本。

2.5 info breakpoints:查看所有断点

(gdb) i b
Num     Type           Disp Enb Address            What
4       breakpoint     keep y   0x0000555555555458 in main(int, char**) at main.cpp:36
        breakpoint already hit 1 time

命中过的断点会额外显示 breakpoint already hit N time,方便确认执行路径是否符合预期——比如你以为一个函数只会被调用一次,结果这里显示命中了 5 次,那说明理解有偏差,值得深挖。

2.6 delete / clear:删除断点

(gdb) d 2          # 删除 2 号断点(d 是 delete 的缩写)
(gdb) d             # 不带编号:删除全部断点(会二次确认)
Delete all breakpoints, watchpoints, tracepoints, and catchpoints? (y or n) y

另外还有 clear [位置],按源码位置而不是编号删除断点,例如 clear main.cpp:49 会删除该行上的断点;不带参数的 clear 需要当前有“默认源文件”上下文,否则会报 No source file specified。日常更推荐用 delete <编号>,定位更精确、不容易误删。

2.7 disable / enable / enable once

断点太多、暂时不想被某几个打断,又不想直接删掉(删了还得重新敲一遍),可以用禁用/启用:

(gdb) disable 6         # 禁用 6 号断点:保留但不再触发
(gdb) enable 6          # 重新启用
(gdb) enable once 7     # 只启用一次,命中后自动变回禁用(Disp 列显示 dis)

enable once 很适合“这个断点只在第一次经过时有意义,之后不想再被打断”的场景,比手动 disable/enable 省事。

2.8 观察点(watchpoint):当你不知道“哪一行”改了变量时

断点需要你先知道“在哪一行”停下,但很多排查场景里,bug 的真实情况是:你知道某个变量的值被改错了,但不知道是哪段代码改的。这正是观察点要解决的问题——它监视的是“值”而不是“位置”,值一旦变化就自动暂停,不管改动发生在源码的哪里。

2.8.1 硬件观察点 vs 软件观察点

类型 硬件观察点 软件观察点
触发方式 CPU 调试寄存器直接比较 GDB 单步 + 软件判断
数量限制 受硬件调试寄存器数量限制,x86 上通常为 4 个 理论上无限制
性能 高(硬件级,几乎不拖慢程序) 低(相当于每条指令都要停下来检查,非常慢)

GDB 会优先尝试用硬件观察点;只有当硬件寄存器用完、或者监视的表达式太复杂无法映射到硬件比较条件时,才会退化成软件观察点。退化之后调试会明显变卡,这是正常现象,不是 GDB 出问题了。

2.8.2 基本用法

(gdb) watch g_num
(gdb) i watchpoints
Num     Type           Disp Enb Address    What
1       hw watchpoint  keep y              g_num
(gdb) r
...
Hardware watchpoint 1: g_num

Old value = 0
New value = 111
testFun (num=9988) at main.cpp:32
32          printf("cnt: %d\n", cnt);

触发时 GDB 会直接告诉你旧值、新值,以及此刻停在了哪个函数的哪一行——这正是“值被改的那一刻”,不需要你猜。

2.8.3 不止能监视简单变量

watch 后面跟的其实是一个表达式,不局限于单个变量名,常见的还有:

(gdb) watch arr[3]        # 监视数组某个元素
(gdb) watch *ptr          # 监视某个指针指向的内容
(gdb) watch obj.field     # 监视某个成员变量

【新手提示】监视的表达式越复杂(比如涉及指针解引用、数组下标),越容易因为 GDB 无法把它转换成一个固定内存地址上的硬件比较条件,而退化为软件观察点,导致明显变慢。如果只是想抓“某个指针指向的值什么时候变”,可以先用 print 确认当前地址,再考虑是否有更精确的监视方式。

2.8.4 用完记得清理

观察点和断点一样会一直占用硬件寄存器资源,调试完某个变量后,记得 delete <编号> 或者直接 d 清一下,不然硬件观察点数量有限(x86 上通常只有 4 个),很容易“没额度用了”。

2.9 小结

  • 断点解决的是“在哪一行停下”,观察点解决的是“在什么值变化时停下”,两者互补。
  • 条件断点(b 位置 if 条件)能省掉大量手动改代码加判断的麻烦。
  • 调试阶段尽量用 -O0 编译,断点和单步的行为会符合直觉得多。
  • 硬件地址下断点只适合临时、当次会话使用,不要当成固定方式依赖。

← 上一章:GDB 快速上手 | 目录 | 下一章:运行与参数传递 →

posted @ 2026-10-10 08:28  BlackSnow  阅读(13)  评论(0)    收藏  举报