3. 调试与性能调优概述
调试器是嵌入式工程师的听诊器。而 GDB,就是那个最趁手的听诊器。
这一章我们聚焦 GDB 最核心的操作。说实话,我见过不少工程师,IDE 用得飞起,但一进命令行就懵了。其实 GDB 没那么玄乎,掌握几个关键操作,你就能把程序扒个底朝天。
3.1 断点管理:硬件断点 vs 软件断点
断点是调试的基石。但很多人不知道,断点还分硬件和软件两种。它们原理不同,适用场景也不同。
3.1.1 软件断点
软件断点是最常用的。GDB 会在你指定的地址,把原来的指令替换成 0xCC(即 int 3 指令)。程序执行到这里,CPU 就会触发一个异常,GDB 就接管了控制权。
优点:数量不限,随便设。缺点:会修改内存中的指令。所以不能用于 ROM 或 Flash 中的代码。
(gdb) break main # 在函数入口设断点
(gdb) break *0x08048400 # 在指定地址设断点
(gdb) break file.c:42 # 在源文件某行设断点
(gdb) info breakpoints # 查看所有断点
3.1.2 硬件断点
硬件断点依赖 CPU 的调试寄存器。ARM Cortex-M 系列通常有 4-8 个硬件断点寄存器。它不修改内存,所以可以用于 ROM 中的代码。
我曾在调试一个 Bootloader 时遇到过这种情况——代码在 Flash 里,软件断点根本写不进去。当时我折腾了半天,最后才想起来用硬件断点。嗯,从那以后我养成了习惯:调试只读区域代码,第一反应就是硬件断点。
(gdb) hbreak main # 硬件断点
(gdb) hbreak *0x08000000 # 在 ROM 地址设硬件断点
(gdb) info breakpoints # 查看时,hw breakpoint 会标注类型
3.1.3 断点类型对比
| 特性 | 软件断点 | 硬件断点 |
|---|---|---|
| 原理 | 替换指令为 0xCC | 使用调试寄存器 |
| 数量限制 | 无限制 | 通常 4-8 个 |
| 适用区域 | RAM | RAM / ROM / Flash |
| 副作用 | 修改内存 | 无修改 |
| 调试器依赖 | 仅需 GDB | 需要硬件调试器支持 |
3.2 单步执行与反汇编
单步执行,说白了就是让程序一步一步走。但这里有个坑:你是在源码级别单步,还是在指令级别单步?
3.2.1 源码级单步
(gdb) next # 下一行,不进入函数
(gdb) step # 下一行,进入函数
(gdb) finish # 执行完当前函数,返回
我个人习惯用 next 跳过库函数调用,用 step 进入自己写的函数。这样效率高。
3.2.2 指令级单步与反汇编
有时候源码级单步不够用。比如你怀疑编译器优化出了问题,或者你在调试中断服务程序。这时候就得看汇编了。
(gdb) disassemble main # 反汇编 main 函数
(gdb) disassemble $pc, $pc+32 # 反汇编当前地址附近 32 字节
(gdb) stepi # 单步执行一条汇编指令
(gdb) nexti # 单步执行,跳过函数调用
我曾经调试过一个诡异的死机问题。源码看着完全正常,但程序就是跑飞了。我反汇编一看,发现编译器把两个相邻的变量优化到了同一个寄存器里。嗯,这就是典型的优化坑。从那以后,我调试疑难杂症时,一定会看一眼反汇编。
3.3 内存与寄存器查看
断点停下来了,然后呢?你得看看程序的状态。内存和寄存器,就是程序的全部状态。
3.3.1 查看寄存器
(gdb) info registers # 查看所有寄存器
(gdb) info registers r0 r1 # 查看指定寄存器
(gdb) print $r0 # 打印 r0 的值
(gdb) print/x $r0 # 十六进制打印
3.3.2 查看内存
(gdb) x/10x 0x20000000 # 查看 10 个十六进制字
(gdb) x/20b 0x20000000 # 查看 20 个字节
(gdb) x/5i $pc # 查看当前地址的 5 条指令
(gdb) x/s 0x20000000 # 查看字符串
x 命令的格式是 x/[数量][格式][单位] 地址。格式有 x(十六进制)、d(十进制)、s(字符串)、i(指令)。单位有 b(字节)、h(半字)、w(字)。
用 display 命令可以设置自动显示的变量或寄存器。每次单步后,GDB 会自动打印它们。
(gdb) display/x $r0
(gdb) display/4x 0x20000000
(gdb) info display # 查看所有自动显示项
3.4 Core Dump 分析
程序崩溃了,但现场没保留?那是你没用 Core Dump。
Core Dump 是程序崩溃时,操作系统把内存映像保存下来的文件。你可以用 GDB 加载它,就像调试一个活着的程序一样——查看调用栈、变量值、寄存器状态。
3.4.1 生成 Core Dump
在 Linux 系统中,需要先设置 Core Dump 文件大小限制:
$ ulimit -c unlimited # 取消 Core Dump 大小限制
$ echo "/tmp/core.%p" > /proc/sys/kernel/core_pattern # 设置 Core Dump 路径
在嵌入式系统中,通常需要内核支持 CONFIG_ELF_CORE 选项,并且要有足够的存储空间。
3.4.2 分析 Core Dump
$ arm-none-eabi-gdb my_program.elf /tmp/core.1234
(gdb) bt # 查看调用栈
(gdb) info registers # 查看崩溃时的寄存器状态
(gdb) frame 2 # 切换到第 2 层调用栈
(gdb) print local_var # 查看局部变量
Core Dump 分析的关键是:可执行文件必须包含调试信息(编译时加 -g 选项),并且 Core Dump 文件必须与可执行文件匹配。版本对不上,分析结果就是错的。
我记得有一次,客户现场的设备频繁重启。我远程拿到 Core Dump 文件,加载到 GDB 一看——调用栈停在了一个野指针赋值的地方。再查那个指针的来源,发现是一个全局变量被意外覆盖了。整个过程没花半小时。如果没有 Core Dump,我可能得飞到现场,接上 JTAG,复现几个小时才能找到问题。
3.5 本章知识结构
下面这张图总结了 GDB 核心操作的知识体系。你可以把它当作调试时的快速参考。
这张图把 GDB 的核心操作分成了四个模块。断点管理是入口,单步执行是过程,内存与寄存器查看是状态检查,Core Dump 分析是事后复盘。调试时,你就在这四个模块之间来回切换。
先设断点,跑到可疑位置。然后单步跟踪,同时用 display 盯着关键变量。如果程序崩溃了,第一时间看 Core Dump。这套流程,我用了十年,基本没失手过。