抓鬼代码

STM32 HardFault 自动打印故障现场代码(嵌入式抓鬼代码)

一、前言

1. 痛点说明

STM32 嵌入式开发中,空指针访问、数组越界、内存踩踏、非法地址访问等问题,都会触发HardFault硬件硬故障

工程启动文件 startup_stm32xxx.s 默认自带空的 HardFault_Handler 死循环,故障发生后程序直接卡死,无任何报错信息,无法定位崩溃代码行,排查难度极大。

2. 方案作用

重写 HardFault 中断服务函数,自动抓取故障发生时刻CPU寄存器、栈帧、崩溃指令地址,通过串口打印完整故障现场,无需在线仿真,脱机也能精准定位死机代码行

3. 编译器适配范围

  • 完全兼容:ARM GCC、Keil MDK AC6、STM32CubeIDE(主流编译器,本文代码直接复用)

  • 兼容修改:老旧Keil MDK AC5,汇编函数语法需要微调

4. 使用前提

工程已完成printf串口重定向,保证串口可以正常输出日志。


二、完整可直接工程代码

可放置于 main.c 文件末尾,或单独新建 debug_fault.c 文件添加至工程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdio.h>
#include <stdint.h>

/**
* @brief HardFault故障C层处理函数
* @param fault_args: 栈指针地址,保存故障发生时8个内核寄存器
* @retval 无
* @note 栈内寄存器固定顺序:R0、R1、R2、R3、R12、LR、PC、xPSR
*/
void hard_fault_handler_c(uint32_t *fault_args) {
// 打印完整HardFault故障现场
printf("\r\n==================== HardFault 致命错误报告 ====================\r\n");
printf("R0 = 0x%08lX\r\n", fault_args[0]);
printf("R1 = 0x%08lX\r\n", fault_args[1]);
printf("R2 = 0x%08lX\r\n", fault_args[2]);
printf("R3 = 0x%08lX\r\n", fault_args[3]);
printf("R12 = 0x%08lX\r\n", fault_args[4]);
printf("LR = 0x%08lX (异常发生前函数返回地址)\r\n", fault_args[5]);
printf("PC = 0x%08lX (【核心】崩溃指令所在代码地址)\r\n", fault_args[6]);
printf("PSR = 0x%08lX (程序状态寄存器)\r\n", fault_args[7]);
printf("================================================================\r\n");
printf("故障排查提示:使用PC地址可直接定位死机代码行\r\n\r\n");

// 死循环锁定故障现场,防止程序继续跑飞
while (1)
{
// 可拓展:添加LED闪烁,硬件直观提示设备发生硬故障
}
}

/**
* @brief HardFault中断服务函数(纯汇编)
* @note __attribute__((naked)):编译器不自动生成栈保护、返回指令,纯手写汇编
* 自动识别当前使用堆栈:主栈MSP / 进程栈PSP
*/
__attribute__((naked)) void HardFault_Handler(void) {
__asm volatile (
"TST LR, #4 \n" // 检测EXC_RETURN寄存器Bit2位
"ITE EQ \n" // 条件判断:相等/不相等分支
"MRSEQ R0, MSP \n" // Bit2=0:故障前使用主堆栈MSP,读取MSP存入R0
"MRSNE R0, PSP \n" // Bit2=1:故障前使用进程堆栈PSP,读取PSP存入R0
"B hard_fault_handler_c \n" // 跳转到C故障处理函数,R0作为函数入参
);
}


三、代码分层逻辑说明

1. 汇编层:案发现场保护(HardFault_Handler)

异常进入瞬间,CPU会自动压栈8个内核寄存器,第一步核心任务:判断故障发生时CPU使用的堆栈类型,取出栈顶地址

2. C语言层:案发现场解析(hard_fault_handler_c)

接收汇编传递的栈指针,按照固定偏移读取栈内保存的寄存器数值,串口打印全部现场信息,其中PC寄存器地址为定位BUG的核心关键


四、核心底层原理(必懂知识点)

1. TST LR, #4 汇编指令原理

  • HardFault异常触发后,LR寄存器会被赋值为EXC_RETURN特殊返回值

  • #4 二进制:0100,对应寄存器Bit2位

  • 判断规则:

    • Bit2 = 0:异常发生前,CPU使用主堆栈 MSP(内核中断默认堆栈)

    • Bit2 = 1:异常发生前,CPU使用进程堆栈 PSP(RTOS任务运行时堆栈)

2. R0寄存器传参规则(ARM ATPCS调用规范)

ARM Cortex-M内核函数调用约定:函数第一个形参,固定通过R0寄存器传递

汇编中将MSP/PSP栈指针存入R0,直接跳转C函数后,uint32_t *fault_args 指针直接接管栈顶地址,无需额外传参。

3. 栈帧寄存器偏移规则

Cortex-M内核进入异常时,自动向下压栈8个寄存器,数组下标一一对应,固定不变:

数组下标 寄存器 作用说明
fault_args[0] R0 通用寄存器
fault_args[1] R1 通用寄存器
fault_args[2] R2 通用寄存器
fault_args[3] R3 通用寄存器
fault_args[4] R12 过程调用临时寄存器
fault_args[5] LR 异常前函数返回地址
fault_args[6] PC 崩溃代码指令地址(核心)
fault_args[7] xPSR 程序状态字寄存器

五、根据PC地址定位崩溃代码(两种方法)

假设串口打印崩溃地址:PC = 0x08001A4C

方法1:Keil MDK 在线仿真定位(最直观)

  1. 进入Keil调试模式

  2. 顶部菜单栏:View → Disassembly Window 打开反汇编窗口

  3. 反汇编窗口右键,勾选 Show Code 显示对应C代码

  4. 地址栏输入串口打印的PC地址,回车,光标直接跳转至崩溃代码行

方法2:GCC addr2line 脱机定位(无仿真器也能用)

适用场景:STM32CubeIDE、GCC编译器,无仿真器、量产设备死机排查

  1. 找到工程编译生成的 .elf 可执行文件

  2. 打开CMD终端,输入以下命令:

1
2
arm-none-eabi-addr2line -e 你的工程文件.elf 0x08001A4C

终端直接输出结果:main.c:142,精准告知崩溃文件与代码行数。


六、老旧AC5编译器适配说明

当前主流开发基本淘汰AC5编译器,若必须使用Keil AC5,汇编中断函数需要修改函数声明,其余代码无需改动:

1
2
3
4
5
6
7
8
// AC5 专用写法
__asm void HardFault_Handler(void) {
TST LR, #4
ITE EQ
MRSEQ R0, MSP
MRSNE R0, PSP
B hard_fault_handler_c
}

七、常见HardFault故障根源汇总

  • 野指针:指针未初始化直接解引用

  • 数组越界:访问数组下标超出定义范围

  • 栈溢出:局部变量过大、递归死循环导致栈空间耗尽

  • 非法地址访问:操作外设未开启时钟、访问保留内存地址

  • RTOS场景:任务堆栈过小,任务运行时栈溢出


八、使用注意事项

  1. 该函数会覆盖启动文件默认的HardFault中断,无需修改.s启动文件

  2. 必须保证printf串口重定向正常,否则无法打印故障日志

  3. 故障发生后程序进入死循环,上电复位即可恢复

  4. 可在while死循环内添加LED指示灯,硬件直观提示故障

(注:部分内容可能由 AI 生成)