VS Code 使用 GDB 调试STM32实战:以 SPI HardFault 为例

一、为什么写这篇

嵌入式项目里最难排查的一类问题是:

  • 系统运行一段时间后才崩溃
  • 代码停在看似无辜的位置(例如 HAL_SPI_TransmitReceive)
  • 每次重启后短时间又正常

这篇文章给出一套在 VS Code 中可复用的 GDB 调试流程,并用本项目的 SPI 异常做完整示例。本文默认已经移植好在vscode中使用gdb的环境。

二、环境与前提

  • 编辑器:VS Code
  • 调试器:GDB(arm-none-eabi-gdb)
  • 目标:STM32
  • 具备可用的下载/调试链路(J-Link / ST-Link + gdb server)

建议:

  • 使用 Debug 构建(保留符号)

  • 先确认可以正常单步、查看变量、查看调用栈

  • 调试控制台:

    image-20260303111120714

三、三种断点类型

1) 代码断点(break

  • 作用:程序执行到某一行就停,最适合“按流程走读代码”。

  • 常用场景:初始化顺序、函数调用链、某段逻辑是否进入。

  • 优点:直观、上手最快;适合先缩小问题范围。

  • 局限:高频函数里会频繁停住;对“偶发改内存”不敏感。

  • 常用命令break 函数名break 文件:行号nextstepcontinue。可直接的代码打上断点不需要在调试控制台进行命令传输。

    image-20260303101220202

2) 条件断点(condition

  • 作用:断点命中后先判断表达式,条件为真才真正停下。

  • 常用场景:循环第 N 次才异常、某变量达到阈值才出错、偶发分支。

  • 优点:减少无效停顿,专注“异常瞬间”。

  • 局限:条件表达式过复杂会拖慢调试;表达式写错会漏停。

  • 常用命令:先 break ...,再 condition 断点号 条件(如 condition 3 ch > 7)。

    例子:

    image-20260303102029754

    在这个函数中我想要知道spi2的配置结构体是否被改写:

    break TPS92518_Write_Bus
    info breakpoints(假设编号 4)
    condition 4 hspi2.Instance != SPI2
    continue
    

    需要在调试控制台编写这段代码:

    image-20260303101744948

    image-20260303101857729

    实际可以看到编号2就是刚刚运行break TPS92518_Write_Bus打的断点,而编号1是第一种直接打下的红色断点。

    那么再给这个断点添加上条件就可以继续运行了condition 2 hspi2.Instance != SPI2.(条件可以根据实际进行修改)。

3) 数据断点/观察点(watchpoint

  • 作用:监控某地址被读/写,一发生就停(不是按代码行,而是按内存访问)。
  • 常用场景:变量“本不该变却变了”、结构体成员被踩、怀疑越界/野指针。
  • 优点:能直接抓到“谁第一次改坏它”,定位这类问题效率最高。
  • 局限:依赖硬件调试寄存器,数量有限(常见 2~4 个)。
  • 常用命令watch -location hspi2.Instance(写触发)、awatch -location hspi2.Instance(读写触发)。
  • 这个调试手法就必须在命令行中输入了,经常用于监测某个不应该被修改的地址由于数组越界而发生改变导致进入HardFault的异常。

四、案例背景(SPI)

现象:

  • 运行一段时间后进入 HardFault
  • 在HardFault中进行栈回溯会发现总是在调用spi传输时进入错误中断

关键判断:

  • spi出现问题,由于系统初始可以正常运行而运行一段时间后才发生错误,故排除spi配置错误

  • 在spi传输前添加异常定位代码,判断spi配置是否还是正确的

    image-20260303103127688

  • 后续 HAL_SPI_DeInit 使用这个坏地址访问寄存器,也是触发HardFault,故推断spi2的寄存器已经被全部改变,只要有操作到spi寄存器这段地址的代码都会进入HardFault。

五、30 秒定位法

  1. 在 main 或初始化后先暂停一次。
  2. 在 调试控制台(Debug Console) 输入:
    • p/x &hspi2.Instance

      image-20260303103354514

    • watch -location hspi2.Instance

    • continue

  3. 一旦有代码写这个成员,调试器会立刻停下。
  4. 停下后马上输入:
    • bt

    • info registers

    • p/x hspi2.Instance

    • 此时也不可以不需要再输入,因为代码中已经产生断点:

      image-20260303103534575

      断点打在了这个全局数组中,此时查看堆栈发现i是16,而这个二维数组i最大只能是15,故造成数组越界导致进入HardFault。

      image-20260303103746335

      进一步查看一下这个数组的地址:

      image-20260303103901415

      可以清晰的看到这个数组的地址是0x20001ee4,与&hspi2.Instance的地址0x200026e4刚好就差了16*128也就是这个数组的大小,结合栈的特性,数组的地址又是往上递增,故操作到数组的后面地址即&hspi2.Instance的地址。

      添加上防越界代码即可解决这个问题:

      image-20260303104220440

      此oled的驱动代码以及逻辑是从老项目中移植过来,老项目中并未运行出问题,大概率是因为老项目与OLED_GRAM紧邻的变量是一个不怕被更改的变量,故一直没有出现硬件错误中断,但是实际可能会发生一些隐晦的控制逻辑错误。

解释:

  • p/x &hspi2.Instance:打印该成员的地址(十六进制)
  • watch -location hspi2.Instance:监控这个地址被写入
  • bt:看调用栈,直接找到“谁写坏了它”

六、常用 GDB 命令清单

  • 只监视写入:watch -location hspi2.Instance
  • 监视读写:awatch -location hspi2.Instance
  • 按地址监视:watch -location (uint32_t)0x200026e4
  • 查看断点/观察点:info breakpoints
  • 删除断点/观察点:delete 编号
  • 打印地址:p/x &hspi2.Instance
  • 打印当前值:p/x hspi2.Instance

七、排查闭环模板

  1. 先抓现场:HardFault 寄存器 + 调用栈
  2. 再抓首改:对可疑变量下 watchpoint
  3. 定位源头:是谁在何处改写
  4. 修复根因:越界、非法索引、并发访问、野指针
  5. 长稳验证:跑压力/长时间测试

八、这类问题最常见根因

  • 数组越界(最常见)
  • 错误索引或枚举值误用
  • 结构体被 memcpy/memset 误覆盖
  • 中断与主循环并发访问缺少保护

九、结论

遇到“变量本应不变却被改写”的问题,不要先猜逻辑分支。
最有效的方式是:

  • 直接对该变量下 watchpoint
  • 让调试器在第一次写入时停住
  • 用调用栈反推根因

根因

  • 数组越界(最常见)
  • 错误索引或枚举值误用
  • 结构体被 memcpy/memset 误覆盖
  • 中断与主循环并发访问缺少保护

九、结论

遇到“变量本应不变却被改写”的问题,不要先猜逻辑分支。
最有效的方式是:

  • 直接对该变量下 watchpoint
  • 让调试器在第一次写入时停住
  • 用调用栈反推根因
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐