单片机进入 HardFault 后,如何找到真正出错的代码?——STM32/Cortex-M 故障定位实战

在 STM32 或其他 Cortex-M 单片机开发中,很多工程师都遇到过类似问题:

  • 程序运行一段时间后突然跑飞;
  • 系统一上电就复位;
  • 调试器停在 HardFault_Handler()
  • 程序卡死在死循环中;
  • 重新上电后故障消失,现场无法复现;
  • 明明停在 HardFault 中,却不知道真正出错的是哪一行代码。

HardFault 并不等于“单片机随机死机”。

它实际上是 Cortex-M 内核在检测到严重异常后,主动进入的一种故障处理流程。只要能够正确读取异常现场、分析状态寄存器,并将程序地址反查到源码,就有机会找到真正的故障位置。

本文将系统介绍:

  1. HardFault 是什么;
  2. 常见的触发原因;
  3. PC、LR、SP 分别有什么作用;
  4. 如何读取 Fault 状态寄存器;
  5. 如何根据地址反查代码行;
  6. 如何保存崩溃现场;
  7. 一套可直接使用的 HardFault 定位代码。

一、HardFault 是什么

HardFault 是 ARM Cortex-M 内核中的一种硬故障异常。

当 CPU 检测到严重错误,或者其他可配置 Fault 没有开启、无法处理、被升级时,系统会进入:

HardFault_Handler()

Cortex-M 中常见的故障类型包括:

故障类型 说明
MemManage Fault 存储器管理故障
BusFault 总线访问故障
UsageFault 指令或用法故障
HardFault 硬故障,可能由其他 Fault 升级而来

可以简单理解为:

非法访问或执行错误
        ↓
MemManage / BusFault / UsageFault
        ↓
故障未单独处理或发生严重异常
        ↓
HardFault_Handler

因此,HardFault 不是“随机死机”,而是 CPU 在告诉开发者:

程序执行过程中发生了严重异常,请检查当前寄存器、堆栈和故障状态。


二、为什么程序总是停在 HardFault_Handler

很多 STM32 工程默认的 HardFault 处理函数如下:

void HardFault_Handler(void)
{
    while (1)
    {
    }
}

这段代码只会让 CPU 停在死循环中。

调试器最终看到的位置自然是:

HardFault_Handler()

但这通常不是程序真正出错的位置。

真正的故障可能发生在:

  • 某个指针解引用操作;
  • 某次数组写入;
  • 某个 memcpy()
  • 某个任务栈溢出;
  • 某次函数返回;
  • 某次中断处理;
  • 某个非法函数指针调用。

进入异常时,Cortex-M 会自动把部分寄存器压入当前堆栈。我们需要做的,就是从堆栈中把这些寄存器取出来。


三、HardFault 常见触发原因

1. 非法地址访问

程序访问了不存在的 Flash、RAM 或外设地址,可能触发 BusFault,最终升级为 HardFault。

例如:

volatile uint32_t *ptr = (uint32_t *)0xDEADBEEF;
uint32_t value = *ptr;

地址 0xDEADBEEF 通常不是有效的 MCU 地址。

执行解引用时,CPU 会发起总线访问。如果总线访问失败,就可能进入 Fault。

STM32 常见地址范围示例:

0x08000000 附近:内部 Flash
0x20000000 附近:内部 SRAM
0x40000000 附近:外设寄存器
0xE0000000 附近:Cortex-M 系统控制空间

具体地址范围需要查看所使用芯片的数据手册。

常见错误包括:

uint32_t *p = (uint32_t *)0x12345678;
*p = 0x55AA55AA;

或者指针偏移计算错误:

uint8_t buffer[16];

uint32_t *p = (uint32_t *)(buffer + 100);
*p = 0x12345678;

2. 空指针或野指针

空指针是 HardFault 最常见的原因之一。

例如:

typedef struct
{
    uint32_t value;
} Device_t;

Device_t *device = NULL;

device->value = 100;

这里的 deviceNULL,其值通常为:

0x00000000

执行:

device->value = 100;

相当于访问地址 0x00000000 附近的内存,可能直接触发异常。

野指针则是指向了已经失效或不确定地址的指针,例如:

uint8_t *get_buffer(void)
{
    uint8_t buffer[32];
    return buffer;
}

buffer 是局部变量,函数结束后对应栈空间已经失效。继续使用返回的指针,会产生不可预测的结果。


3. 数组越界

数组越界的危险之处在于,它不一定立即触发 HardFault。

例如:

uint8_t buffer[8];

buffer[8] = 0xAA;

合法下标应该是:

0 ~ 7

但程序写入了 buffer[8]

这次写入可能覆盖:

  • 相邻变量;
  • 指针变量;
  • 函数返回地址;
  • RTOS 任务控制块;
  • 堆栈中的 LR;
  • 中断现场;
  • 内存分配器管理信息。

因此,程序可能在越界写入之后继续运行一段时间,最后在完全不同的位置进入 HardFault。

例如程序停在:

return;

真正的原因可能不是 return 语句,而是更早之前数组越界,破坏了函数返回地址。

排查时应重点检查:

memcpy()
memset()
sprintf()
strcpy()
strcat()
数组下标
循环边界
接收缓冲区长度

4. 未对齐访问

某些数据类型要求地址按照特定边界对齐。

例如,访问一个 32 位整数,通常希望地址满足 4 字节对齐。

错误示例:

uint8_t buffer[8];

uint32_t value = *(uint32_t *)&buffer[1];

&buffer[1] 可能不是 4 字节对齐地址。

某些 Cortex-M 内核允许部分未对齐访问,但以下场景仍可能触发异常:

  • 特定类型的多字访问;
  • 配置了未对齐访问陷阱;
  • 使用某些加载、存储指令;
  • 访问打包结构体;
  • 将字节流强制转换成结构体指针。

例如:

#pragma pack(push, 1)

typedef struct
{
    uint8_t id;
    uint32_t value;
} Packet_t;

#pragma pack(pop)

此时 value 可能处于未对齐地址。

更安全的方式是使用 memcpy()

uint32_t value;

memcpy(&value, &buffer[1], sizeof(value));

5. 栈溢出

栈溢出也是嵌入式系统中非常常见的问题。

常见原因包括:

  • 局部数组过大;
  • 函数调用层级过深;
  • 递归调用;
  • 中断嵌套过深;
  • RTOS 任务栈分配太小;
  • 在栈中创建大型结构体。

例如:

void test_function(void)
{
    uint8_t large_buffer[8192];

    memset(large_buffer, 0, sizeof(large_buffer));
}

如果当前栈空间不足,large_buffer 就可能覆盖其他内存。

栈溢出可能破坏:

  • 返回地址;
  • 保存的 LR;
  • 局部变量;
  • 其他任务栈;
  • 全局数据;
  • RTOS 控制结构。

在 RTOS 项目中,应重点检查:

  • 任务栈大小;
  • 任务栈剩余水位;
  • 中断栈;
  • 是否存在深层调用;
  • 是否在任务函数中定义了大数组。

FreeRTOS 中可以使用类似接口查看剩余栈空间:

UBaseType_t remain_stack;

remain_stack = uxTaskGetStackHighWaterMark(NULL);

6. 非法函数指针

函数指针被破坏,也会导致 CPU 跳转到错误地址。

例如:

void (*callback)(void) = NULL;

callback();

或者函数指针被数组越界覆盖:

typedef void (*Callback_t)(void);

typedef struct
{
    uint8_t buffer[8];
    Callback_t callback;
} Object_t;

如果对 buffer 越界写入,就可能破坏 callback

最终 CPU 可能跳转到:

0x00000000
0xFFFFFFFF
0xA5A5A5A5
0xCCCCCCCC

这些明显异常的地址。


7. 从错误地址执行代码

Cortex-M 处理器对指令地址有特殊要求。

Cortex-M 只支持 Thumb 指令集,因此函数地址的最低位通常需要为 1。

错误地清除函数地址最低位,可能导致 UsageFault。

例如:

void target_function(void)
{
}

void (*func)(void);

func = (void (*)(void))((uint32_t)target_function & ~1UL);
func();

这类错误可能表现为:

  • INVSTATE
  • INVPC
  • 非法指令;
  • 跳转地址异常。

四、PC、LR、SP 分别是什么

进入 HardFault 后,最值得关注的三个寄存器是:

PC
LR
SP

1. PC:程序计数器

PC 是 Program Counter,也就是程序计数器。

它表示 CPU 当前执行到哪个地址。

在异常发生时,压入堆栈中的 PC 通常是最重要的定位入口。

例如:

PC = 0x08001234

说明故障发生时,程序执行位置在 Flash 地址 0x08001234 附近。

之后可以使用:

arm-none-eabi-addr2line

把这个地址反查到具体函数和源码行。


2. LR:链接寄存器

LR 是 Link Register,也就是链接寄存器。

正常函数调用时,LR 中通常保存函数返回地址。

例如:

function_a();

调用 function_a() 后,LR 会保存函数执行完成后应该返回的位置。

需要注意:

在异常处理函数内部直接查看 LR,得到的可能是 EXC_RETURN,而不是业务函数原本的返回地址。

常见的 EXC_RETURN 值包括:

0xFFFFFFF1
0xFFFFFFF9
0xFFFFFFFD

这些值用于告诉 CPU:

  • 异常返回到线程模式还是处理模式;
  • 使用 MSP 还是 PSP;
  • 是否包含浮点寄存器现场。

真正用于分析业务调用链的 LR,通常是异常入栈时保存的那个 LR。


3. SP:堆栈指针

SP 是 Stack Pointer,也就是堆栈指针。

Cortex-M 中常见两个堆栈指针:

MSP:Main Stack Pointer
PSP:Process Stack Pointer

MSP 通常用于:

  • 复位后默认堆栈;
  • 中断和异常处理;
  • 裸机程序。

PSP 通常用于:

  • RTOS 任务;
  • 线程模式;
  • 用户任务栈。

进入异常时,需要根据 LR 中的 EXC_RETURN 判断异常发生前使用的是 MSP 还是 PSP。


五、Cortex-M 异常时自动保存了什么

Cortex-M 进入异常时,硬件会自动将以下寄存器压入当前堆栈:

R0
R1
R2
R3
R12
LR
PC
xPSR

可以定义一个结构体表示异常堆栈:

typedef struct
{
    uint32_t r0;
    uint32_t r1;
    uint32_t r2;
    uint32_t r3;
    uint32_t r12;
    uint32_t lr;
    uint32_t pc;
    uint32_t xpsr;
} HardFaultStackFrame_t;

进入 HardFault 后,只要找到正确的堆栈指针,就可以读取这些信息。


六、获取 HardFault 压栈现场

下面是一种 GCC 编译器下常见的处理方式。

#include "stm32f4xx.h"
#include <stdint.h>

typedef struct
{
    uint32_t r0;
    uint32_t r1;
    uint32_t r2;
    uint32_t r3;
    uint32_t r12;
    uint32_t lr;
    uint32_t pc;
    uint32_t xpsr;
} HardFaultStackFrame_t;

void HardFault_Handler_C(uint32_t *stack_address);

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile
    (
        "tst lr, #4            \n"
        "ite eq                \n"
        "mrseq r0, msp         \n"
        "mrsne r0, psp         \n"
        "b HardFault_Handler_C \n"
    );
}

这段汇编的作用是判断异常发生前使用的是 MSP 还是 PSP。

判断依据是 LR,也就是 EXC_RETURN 的第 2 位:

LR bit 2 = 0:使用 MSP
LR bit 2 = 1:使用 PSP

随后将正确的堆栈地址放入 R0,并跳转到 C 函数。


HardFault 的 C 语言处理函数

volatile HardFaultStackFrame_t g_hardfault_frame;

volatile uint32_t g_hfsr;
volatile uint32_t g_cfsr;
volatile uint32_t g_mmar;
volatile uint32_t g_bfar;
volatile uint32_t g_afsr;
volatile uint32_t g_shcsr;

void HardFault_Handler_C(uint32_t *stack_address)
{
    HardFaultStackFrame_t *frame;

    frame = (HardFaultStackFrame_t *)stack_address;

    g_hardfault_frame.r0   = frame->r0;
    g_hardfault_frame.r1   = frame->r1;
    g_hardfault_frame.r2   = frame->r2;
    g_hardfault_frame.r3   = frame->r3;
    g_hardfault_frame.r12  = frame->r12;
    g_hardfault_frame.lr   = frame->lr;
    g_hardfault_frame.pc   = frame->pc;
    g_hardfault_frame.xpsr = frame->xpsr;

    g_hfsr  = SCB->HFSR;
    g_cfsr  = SCB->CFSR;
    g_mmar  = SCB->MMFAR;
    g_bfar  = SCB->BFAR;
    g_afsr  = SCB->AFSR;
    g_shcsr = SCB->SHCSR;

    __BKPT(0);

    while (1)
    {
    }
}

当程序再次进入 HardFault 后,可以在调试器中查看:

g_hardfault_frame.pc
g_hardfault_frame.lr
g_hardfault_frame.xpsr
g_cfsr
g_hfsr
g_mmar
g_bfar

其中最优先查看的是:

PC
LR
CFSR
HFSR
MMFAR
BFAR

七、Fault 状态寄存器怎么看

Cortex-M 中最常用的故障状态寄存器包括:

寄存器 作用
HFSR HardFault 状态
CFSR 可配置 Fault 综合状态
MMFAR MemManage 故障地址
BFAR BusFault 故障地址
SHCSR 系统异常控制与状态
AFSR 辅助故障状态

1. HFSR:硬故障状态寄存器

HFSR 用于说明 HardFault 的类型。

最常关注的是 FORCED 位。

如果:

HFSR.FORCED = 1

通常表示某个可配置 Fault 被升级为了 HardFault。

例如:

MemManage
BusFault
UsageFault

发生后没有被单独处理,最终进入 HardFault。

代码中可以这样判断:

if ((SCB->HFSR & SCB_HFSR_FORCED_Msk) != 0U)
{
    /* 可配置 Fault 被升级为 HardFault */
}

2. CFSR:可配置故障状态寄存器

CFSR 是 HardFault 定位中最重要的寄存器之一。

它实际上由三部分组成:

CFSR = MMFSR + BFSR + UFSR

按照位域划分:

[7:0]   MMFSR
[15:8]  BFSR
[31:16] UFSR

也就是:

CFSR[7:0]    :MemManage Fault Status
CFSR[15:8]   :BusFault Status
CFSR[31:16]  :UsageFault Status

在 CMSIS 中可以直接读取:

uint32_t cfsr = SCB->CFSR;

八、MMFSR:存储器管理故障

MMFSR 位于 CFSR 的低 8 位。

常见状态包括:

标志 含义
IACCVIOL 指令访问违规
DACCVIOL 数据访问违规
MUNSTKERR 异常返回出栈错误
MSTKERR 异常进入压栈错误
MLSPERR 浮点上下文保存错误
MMARVALID MMFAR 地址有效

IACCVIOL

表示 CPU 尝试从不允许执行的区域取指令。

可能原因:

  • 函数指针错误;
  • 返回地址被破坏;
  • 跳转到了 RAM 或非法区域;
  • MPU 禁止执行。

DACCVIOL

表示数据访问违规。

可能原因:

  • 访问受保护内存;
  • 错误指针;
  • MPU 权限配置错误;
  • 访问禁止区域。

MMARVALID

只有该位有效时,MMFAR 中的地址才具有参考意义。

可以这样判断:

uint8_t mmfsr;

mmfsr = (uint8_t)(SCB->CFSR & 0xFFU);

if ((mmfsr & (1U << 7)) != 0U)
{
    uint32_t fault_address = SCB->MMFAR;
}

九、BFSR:总线故障

BFSR 位于 CFSR 的第 8~15 位。

常见标志包括:

标志 含义
IBUSERR 指令总线错误
PRECISERR 精确数据总线错误
IMPRECISERR 非精确数据总线错误
UNSTKERR 异常返回出栈错误
STKERR 异常进入压栈错误
LSPERR 浮点上下文错误
BFARVALID BFAR 地址有效

IBUSERR

CPU 取指令时发生总线错误。

常见原因:

  • 跳转到非法代码地址;
  • Flash 读取错误;
  • 函数指针异常;
  • 返回地址损坏。

PRECISERR

精确总线错误。

PRECISERR 置位时,压栈 PC 通常更有定位价值。

常见原因:

  • 访问非法外设地址;
  • 访问不存在的 RAM;
  • 指针错误;
  • 非法读写。

如果同时:

BFARVALID = 1

BFAR 中通常保存了发生错误的访问地址。


IMPRECISERR

非精确总线错误。

这种故障常见于写缓冲导致的延迟错误。

问题是:

当前 PC 不一定就是最初发起错误访问的那条指令。

因此,如果发现:

IMPRECISERR = 1

需要扩大排查范围,检查前面执行过的内存写操作。

例如:

*p = value;

真正的总线错误可能在后面才被 CPU 检测到。


BFARVALID

只有 BFARVALID 有效时,BFAR 才值得使用。

uint8_t bfsr;

bfsr = (uint8_t)((SCB->CFSR >> 8) & 0xFFU);

if ((bfsr & (1U << 7)) != 0U)
{
    uint32_t fault_address = SCB->BFAR;
}

十、UFSR:用法故障

UFSR 位于 CFSR 的高 16 位。

常见状态包括:

标志 含义
UNDEFINSTR 执行未定义指令
INVSTATE 非法状态
INVPC 非法 PC
NOCP 使用了不存在的协处理器
UNALIGNED 未对齐访问
DIVBYZERO 除零异常

UNDEFINSTR

CPU 执行了未定义指令。

可能原因:

  • PC 跳到了数据区域;
  • 函数指针错误;
  • 返回地址损坏;
  • Flash 内容损坏;
  • 代码地址计算错误。

INVSTATE

CPU 进入了非法执行状态。

Cortex-M 只支持 Thumb 状态,因此错误的跳转地址可能触发 INVSTATE


INVPC

异常返回时使用了非法 PC 或非法 EXC_RETURN。

常见原因:

  • 栈中的 LR 被破坏;
  • 返回地址被覆盖;
  • 栈溢出;
  • 错误修改异常现场。

UNALIGNED

发生未对齐访问。

如果开启了未对齐访问陷阱:

SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk;

不满足对齐要求的访问就可能触发 UsageFault。


DIVBYZERO

发生整数除零。

需要先开启除零异常捕获:

SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;

测试代码:

volatile int32_t a = 10;
volatile int32_t b = 0;
volatile int32_t c;

c = a / b;

十一、如何开启 MemManage、BusFault 和 UsageFault

很多工程默认只处理 HardFault。

为了让问题分类更加清晰,可以主动开启可配置 Fault:

void Fault_Init(void)
{
    SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;
    SCB->SHCSR |= SCB_SHCSR_BUSFAULTENA_Msk;
    SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk;

    SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;
    SCB->CCR |= SCB_CCR_UNALIGN_TRP_Msk;
}

这样发生不同错误时,系统可以分别进入:

MemManage_Handler()
BusFault_Handler()
UsageFault_Handler()

而不是所有异常全部升级到 HardFault。

不过无论是否单独开启,统一保存异常现场仍然非常重要。


十二、如何根据 PC 地址反查代码行

假设从异常堆栈中得到:

PC = 0x08001234

接下来需要将这个地址转换为:

函数名
源文件
代码行号

最常用的工具是:

arm-none-eabi-addr2line

1. 确保保留 ELF 文件

烧录程序时通常使用:

.hex
.bin

但地址反查需要使用带调试信息的:

.elf
.axf
.out

例如:

app.elf

必须保证该 ELF 文件与设备当前运行的固件版本完全一致。

否则可能出现:

  • 函数名称错误;
  • 行号错误;
  • 地址无法解析;
  • 定位到错误源码。

2. 使用 addr2line

命令示例:

arm-none-eabi-addr2line -e app.elf 0x08001234

建议增加:

-f:显示函数名
-C:解析 C++ 符号名

完整命令:

arm-none-eabi-addr2line -f -C -e app.elf 0x08001234

输出可能类似:

sensor_read
D:/project/src/sensor.c:126

说明地址 0x08001234 对应:

函数:sensor_read
文件:sensor.c
行号:126

3. 同时反查 PC 和 LR

不要只反查 PC。

建议同时查询:

arm-none-eabi-addr2line -f -C -e app.elf 0x08001234
arm-none-eabi-addr2line -f -C -e app.elf 0x080010A9

其中:

0x08001234:PC
0x080010A9:LR

PC 可以帮助判断故障发生在哪里。

LR 可以帮助判断当前函数是从哪里被调用的。


4. Thumb 地址最低位问题

函数指针或 LR 的最低位可能为 1,用来表示 Thumb 状态。

例如:

LR = 0x080010A9

实际指令地址可能按对齐地址处理:

0x080010A8

可以尝试:

arm-none-eabi-addr2line -f -C -e app.elf 0x080010A8

对于返回地址,有时还需要查看返回地址之前的指令。

例如:

LR - 2
LR - 4

但不能机械地固定减 2 或减 4,需要结合反汇编和具体指令长度判断。


十三、如何在 Keil 中定位地址

如果使用 Keil MDK,可以通过以下方式查询地址。

方法一:反汇编窗口

进入调试模式后打开:

View → Disassembly Window

在地址栏输入:

0x08001234

查看对应汇编指令。


方法二:MAP 文件

打开编译生成的:

工程名.map

搜索接近的地址。

例如 PC 为:

0x08001234

可以查找函数起始地址:

0x08001200

然后根据偏移判断位于哪个函数。


方法三:命令窗口

在 Keil 调试命令窗口中,可以尝试:

disassemble 0x08001234

或者直接在反汇编窗口跳转到对应地址。


十四、如何判断 PC 是否可信

并不是所有 Fault 中的 PC 都能直接指向根因。

PC 可能存在以下情况:

1. 精确总线错误

如果 BFSR 中:

PRECISERR = 1

PC 通常具有较高参考价值。


2. 非精确总线错误

如果:

IMPRECISERR = 1

PC 可能只是 CPU 检测到故障时所在的位置,而不是最初执行错误写操作的位置。

此时需要检查:

  • PC 之前执行过的写操作;
  • DMA 配置;
  • 外设寄存器写入;
  • 指针写入;
  • 写缓冲行为。

3. 返回地址被破坏

如果栈溢出或数组越界破坏了 LR,程序可能在函数返回时才进入 HardFault。

此时 PC 可能指向:

BX LR
POP {..., PC}
异常返回

真正根因则发生在更早的内存破坏位置。


4. 编译优化影响

开启高等级优化后:

-O2
-O3

源码行和指令地址之间不一定是一一对应关系。

可能出现:

  • 多行源码合并;
  • 某些变量被优化掉;
  • 函数内联;
  • 指令重排;
  • 无法直接查看局部变量。

调试疑难 Fault 时,可以临时降低优化等级:

-O0
-Og

但要注意,降低优化等级后,程序的栈占用和执行时序也可能发生变化。


十五、崩溃现场应该保存哪些信息

建议至少保存以下内容:

PC
LR
SP
xPSR
R0
R1
R2
R3
R12
CFSR
HFSR
MMFAR
BFAR
SHCSR

RTOS 项目还建议保存:

当前任务名称
当前任务句柄
任务栈剩余水位
系统 Tick
中断嵌套状态
固件版本
复位原因

有条件时还可以保存:

关键外设状态
最近一段运行日志
最近收到的通信数据
当前状态机状态
错误计数器

十六、崩溃现场保存到哪里

常见方案有:

1. 保留 RAM 区

可以在 RAM 中划分一块不初始化区域:

__attribute__((section(".noinit")))
volatile HardFaultRecord_t g_fault_record;

启动后检查记录是否有效。

优点:

  • 写入速度快;
  • HardFault 中操作简单;
  • 不依赖 Flash 擦除;
  • 对实时性影响小。

缺点:

  • 完全断电后数据丢失;
  • 启动代码不能清零该区域;
  • 需要修改链接脚本。

2. 内部 Flash

可以将故障信息保存到内部 Flash。

优点:

  • 断电后保留;
  • 下次启动可以读取。

缺点:

  • 写 Flash 需要一定时间;
  • 写入前可能需要擦除;
  • 故障状态下 Flash 驱动不一定可靠;
  • 频繁写入会消耗擦写寿命;
  • 如果 Fault 本身与 Flash 有关,写入可能失败。

因此,不建议在 HardFault 中执行复杂 Flash 文件系统操作。

更安全的方案是:

  1. 先将故障记录写入保留 RAM;
  2. 系统复位;
  3. 下次启动后再把记录转存到 Flash。

3. EEPROM 或 FRAM

如果硬件具有独立 EEPROM 或 FRAM,可以保存故障记录。

FRAM 在写入速度和寿命方面通常更适合频繁记录。

但仍应保证:

  • 底层总线可用;
  • 写入函数不会使用动态内存;
  • 不会等待复杂中断;
  • 不会再次触发 Fault。

4. 串口输出

调试阶段可以直接打印:

PC
LR
CFSR
HFSR
MMFAR
BFAR

例如:

printf("PC   = 0x%08lX\r\n", frame->pc);
printf("LR   = 0x%08lX\r\n", frame->lr);
printf("CFSR = 0x%08lX\r\n", SCB->CFSR);
printf("HFSR = 0x%08lX\r\n", SCB->HFSR);
printf("MMFAR= 0x%08lX\r\n", SCB->MMFAR);
printf("BFAR = 0x%08lX\r\n", SCB->BFAR);

但需要注意:

在 HardFault 中调用完整的 printf() 可能不安全。

因为 printf() 可能:

  • 使用较大栈空间;
  • 调用锁;
  • 使用中断;
  • 使用动态内存;
  • 再次访问已经异常的外设。

工程中更推荐:

  • 使用简单轮询串口发送;
  • 使用最小化十六进制输出函数;
  • 或先保存到 RAM,复位后再输出。

十七、一个较完整的 HardFault 记录示例

#include "stm32f4xx.h"
#include <stdint.h>

#define HARDFAULT_MAGIC 0x48465254UL

typedef struct
{
    uint32_t magic;

    uint32_t r0;
    uint32_t r1;
    uint32_t r2;
    uint32_t r3;
    uint32_t r12;
    uint32_t lr;
    uint32_t pc;
    uint32_t xpsr;

    uint32_t msp;
    uint32_t psp;

    uint32_t hfsr;
    uint32_t cfsr;
    uint32_t mmfar;
    uint32_t bfar;
    uint32_t afsr;
    uint32_t shcsr;
} HardFaultRecord_t;

__attribute__((section(".noinit")))
volatile HardFaultRecord_t g_hardfault_record;

void HardFault_Handler_C(uint32_t *stack_address);

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile
    (
        "tst lr, #4            \n"
        "ite eq                \n"
        "mrseq r0, msp         \n"
        "mrsne r0, psp         \n"
        "b HardFault_Handler_C \n"
    );
}

void HardFault_Handler_C(uint32_t *stack_address)
{
    g_hardfault_record.magic = HARDFAULT_MAGIC;

    g_hardfault_record.r0   = stack_address[0];
    g_hardfault_record.r1   = stack_address[1];
    g_hardfault_record.r2   = stack_address[2];
    g_hardfault_record.r3   = stack_address[3];
    g_hardfault_record.r12  = stack_address[4];
    g_hardfault_record.lr   = stack_address[5];
    g_hardfault_record.pc   = stack_address[6];
    g_hardfault_record.xpsr = stack_address[7];

    g_hardfault_record.msp = __get_MSP();
    g_hardfault_record.psp = __get_PSP();

    g_hardfault_record.hfsr  = SCB->HFSR;
    g_hardfault_record.cfsr  = SCB->CFSR;
    g_hardfault_record.mmfar = SCB->MMFAR;
    g_hardfault_record.bfar  = SCB->BFAR;
    g_hardfault_record.afsr  = SCB->AFSR;
    g_hardfault_record.shcsr = SCB->SHCSR;

    __DSB();
    __ISB();

#ifdef DEBUG
    __BKPT(0);
#endif

    NVIC_SystemReset();

    while (1)
    {
    }
}

启动后可以检查:

void HardFault_Record_Check(void)
{
    if (g_hardfault_record.magic == HARDFAULT_MAGIC)
    {
        /*
         * 输出或上传:
         * g_hardfault_record.pc
         * g_hardfault_record.lr
         * g_hardfault_record.cfsr
         * g_hardfault_record.hfsr
         * g_hardfault_record.mmfar
         * g_hardfault_record.bfar
         */

        g_hardfault_record.magic = 0U;
    }
}

需要注意,.noinit 的具体配置方式与编译器、链接脚本有关。


十八、实际排查顺序

遇到 HardFault 后,可以按照下面的顺序排查。

第一步:查看 PC

先确认:

程序到底执行到了哪个地址?

拿到:

PC = 0x0800XXXX

然后使用 ELF 文件反查。


第二步:查看 LR

确认:

当前函数可能从哪里被调用?
函数返回地址是否合理?

如果 LR 是:

0xFFFFFFF9
0xFFFFFFFD

说明查看的是异常处理过程中的 EXC_RETURN。

这时应查看异常堆栈中保存的 LR,而不是处理函数当前 LR。


第三步:读取 CFSR

判断属于:

MemManage
BusFault
UsageFault

例如:

DACCVIOL:数据访问违规
PRECISERR:精确总线错误
UNALIGNED:未对齐访问
DIVBYZERO:除零
INVPC:非法 PC

第四步:检查 MMFAR 和 BFAR

如果对应有效位已经置位,就检查故障地址。

例如:

BFAR = 0x00000004

很可能是空指针加成员偏移:

object->member

如果 object == NULL,而 member 偏移为 4,就可能访问:

0x00000004

如果:

BFAR = 0xDEADBEEF

说明指针很可能已经被异常值覆盖。

如果:

BFAR = 0xA5A5A5A5

需要检查工程是否使用 0xA5 填充未初始化内存或任务栈。


第五步:地址反查源码

执行:

arm-none-eabi-addr2line -f -C -e app.elf 0x08001234

然后结合:

  • PC;
  • LR;
  • 反汇编;
  • 调用链;
  • CFSR;
  • BFAR/MMFAR;
  • 源码上下文。

不要只看单独一条源码。


第六步:检查是否存在更早的内存破坏

如果出错位置看起来没有问题,应重点检查:

  • 数组越界;
  • memcpy() 长度;
  • memset() 长度;
  • 栈溢出;
  • 堆内存破坏;
  • 已释放对象继续使用;
  • DMA 越界;
  • 中断并发;
  • RTOS 任务栈;
  • 错误函数指针。

很多 HardFault 的“爆发位置”并不是真正的“根因位置”。


十九、典型案例分析

案例一:BFAR 为 0x00000008

异常信息:

PC   = 0x08002568
LR   = 0x08002431
CFSR = 0x00008200
BFAR = 0x00000008

BFAR 为:

0x00000008

通常需要怀疑空指针。

例如:

typedef struct
{
    uint32_t id;
    uint32_t state;
    uint32_t value;
} Object_t;

Object_t *obj = NULL;

obj->value = 100;

假设 value 的成员偏移为 8,那么实际访问地址就是:

NULL + 8 = 0x00000008

这与 BFAR 一致。


案例二:PC 跳到 0xA5A5A5A4

异常信息:

PC = 0xA5A5A5A4

这种地址明显不在 Flash 范围。

如果工程使用 0xA5 初始化任务栈,说明可能发生了:

  • 栈溢出;
  • 返回地址被覆盖;
  • 函数指针未初始化;
  • 从未使用的栈空间中读取了返回地址。

应优先检查任务栈水位和大数组。


案例三:CFSR 显示 UNALIGNED

异常信息中 UFSR 的 UNALIGNED 置位。

重点排查:

uint32_t value = *(uint32_t *)&buffer[1];

或者:

Packet_t *packet = (Packet_t *)&buffer[1];

更安全的方式:

Packet_t packet;

memcpy(&packet, &buffer[1], sizeof(packet));

案例四:程序在函数返回时崩溃

反汇编显示 PC 位于:

POP {..., PC}

或者:

BX LR

这时不要只怀疑返回指令。

更可能是:

  • 栈溢出;
  • 局部数组越界;
  • memcpy() 覆盖栈;
  • LR 被提前破坏;
  • 函数栈帧被破坏。

应该检查该函数执行期间所有可能写内存的位置。


二十、工程实践建议

1. 不要只写死循环

默认的:

while (1)
{
}

只能让程序停住,无法提供足够信息。

至少应保存:

PC
LR
CFSR
HFSR
MMFAR
BFAR

2. 调试阶段打开断言

例如:

assert(pointer != NULL);
assert(index < ARRAY_SIZE(buffer));
assert(length <= sizeof(buffer));

断言能在错误刚发生时停止程序,而不是等到内存被破坏后再进入 HardFault。


3. 对关键缓冲区增加边界检查

错误写法:

memcpy(dst, src, length);

更安全的写法:

if ((dst != NULL) &&
    (src != NULL) &&
    (length <= dst_size))
{
    memcpy(dst, src, length);
}

4. RTOS 项目开启栈溢出检测

以 FreeRTOS 为例,可配置:

#define configCHECK_FOR_STACK_OVERFLOW 2

并实现:

void vApplicationStackOverflowHook(TaskHandle_t xTask,
                                   char *pcTaskName)
{
    (void)xTask;
    (void)pcTaskName;

    __BKPT(0);

    while (1)
    {
    }
}

同时定期检查:

uxTaskGetStackHighWaterMark()

5. 为故障记录添加固件版本

故障记录中建议包含:

软件版本
Git 提交号
编译时间
硬件版本
设备编号

否则,即使得到了 PC 地址,也可能因为 ELF 文件版本不一致而无法正确定位。


6. HardFault 中不要执行复杂业务

HardFault 处理函数中应尽量避免:

  • 动态内存分配;
  • 文件系统;
  • 复杂日志系统;
  • 依赖中断的驱动;
  • RTOS 阻塞接口;
  • 长时间延时;
  • 复杂 Flash 擦写。

推荐原则:

读取现场
    ↓
保存最小记录
    ↓
触发断点或复位
    ↓
启动后再进行完整处理

二十一、一眼记住 HardFault 定位顺序

可以把整个排查流程记成下面六步:

1. 先看 PC:程序死在哪里
        ↓
2. 再看 LR / SP:还原调用关系和栈现场
        ↓
3. 读取 CFSR:判断属于哪类 Fault
        ↓
4. 查看 MMFAR / BFAR:确认出错地址
        ↓
5. 使用 addr2line:反查函数和源码行
        ↓
6. 结合日志和上下文:寻找真正根因

HardFault 定位的核心,不是记住更多寄存器名称,而是按照:

异常现场
    ↓
关键寄存器
    ↓
故障类型
    ↓
错误地址
    ↓
源码上下文

的顺序进行排查。


总结

HardFault 并不是无法解决的“玄学故障”。

只要能够正确获取异常堆栈,并分析:

PC
LR
SP
CFSR
HFSR
MMFAR
BFAR

再结合:

ELF 文件
MAP 文件
addr2line
反汇编
调用链
运行日志

通常就能逐步接近真正的故障原因。

工程中最重要的不是让设备在 HardFault 后立刻重启,而是:

在重启之前,尽可能保存完整的崩溃现场。

因为只有留下现场,才能真正解决那些偶发、随机、难以复现的嵌入式故障。


Logo

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

更多推荐