# 单片机进入 HardFault 后,如何找到真正出错的代码?——STM32/Cortex-M 故障定位实战
单片机进入 HardFault 后,如何找到真正出错的代码?——STM32/Cortex-M 故障定位实战
在 STM32 或其他 Cortex-M 单片机开发中,很多工程师都遇到过类似问题:
- 程序运行一段时间后突然跑飞;
- 系统一上电就复位;
- 调试器停在
HardFault_Handler(); - 程序卡死在死循环中;
- 重新上电后故障消失,现场无法复现;
- 明明停在 HardFault 中,却不知道真正出错的是哪一行代码。
HardFault 并不等于“单片机随机死机”。
它实际上是 Cortex-M 内核在检测到严重异常后,主动进入的一种故障处理流程。只要能够正确读取异常现场、分析状态寄存器,并将程序地址反查到源码,就有机会找到真正的故障位置。
本文将系统介绍:
- HardFault 是什么;
- 常见的触发原因;
- PC、LR、SP 分别有什么作用;
- 如何读取 Fault 状态寄存器;
- 如何根据地址反查代码行;
- 如何保存崩溃现场;
- 一套可直接使用的 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;
这里的 device 为 NULL,其值通常为:
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 文件系统操作。
更安全的方案是:
- 先将故障记录写入保留 RAM;
- 系统复位;
- 下次启动后再把记录转存到 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 后立刻重启,而是:
在重启之前,尽可能保存完整的崩溃现场。
因为只有留下现场,才能真正解决那些偶发、随机、难以复现的嵌入式故障。
更多推荐


所有评论(0)