3. 断点艺术
调试这件事,说白了就是让程序停下来,然后你去看它到底在干什么。而断点,就是让程序停下来的那个「刹车」。我做了十几年嵌入式,踩过的坑比写过的代码还多,但最让我头疼的,往往是断点没用好。
今天咱们就把断点这事儿聊透。从最基础的普通断点,到高级的硬件断点,再到怎么管理它们,我把自己压箱底的经验都掏出来。
普通断点:最基础的刹车
普通断点,也叫软件断点。原理很简单:GDB 在目标地址上插入一条特殊指令(比如 x86 上的 int 3,ARM 上的 BKPT),程序执行到这儿就停下来。
用法也简单:
(gdb) break main
(gdb) break foo.c:42
(gdb) break *0x400520
我个人习惯用 b 代替 break,少打几个字嘛。
info functions 先搜一下。我经常记不住那些又长又臭的 C++ 函数名,全靠这个。
普通断点有个限制:数量。在 Flash 上调试时,软件断点需要修改代码段,而 Flash 的擦写次数有限。我曾经在一个项目里设了 20 多个断点,结果 Flash 被写坏了……嗯,从那以后我学会了「用完就删」。
条件断点:精准打击
普通断点太「笨」了,每次循环都停。你想想看,一个循环跑 10000 次,你只想在第 9999 次时看状态,难道要按 9998 次 continue?
条件断点就是干这个的:
(gdb) break foo.c:42 if count == 9999
(gdb) break func if ptr == NULL
这里要注意,条件表达式是 GDB 来解析的,不是 C 编译器。所以有些写法会踩坑:
if strcmp(s, "hello") == 0,结果 GDB 告诉我它不认识 strcmp。为什么?因为 GDB 的条件表达式用的是它自己的语法,不是 C 标准库。正确的写法是用 $_streq 这个内置函数。
正确的写法:
(gdb) break foo.c:42 if $_streq(s, "hello")
条件断点的性能开销比普通断点大。每次命中断点时,GDB 都要停下来评估条件。如果条件很复杂,或者断点命中很频繁,调试会变得很慢。我建议:能用简单条件就别用复杂条件,能用 == 就别用函数调用。
临时断点:用完即走
临时断点,说白了就是「一次性断点」。它只生效一次,命中后自动删除。
(gdb) tbreak main
(gdb) tb foo.c:42
什么时候用?我举个实际场景:你想让程序跑到某个函数后停下来,但只停一次。比如初始化代码,你只想看第一次执行时的状态,后面就不想看了。
另一个常见用法:配合 run 命令。你可以在程序入口设一个临时断点,然后 run,程序启动后自动停在 main 函数,断点自动消失。干净利落。
continue 组合使用。比如我想跳过某个函数的初始化部分,就在函数入口设临时断点,然后 continue 到那里,断点自动消失,接着我手动单步执行。
硬件断点:嵌入式调试的救命稻草
硬件断点,和软件断点完全不同。它不修改代码,而是利用 CPU 的调试寄存器来实现。CPU 在取指或访存时,会检查地址是否匹配调试寄存器中的值,匹配就触发异常。
硬件断点的优势:
- 不修改代码——适合 ROM、Flash 等只读区域
- 可以监控数据访问——比如某个变量被谁改了
- 支持读写断点——不只是执行断点
用法:
(gdb) hbreak main // 硬件执行断点
(gdb) watch my_var // 硬件写断点(变量被写时停)
(gdb) awatch my_var // 硬件读写断点(读或写都停)
(gdb) rwatch my_var // 硬件读断点(变量被读时停)
但是,硬件断点数量有限。ARM Cortex-M 系列通常只有 4-8 个硬件断点寄存器。x86 更少,只有 4 个。所以别指望设几十个硬件断点。
watch 后,程序跑得巨慢,几乎卡死。为什么?因为 watch 默认用硬件断点,但硬件断点不够时,GDB 会退化成软件模拟——每执行一条指令都检查一次变量地址。这效率,你想想看。后来我改用条件断点 + 日志输出,才搞定。
硬件断点的另一个限制:它只能监控连续的地址范围。如果你想监控一个结构体中的多个成员,可能需要多个硬件断点。
断点的高级管理:enable/disable/delete
断点多了,管理就成问题。我见过有人设了 30 多个断点,最后自己都忘了哪些是干嘛的。这时候就需要管理命令了。
查看断点列表
(gdb) info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x08001234 main
2 breakpoint keep y 0x08005678 foo.c:42
3 hw breakpoint keep y 0x08009abc func
4 watchpoint keep y my_var
每一行都有一个编号(Num),后面所有的管理操作都基于这个编号。
启用/禁用断点
(gdb) disable 2 // 禁用 2 号断点
(gdb) enable 2 // 启用 2 号断点
(gdb) disable // 禁用所有断点
(gdb) enable // 启用所有断点
禁用断点不会删除它,只是暂时让它失效。这在调试时很有用:比如你怀疑某个断点影响了程序行为,可以先禁用它试试,而不是删掉。
删除断点
(gdb) delete 2 // 删除 2 号断点
(gdb) delete // 删除所有断点
(gdb) clear main // 删除 main 函数上的断点
(gdb) clear foo.c:42 // 删除 foo.c:42 上的断点
clear 和 delete 的区别:clear 按位置删除,delete 按编号删除。我个人更习惯用 delete,因为编号更精确,不会误删。
断点条件修改
有时候设完断点,发现条件写错了。不用删了重建,可以直接改:
(gdb) condition 2 count == 1000 // 修改 2 号断点的条件
(gdb) condition 2 // 删除 2 号断点的条件(变成普通断点)
断点忽略次数
还有一个我常用的功能:ignore。它让断点忽略前 N 次命中:
(gdb) ignore 2 1000 // 2 号断点忽略前 1000 次命中
这比条件断点更高效。为什么?因为条件断点每次命中都要停下来评估条件,而 ignore 是在 CPU 内部计数,不停下来。性能好得多。
ignore 而不是条件断点。比如循环 10000 次,我想在第 9999 次停,用 ignore 2 9998,然后 continue。程序几乎全速运行,只在第 9999 次时停下来。
知识体系总览
下面这张图,是我自己总结的断点知识体系。你可以把它当成一个「断点速查地图」:
这张图把断点分成了四大类,以及对应的管理命令。你可以把它截图保存,调试时看一眼,思路就清晰了。
实战建议
最后,我总结几条实战中的断点使用原则:
- 能用硬件断点,就别用软件断点——尤其是在 Flash/ROM 上调试时,避免修改代码段。
- 能用
ignore,就别用条件断点——性能差距很大,尤其是高频命中的场景。 - 断点用完就删——别留着过年。我见过有人留着几十个断点,下次调试时全乱了。
- 善用
disable——不确定某个断点是否有影响时,先禁用它,而不是删掉。 - 硬件断点数量有限,省着用——优先给关键变量设
watch,次要的用条件断点替代。
嗯,断点这事儿,说简单也简单,说复杂也复杂。关键是要理解每种断点的原理和适用场景。你想想看,如果你连断点都设不明白,那调试效率能高到哪去?
好了,这一章就到这儿。记住:断点不是越多越好,而是越精准越好。