2. 嵌入式调试环境搭建

调试器这东西,说白了就是让你能「看」进程序内部。GDB 作为嵌入式调试的标配,它的核心命令并不多,但每个都值得你花时间吃透。我刚开始用 GDB 时,就靠这几个命令走天下,后来发现很多同事连 step 和 next 的区别都说不清楚——嗯,这其实是个大坑。

2.1 启动调试:让程序跑起来

启动调试的方式主要有三种,我根据项目场景不同会换着用。

启动方式 命令示例 适用场景
直接调试可执行文件 gdb ./app.elf 本地调试,最常见
附加到运行中的进程 gdb attach <pid> 调试已跑起来的服务,不重启
远程调试(嵌入式常用) target remote :2331 板子上跑 gdbserver,PC 端连过来
个人习惯:我一般先用 gdb ./app.elf 加载符号表,再通过 target remote 连目标板。这样即使板子还没启动,也能先看源码、设断点。

启动后,GDB 会停在 _start 或 main 之前。如果你想直接跑到 main 再停,可以用:

(gdb) break main
(gdb) run

嵌入式场景下,run 通常不好使——因为程序已经在板子上跑了。这时候用 continue 代替。

2.2 暂停与继续:掌控执行节奏

暂停程序靠断点,继续执行靠 continue。这俩是调试的「油门」和「刹车」。

2.2.1 设置断点

(gdb) break main              // 在函数入口设断点
(gdb) break file.c:42          // 在源码行号设断点
(gdb) break *0x80002000        // 在地址设断点(硬件断点常用)
(gdb) info breakpoints         // 查看所有断点

我曾经在调试一个 UART 驱动时,发现中断服务函数总是不触发。折腾了半天,最后用 break *0x80002000 直接在中断向量表地址设断点,才发现是中断向量配置错了。嗯,硬件断点在这种场景下特别好用。

2.2.2 继续执行

(gdb) continue    // 简写 c,继续跑直到下一个断点
(gdb) continue 5  // 忽略当前断点 5 次,第 6 次才停
注意:在嵌入式调试中,continue 后程序会全速运行。如果断点设得太频繁,可能会影响实时性。我建议只在关键路径上设断点。

2.3 单步执行:逐行看程序怎么走

单步执行是调试的「显微镜」。GDB 提供了四个核心命令,我列个表对比一下:

命令 简写 行为 遇到函数调用时
step s 执行一行源码 进入函数内部
next n 执行一行源码 不进入函数,直接跳过
finish fin 一直执行到当前函数返回 跳出函数
until u 执行到指定行或地址 灵活跳出循环

为什么会这样设计?你想想看:

  • 如果你在 main 里调了 uart_send(),用 next 会直接跳过这个函数,不关心内部细节。
  • 用 step 则会钻进 uart_send() 里,一行一行看它怎么发数据的。
  • 如果已经钻进去了,想快速出来,就用 finish。
避坑指南:我曾经在调试一个 RTOS 任务切换时,用 step 进入了调度器函数,结果 GDB 卡住了——因为调度器会切换上下文,单步执行时 PC 指针乱跳。后来我改用 next 跳过调度器,只在任务函数内部设断点,问题就解决了。

2.4 程序状态查看:看变量、寄存器、内存

暂停下来之后,你得知道程序「现在是什么状态」。GDB 提供了三组查看命令:

2.4.1 查看变量

(gdb) print var_name        // 打印变量值
(gdb) print/x var_name      // 十六进制显示
(gdb) display var_name      // 每次暂停都自动打印
(gdb) info locals           // 查看当前函数所有局部变量

我个人习惯用 display 来监控关键变量。比如调试一个环形缓冲区时,我会把 head、tail、count 都设成自动显示,这样每次断点停下就能看到队列状态。

2.4.2 查看寄存器

(gdb) info registers        // 查看所有寄存器
(gdb) info registers r0 r1  // 只看 r0 和 r1
(gdb) print $pc             // 查看程序计数器
(gdb) print $sp             // 查看栈指针

嵌入式调试中,寄存器查看特别重要。我记得有一次调试 I2C 通信,从设备一直没应答。我用 info registers 看了状态寄存器,发现 ACK 位始终为 0,这才定位到是硬件上拉电阻没焊好。

2.4.3 查看内存

(gdb) x/10x 0x20000000      // 查看地址 0x20000000 开始的 10 个十六进制字
(gdb) x/20b 0x20000000      // 按字节显示
(gdb) x/5i  $pc             // 反汇编当前 PC 附近的 5 条指令

x 命令的格式是 x/<数量><格式> <地址>。格式可以是:

  • x 十六进制
  • d 十进制
  • c 字符
  • s 字符串
  • i 指令
实用技巧:调试栈溢出时,我常用 x/64x $sp 查看栈顶附近的数据。如果看到栈空间被写入了大量 0xDEADBEEF 之类的填充值,基本就能确认溢出了。

2.5 知识体系总览

下面这张图把本章的核心命令和它们的关系串起来了。你可以把它当作调试时的「快捷键地图」。

GDB核心命令知识体系 GDB核心命令知识体系 GDB调试会话 启动调试 暂停 / 继续 单步执行 程序状态查看 gdb ./app.elf gdb attach <pid> target remote :2331 break main continue step (s) next (n) finish until print / display info registers x/ 查看内存 💡 调试流程建议 启动 → 设断点 → 暂停 → 查看状态 → 单步/继续

2.6 实战组合拳:一个典型调试流程

说了这么多,不如看一个实际例子。假设你在调试一个串口发送函数 uart_send(),发现数据发出去不对:

  1. 启动并设断点:gdb ./app.elf → break uart_send
  2. 跑起来:continue(或 target remote 后直接 c)
  3. 暂停到断点:程序停在 uart_send 入口
  4. 查看参数:print buf、print len,确认传入的数据对不对
  5. 单步执行:next 跳过函数内部,或者 step 钻进函数看细节
  6. 查看寄存器:info registers 看 UART 状态寄存器
  7. 查看内存:x/16x buf 看缓冲区实际内容
  8. 继续或跳出:continue 跑下一个断点,或 finish 跳出当前函数
核心思路:调试不是乱试,而是「假设 → 验证」的循环。先用 print 和 info registers 收集证据,再用 step 或 next 缩小范围,最后用 continue 验证修复效果。