STM32调试三板斧:printf重定向、HardFault定位、逻辑分析仪抓波形,从此告别瞎改代码

嵌入式开发最耗时间的不是写代码,是调bug。这篇文章总结了我调试STM32最常用的三板斧------printf重定向、HardFault精准定位、逻辑分析仪抓波形,全是实战经验。

有同学问我:"学长,我LED点不亮,代码改了几十遍都找不出问题,怎么办?"

我的第一反应是------你用什么工具在调?

他说:"就...看代码啊。"

------这就是问题所在。嵌入式调试靠"看"是看不出bug的,要用工具。

这篇文章分享我调试STM32最常用的三板斧,从入门级到进阶级,覆盖了绝大多数调试场景。

💬 你平时调试用什么工具?遇到过最诡异的bug是什么?评论区分享一下,看看谁的经历最离谱!


第一板斧:printf重定向------最简单的调试武器

为什么需要printf?

不管是写上位机程序还是嵌入式,printf 永远是最直接的调试手段。但STM32默认没有printf输出------你需要把printf的输出"重定向"到串口。

实现步骤(CubeMX+HAL库)

Step 1:CubeMX配置串口

开一个USART(比如USART1),模式选 Asynchronous,波特率115200,其他默认。生成代码。

Step 2:重写fputc函数

在 main.c 或新建的 debug.c 中:

c 复制代码
#include <stdio.h>  /* 需要添加,printf依赖的头文件 */

/* 重写fputc------printf输出的每个字符都会进入这个函数 */
int fputc(int ch, FILE *f) {
    /* 把字符通过串口发出去 */
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
    return ch;
}

Step 3:勾选Use MicroLIB

Keil里这样配:

复制代码
Project → Options → Target → Code Generation → 勾选 "Use MicroLIB"

为什么勾MicroLIB? 标准C库的printf实现很大(大几十KB),MicroLIB是精简版,占用小、适合嵌入式。不勾的话链接会报错或者程序跑不起来。

Step 4:开干

c 复制代码
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();
    
    printf("====================================\r\n");
    printf("  STM32 printf 调试输出\r\n");
    printf("  系统时钟: %d MHz\r\n", HAL_RCC_GetSysClockFreq() / 1000000);
    printf("====================================\r\n");
    
    int count = 0;
    while (1) {
        printf("[%d] 系统运行中...\r\n", count++);
        HAL_Delay(1000);
    }
}

串口助手看到的输出:

复制代码
====================================
  STM32 printf 调试输出
  系统时钟: 72 MHz
====================================
[0] 系统运行中...
[1] 系统运行中...
[2] 系统运行中...

进阶:彩色printf

串口助手中的彩色输出能让你一眼区分"正常信息"、"警告"和"错误":

c 复制代码
/* 定义颜色宏 */
#define PRINT_RED    "\033[31m"
#define PRINT_GREEN  "\033[32m"
#define PRINT_YELLOW "\033[33m"
#define PRINT_RESET  "\033[0m"

/* 使用 */
printf(PRINT_GREEN"[INFO]"PRINT_RESET" 传感器读取完成\r\n");
printf(PRINT_YELLOW"[WARN]"PRINT_RESET" 电压偏低: %.2fV\r\n", voltage);
printf(PRINT_RED"[ERROR]"PRINT_RESET" 通信超时!\r\n");

注意:彩色输出需要串口助手支持ANSI转义码(如 MobaXterm、SecureCRT 支持)。SSCOM 部分版本也支持。

什么时候printf不够用?

  • 时序相关的bug:printf本身耗时较长,会改变程序时序
  • 中断中不要用printf:串口发送是阻塞的,会卡死中断响应
  • 实时性要求高的场景:看下一板斧

第二板斧:HardFault定位------遇到死机不再慌

什么是HardFault?

STM32的HardFault相当于Windows的"蓝屏"------程序执行了非法操作,CPU进入硬件错误中断。最常见的几种原因:

复制代码
┌──────────────────────────────────┐
│  1. 数组越界 / 野指针写坏内存      │ ← 最常见
│  2. 栈溢出(Stack Overflow)      │
│  3. 中断服务函数里做了阻塞操作      │
│  4. 使用FreeRTOS时在临界区里调了    │
│     阻塞API(vTaskDelay等)        │
│  5. 时钟配置错误导致外设异常        │
└──────────────────────────────────┘

方法A:看寄存器定位(最通用,裸机和RTOS都适用)

当程序进入 HardFault,停下调试器,查看这几个寄存器:

复制代码
SCB->HFSR      (0xE000ED2C)  --- HardFault状态寄存器
SCB->CFSR      (0xE000ED28)  --- 可配置错误状态寄存器(含UsageFault/BusFault/MemManage)
SCB->BFAR      (0xE000ED38)  --- BusFault地址寄存器

在Keil中打开 Peripherals → Core Peripherals → Fault Reports,直接看图形界面:

复制代码
=== HardFault Report ===
HardFault Status Register (HFSR):  0x40000000
  └─ FORCED: 0x1  ← 表示是被其他异常强制触发

Configurable Fault Status (CFSR):  0x00008200
  └─ IMPRECISERR: 0x1  ← 不精确的Bus Fault
    
=== 解读 ===
IMPRECISERR=1 意味着错误发生在之前某条指令
常见原因:用DMA操作了未使能时钟的外设

实用技巧:看堆栈回溯

进入HardFault后,在调试器里看 Call Stack 窗口------通常能看到是哪个函数调用导致了死机。如果Call Stack是空的(栈被踩坏了),看SP指针指向的内存:

  1. 记下当前 MSP/PSP 的值
  2. 在 Memory 窗口看这个地址附近的数据
  3. 找返回地址(LR的值),对应到代码里的函数

方法B:利用CMBacktrace库(推荐,项目必备)

CMBacktrace 是一个开源库,专门针对ARM Cortex-M系列做错误定位,能自动输出函数调用栈。

接入步骤:

c 复制代码
/* 1. 把 cm_backtrace 文件夹加入工程 */
/* 2. 在 main.c 中初始化 */
#include "cm_backtrace.h"

int main(void) {
    /* ... 初始化硬件 ... */
    cm_backtrace_init("STM32F103", "v1.0", "2026-06-01");
    
    /* ... 业务代码 ... */
}

/* 3. 在 HardFault_Handler 中调用 */
void HardFault_Handler(void) {
    if (cm_backtrace_is_in_fault()) {
        cm_backtrace_fault(MSP_GET(), PSP_GET(), 0);
    }
    while(1);
}

发生HardFault后的输出:

复制代码
================== Hard Fault ==================
程序名称: STM32F103
固件版本: v1.0
固件时间: 2026-06-01
================== 寄存器状态 ==================
  R0:  0x200001234    R1:  0x00000000
  R2:  0x4001100C    R3:  0x00000005
  R12: 0x00000000    LR:  0x08002567
  PC:  0x080024AB    xPSR: 0x21000000
================== 调用栈回溯 ==================
  [0] 0x080024AB  →  HAL_UART_Transmit + 0x27
  [1] 0x08002567  →  uart_send_data + 0x14
  [2] 0x08002211  →  sensor_read + 0x3A
================== Hard Fault END ==============

看到调用栈了吗?一眼就能看出来是 HAL_UART_Transmit 出了问题------可能串口时钟没配,或者参数传错了。不用一寸一寸看代码了。

方法C:FreeRTOS中的栈溢出检测

用了FreeRTOS,taskENTER_CRITICAL() 里面调了 printf,十有八九会死机。用FreeRTOS自带的栈溢出钩子:

c 复制代码
/* FreeRTOSConfig.h 中开启 */
#define configCHECK_FOR_STACK_OVERFLOW    2

/* 实现钩子函数 */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
    printf("[FATAL] 任务栈溢出!任务名: %s\r\n", pcTaskName);
    /* 或者闪LED报错 */
    while(1) {
        HAL_GPIO_TogglePin(ERROR_LED_GPIO_Port, ERROR_LED_Pin);
        HAL_Delay(200);
    }
}

第三板斧:逻辑分析仪------硬件调试的"内窥镜"

为什么需要逻辑分析仪?

有些bug跟时序有关------"模块初始化顺序不对"、"I2C通信偶尔失败"、"PWM波形的占空比不对"。这种时候printf帮不了你,因为打印本身就会改变时序。

逻辑分析仪就是干这个的------它能精准抓取GPIO引脚的波形变化,精确到微秒级。

万能的"调试引脚法"

原理:在关键代码位置翻转一个GPIO,然后用逻辑分析仪看这个引脚的波形:

c 复制代码
/* 定义调试引脚 */
#define DEBUG_PIN_1    GPIO_PIN_8   /* PB8 */
#define DEBUG_GPIO     GPIOB

/* 一个极简的调试宏 */
#define DEBUG_SET(n)    HAL_GPIO_WritePin(DEBUG_GPIO, DEBUG_PIN_##n, GPIO_PIN_SET)
#define DEBUG_RESET(n)  HAL_GPIO_WritePin(DEBUG_GPIO, DEBUG_PIN_##n, GPIO_PIN_RESET)
#define DEBUG_TOGGLE(n) HAL_GPIO_TogglePin(DEBUG_GPIO, DEBUG_PIN_##n)

/* 用法 */
void sensor_read_task(void) {
    DEBUG_SET(1);                      /* 开始读取 → 拉高PB8 */
    
    i2c_start();
    DEBUG_TOGGLE(2);                   /* 开始I2C通信 → 翻转PB9 */
    i2c_write(0x3C);
    i2c_read(buffer, 10);
    DEBUG_TOGGLE(2);                   /* I2C结束 → 再翻转PB9,PB9上看到一个脉冲 */
    
    data_process(buffer);
    DEBUG_TOGGLE(3);                   /* 数据处理完成 → 翻转PB10 */
    
    DEBUG_RESET(1);                    /* 全部结束 → 拉低PB8 */
    vTaskDelay(pdMS_TO_TICKS(100));
}

用逻辑分析仪同时抓 PB8/PB9/PB10 三个引脚:

复制代码
PB8  ┌────┐        ┌────┐        ┌────┐
      │    │        │    │        │    │      ← 整体执行时间 ≈ 5.2ms
      └────┘────────┘────┘────────┘────┘
           ^     ^
PB9         ┌─┐ ┌─┐
            │ │ │ │                   ← I2C通信脉冲 ≈ 850μs
            └─┘ └─┘

PB10                ┌┐         ┌┐
                     ││         ││     ← 数据处理 ≈ 120μs
                     └┘         └┘

一眼就能看出来:I2C通信占了大部分时间,如果这里需要优化------降低I2C速度或者用DMA。

用逻辑分析仪抓I2C协议

如果I2C通信偶尔失败,不用猜------直接用逻辑分析仪抓SDA和SCL的波形:

  1. 逻辑分析仪的通道0接SCL,通道1接SDA
  2. 设置采样率:至少4倍于I2C频率(I2C是400kHz的话,采样率设2MHz以上)
  3. 设置触发条件:SDA下降沿触发(即START信号)
  4. 加一个足够长的触发延时,抓到完整的数据帧

大多数逻辑分析仪软件(Saleae、PulseView)支持协议解码------选中I2C协议,它会自动把波形解析成"START→地址→ACK→数据→STOP",看起来就像串口助手一样直观。

复制代码
解析结果:
  START → 地址: 0x3C (写) → ACK → 数据: 0x12 → ACK → 数据: 0x34 → ACK → STOP

哪里出了问题一目了然:设备没ACK?→ 地址不对或者设备没上电。数据是错的?→ 时序问题或者电平不匹配。

入门推荐

设备 价格 说明
Saleae Logic 8 山寨版 20~50元 足够入门,淘宝搜"逻辑分析仪"
正版 Saleae 400+元 稳定、采样率高
金沙滩逻辑分析仪 100~200元 国产,带中文软件,适合入门
DSLogic 300~500元 开源方案,协议支持多

别犹豫,几十块钱就能买到。 排查一次I2C/SPI通信问题的效率提升就值回票价了。


总结:三板斧怎么选?

场景 用哪板斧 为啥
变量值对不对 printf 最简单直接
程序死机在某个函数里 HardFault定位 一秒定位崩溃点
两个任务抢资源 printf + 调试引脚 看谁先跑、跑多久
I2C/SPI通信失败 逻辑分析仪 直接看波形,不猜
程序执行时间超预期 调试引脚 + 逻辑分析仪 精准测量各段耗时
跑FreeRTOS死机 CMBacktrace + 栈溢出钩子 自动输出调用栈

核心心法一句话:不要让"猜"成为你的调试方式。把工具用起来,bug定位效率翻十倍。


如果这篇文章让你觉得"原来调试可以这么搞",点个👍赞收个⭐藏,让更多被bug折磨的嵌入式同学看到。

💬 评论区说说你遇到过最诡异的bug是什么?怎么解决的?

  • 我之前遇到过I2C通信因为上拉电阻没焊导致间歇性失败,查了两天才发现
  • 你遇到过什么奇葩bug?说出来让大家乐一乐(顺便长长经验)

每一条评论我都会回复,踩坑经验越多,大家进步越快。

👆 点关注,每周更新嵌入式干货------调试技巧/FreeRTOS/项目实战

相关推荐
三易串口屏10 小时前
实验22 心跳曲线实验
stm32·tft屏·hmi·三易串口屏·嵌入式ui
LCG元16 小时前
STM32实战:基于STM32F103的家用新风系统智能控制器(空气质量监测+PID调速)
stm32·单片机·嵌入式硬件
凉、介16 小时前
深入理解 ARMv8-A|处理器模式与寄存器
笔记·学习·嵌入式·arm
LCG元16 小时前
STM32实战:基于STM32F103的多通道工业数据采集与监控系统(Modbus RTU+上位机)
stm32·单片机·嵌入式硬件
资深流水灯工程师17 小时前
STM32 单片机 USB 通讯原理与 HAL 库实战详解
stm32·单片机·嵌入式硬件
资深流水灯工程师17 小时前
STM32 I2C 通讯原理与三种实现模式详解
stm32·单片机·嵌入式硬件
资深流水灯工程师17 小时前
STM32 USART 通讯原理与三种模式详解
stm32·单片机·嵌入式硬件
资深流水灯工程师17 小时前
STM32 单片机 SPI 通讯原理详解
stm32·单片机·嵌入式硬件
不做无法实现的梦~17 小时前
MAVLink 协议教程
linux·stm32·嵌入式硬件·算法