3. 主流调试器与工具链概览

调试器这东西,说白了就是给嵌入式程序装了个「慢放键」。GDB 是我用得最多的调试工具,没有之一。刚开始接触时我也觉得命令太多记不住,但后来发现,真正核心的就那么几个——断点、单步、看变量、查内存。把这四样玩熟了,90% 的 bug 都能手到擒来。

3.1 断点管理:让程序在指定位置停下来

断点是调试的起点。没有断点,程序一跑到底,你根本不知道中间发生了什么。

3.1.1 设置断点的几种方式

命令 说明 示例
break <行号> 在源文件指定行设断点 break 42
break <函数名> 在函数入口设断点 break main
break <文件名:行号> 在多文件项目中指定文件 break uart.c:87
break <行号> if <条件> 条件断点,满足条件才停下 break 55 if count == 10

我个人习惯在函数入口先设一个断点,确认程序确实执行到了这里。有一次调试一个串口驱动,怎么调都不进中断,后来发现是中断向量表配错了——断点都没触发,问题一目了然。

小技巧:条件断点非常实用。比如循环执行 1000 次,你只关心第 500 次的情况,加个 if i == 500 就行,不用一次次手动跳过。

3.1.2 管理断点

(gdb) info breakpoints    # 查看所有断点
(gdb) disable 2           # 禁用编号为2的断点
(gdb) enable 2            # 重新启用
(gdb) delete 2            # 删除断点
(gdb) clear main          # 清除main函数处的断点

嗯,这里要注意:delete 是按编号删,clear 是按位置删。我刚开始时经常搞混,结果想删一个断点,把所有的都删了……

3.2 单步执行:一步步看清程序的轨迹

断点让程序停下来,单步执行则让你看清每一步发生了什么。

3.2.1 单步命令对比

命令 缩写 行为 适用场景
step s 进入函数内部 想查看被调函数的执行细节
next n 不进入函数,直接执行完 确认函数返回值,不关心内部
finish fin 执行到当前函数返回 不小心 step 进去了,想快速退出
continue c 继续运行到下一个断点 跳过已确认无误的代码段

你想想看,如果每次调用 printf 都用 step 进去,那得等到猴年马月。我一般用 next 跳过库函数,只在自定义函数上用 step。

避坑指南:我曾经在调试一个 RTOS 任务切换时,用 step 进入了调度器内部,结果 GDB 直接卡死。后来才知道,调度器代码涉及栈切换,单步执行很容易跑飞。遇到这种情况,建议在关键位置设断点,用 continue 跳转。

3.3 变量查看:实时监控数据变化

程序停下来了,总得看看变量值对不对吧?

3.3.1 基本变量查看命令

(gdb) print count        # 打印变量 count 的值
(gdb) print &count       # 打印 count 的地址
(gdb) print *ptr         # 打印指针指向的内容
(gdb) display count      # 每次停下时自动显示 count
(gdb) undisplay 1        # 取消编号为1的自动显示

display 是我最常用的命令之一。调试循环时,把循环变量设成自动显示,每次单步都能看到它的变化,省得一遍遍敲 print。

3.3.2 查看复杂数据结构

(gdb) print my_struct          # 打印整个结构体
(gdb) print my_struct->member  # 打印结构体成员
(gdb) print array[10]          # 打印数组第11个元素
(gdb) print *array@5           # 打印数组前5个元素

我记得有一次调试一个链表操作,程序总是崩溃。用 print *node 一看,发现某个节点的 next 指针指向了 0xDEADBEEF——典型的野指针问题。这种问题光看代码很难发现,但一查变量就原形毕露了。

核心要点:变量查看不只是看「值对不对」,更要看「地址合不合理」。嵌入式开发中,指针错误是头号杀手。

3.4 内存操作:直接与硬件对话

嵌入式调试和普通软件调试最大的区别,就是我们需要直接操作内存。寄存器、外设地址、DMA 缓冲区……这些都在内存里。

3.4.1 查看内存

(gdb) x/10x 0x40020000    # 以十六进制查看10个单元
(gdb) x/10d &buffer       # 以十进制查看buffer的10个元素
(gdb) x/10c &str          # 以字符形式查看字符串
(gdb) x/10i $pc           # 查看当前指令附近的10条汇编

x 命令的格式是 x/<数量><格式> <地址>。格式包括:x(十六进制)、d(十进制)、c(字符)、i(指令)、s(字符串)。

3.4.2 修改内存

(gdb) set var count = 100       # 修改变量
(gdb) set {int}0x40020000 = 0   # 修改指定内存地址的值
(gdb) set $r0 = 0x1234          # 修改寄存器值

这个功能在测试边界条件时特别有用。比如你想测试一个缓冲区满的情况,直接 set var index = BUFFER_SIZE - 1,不用等程序慢慢跑满。

警告:修改内存和寄存器要非常小心。我曾经在调试时手误改了一个外设控制寄存器的值,结果电机直接飞转,差点出事故。建议在修改前先用 print 确认当前值,改完后立即用 continue 让程序继续运行,不要长时间停在调试状态。

3.5 知识结构总览

下面这张图把 GDB 核心命令的关系梳理清楚了。调试时先设断点,然后单步执行,同时查看变量和内存,发现问题后可以直接修改——这是一个完整的闭环。

GDB 核心命令体系 GDB 调试会话 断点管理 单步执行 变量查看 内存操作 break / info disable / enable step / next finish / continue print / display 结构体/数组 x 命令查看 set 修改 调试闭环:设断点 → 单步执行 → 查看变量/内存 → 发现问题 → 修改值 → 继续调试 四个模块相互配合,覆盖嵌入式调试的绝大多数场景

3.6 实战演练:一个完整的调试流程

光说不练假把式。我们用一个实际例子串一遍:

// bug.c - 一个故意写错的程序
#include <stdio.h>

int sum_array(int *arr, int len) {
    int sum = 0;
    for (int i = 0; i <= len; i++) {  // 注意:这里应该是 <,不是 <=
        sum += arr[i];
    }
    return sum;
}

int main() {
    int data[] = {1, 2, 3, 4, 5};
    int result = sum_array(data, 5);
    printf("Sum = %d\n", result);
    return 0;
}

调试步骤:

  1. 编译带调试信息:gcc -g -o bug bug.c
  2. 启动 GDB:gdb ./bug
  3. 设断点:break sum_array
  4. 运行:run
  5. 单步:用 next 一步步走,同时用 display i 和 display sum
  6. 发现问题:当 i 变成 5 时,循环还在继续,说明条件写成了 <=
  7. 查看内存:x/6d data 确认数组越界后读取了垃圾值
  8. 修复:把 <= 改成 <,重新编译运行
总结:GDB 的核心命令就这些——断点让程序停在该停的地方,单步让你看清每一步,变量和内存查看让你知道数据对不对,内存修改让你能快速验证修复方案。把这四样练熟了,嵌入式调试你就入门了。