写 PC 程序时,出错会有异常堆栈告诉你第几行错了。而单片机出错的典型表现是:
HardFault_Handler 的函数里,里面只有一个 while(1);这时候大多数初学者的反应是一行行注释代码去试,非常痛苦。但实际上,Cortex-M 内核在跳进 HardFault 之前,已经自动把现场证据保存好了——包括「崩溃时正在执行哪条指令」。你要做的只是学会读它。
Cortex-M 有三类可配置故障,它们更具体、更好定位:
| 故障类型 | 触发原因(举例) | 异常号 |
|---|---|---|
| MemManage | MPU 区域违规、往只读区写数据、在禁止执行区取指 | 4 |
| BusFault | 访问不存在的外设地址、DMA 地址越界、未上电的外设 | 5 |
| UsageFault | 除零、非对齐访问、未定义指令、切换到 ARM 状态 | 6 |
关键在于:默认情况下这三类故障都是关闭的(HAL 库初始化不会帮你打开)。所以当它们发生时,因为没有专门的 handler,就会「升级」成 HardFault。HardFault 优先级固定为 -1,高于所有可屏蔽中断,所以它一定能被进入。
CFSR 读出来——它会告诉你这到底是「除零」「总线错」还是「MPU 违规」,排查方向立刻从「大海捞针」缩小到「一条街」。进入异常前,CPU 会自动把 8 个 32 位寄存器按顺序压入当前使用的栈。这 8 个字就是破案的关键,尤其第 7 个字——PC(程序计数器),它记录着「崩溃时正要执行的那条指令的地址」。
34 12 00 08,实际地址是 0x08001234——字节要反着读。Cortex-M 有两个栈指针:MSP(主栈,裸机和中断默认用)和 PSP(进程栈,RTOS 的任务用)。要读栈帧,先得知道压在哪个栈里。
答案藏在 LR(R14) 里。异常发生时 LR 不是普通地址,而是一个叫 EXC_RETURN 的特殊编码:
| LR 的值 | 含义 | 你该去看 |
|---|---|---|
0xFFFFFFF9 | 异常前用主栈(裸机 / 中断里) | MSP 寄存器 |
0xFFFFFFFD | 异常前用进程栈(RTOS 任务里) | PSP 寄存器 |
0xFFFFFFF1 | 从 Handler 模式返回(嵌套中断) | MSP 寄存器 |
0xFFFFFFE9 / ED | 同上,但带浮点上下文(M4F/M33) | 对应栈,栈帧多 18 个字 |
9 用 MSP,是 D 用 PSP。不用背完整数值。拿到栈指针(假设 MSP = 0x20004F00)后,打开 Memory 窗口输入这个地址,往下数:
MSP + 24(= 0x20004F18)处的 4 个字节 → PC(崩溃指令地址)MSP + 20(= 0x20004F14)处的 4 个字节 → LR(谁调用了它)拿到 PC 值(比如 0x08001234)后,有三种办法翻译成源码位置:
| 方法 | 操作 | 适用场景 |
|---|---|---|
| ① 反汇编跳转 | Keil/CubeIDE 里 Show Disassembly at Address,输入 PC 值 | 最快,IDE 会直接高亮对应 C 行 |
| ② map 文件 | 打开编译生成的 .map,搜索最接近且小于 PC 的函数地址 | 没有调试器时 |
| ③ addr2line | arm-none-eabi-addr2line -e firmware.elf 0x08001234 | GCC 工具链,直接输出 main.c:128 |
-O2 下函数被内联、栈帧消失,回溯会不准。排查 HardFault 时,先把优化调到 -O0 复现一次,定位后再改回去。PC 告诉你在哪一行,CFSR 告诉你错的是什么类型。CFSR(地址 0xE000ED28)是三个子寄存器拼起来的 32 位:
配套的还有几个寄存器,一起读出来更完整:
| 寄存器 | 地址 | 看它能知道什么 |
|---|---|---|
HFSR | 0xE000ED2C | bit30 FORCED=1 表示由其他故障升级而来;bit1 VECTTBL 表示向量表读取出错 |
BFAR | 0xE000ED38 | 出错的具体地址(仅当 CFSR bit15 = 1 时有效) |
MMFAR | 0xE000ED34 | MPU 违规地址(仅当 CFSR bit7 = 1 时有效) |
SCB->CFSR |= SCB->CFSR;。否则旧标志会和新故障混在一起,把你带偏。每次手动翻内存太累。下面这段代码让 HardFault 发生时自动把关键寄存器打印到串口(或存起来),复位后也能看到。
① 先判断用的是 MSP 还是 PSP(一小段汇编,最值钱的三行):
/* stm32fxxx_it.c —— 替换默认的 HardFault_Handler */ __attribute__((naked)) void HardFault_Handler(void) { /* naked:不要编译器生成函数头,否则会破坏 SP */ __asm volatile( "TST LR, #4 \n" /* 测 LR 的 bit2 */ "ITE EQ \n" /* If-Then-Else */ "MRSEQ R0, MSP \n" /* bit2=0 → 用 MSP */ "MRSNE R0, PSP \n" /* bit2=1 → 用 PSP */ "B hardfault_c \n" /* R0 = 栈帧首地址 */ ); }
② 用 C 函数把现场读出来并打印:
void hardfault_c(uint32_t *stack) { volatile uint32_t r0 = stack[0]; volatile uint32_t r1 = stack[1]; volatile uint32_t r2 = stack[2]; volatile uint32_t r3 = stack[3]; volatile uint32_t r12 = stack[4]; volatile uint32_t lr = stack[5]; /* 调用者返回地址 */ volatile uint32_t pc = stack[6]; /* ★ 崩溃指令地址 */ volatile uint32_t psr = stack[7]; volatile uint32_t cfsr = *(volatile uint32_t *)0xE000ED28; volatile uint32_t hfsr = *(volatile uint32_t *)0xE000ED2C; volatile uint32_t bfar = *(volatile uint32_t *)0xE000ED38; volatile uint32_t mmfar = *(volatile uint32_t *)0xE000ED34; printf("\r\n===== HardFault 现场 =====\r\n"); printf("PC = 0x%08lX <- 用 addr2line 查这一行\r\n", pc); printf("LR = 0x%08lX\r\n", lr); printf("CFSR= 0x%08lX HFSR= 0x%08lX\r\n", cfsr, hfsr); printf("BFAR= 0x%08lX MMFAR= 0x%08lX\r\n", bfar, mmfar); /* 清掉黏滞标志,避免影响下次判断 */ *(volatile uint32_t *)0xE000ED28 |= cfsr; __asm volatile("BKPT #0"); /* 有调试器就停在这里 */ while (1); }
-O2 下,这些变量只写不读,编译器会认为没用而优化掉,调试器里就看不到值了。volatile 强制它们真实存在于栈上。uint32_t *p = NULL; *p = 0x1234; /* 往地址 0 写数据 */
现场:CFSR 的 DACCVIOL(bit1) 置位,MMFAR = 0x00000000。
结论:往零地址写数据,检查指针是否初始化、函数是否返回了 NULL。
void task(void) { char buf[8192]; /* 8 KB 局部变量,栈只有 1~4 KB */ memset(buf, 0, sizeof(buf)); }
现场:CFSR 的 STKERR(bit12) 或 MSTKERR(bit4) 置位;或者 PC 指向一个完全不合理的地址(因为返回地址已被踩坏)。
结论:大数组改 static 或全局 / 用 malloc;调大启动文件里的 Stack_Size。
/* 忘了 __HAL_RCC_GPIOA_CLK_ENABLE(); 就去读 GPIOA */ uint32_t v = GPIOA->IDR; /* 外设时钟没开 → 总线无应答 */
现场:CFSR 的 PRECISERR(bit9) 或 IBUSERR(bit8) 置位,BFAR 指向该外设寄存器地址。
结论:先开时钟,再用外设——这是 STM32 新手最常踩的坑之一。
SCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk | SCB_SHCSR_BUSFAULTENA_Msk | SCB_SHCSR_MEMFAULTENA_Msk;0xA5A5A5A5)填充未使用栈区,定期检查底部是否被改写。malloc 返回值和函数返回的句柄。.noinit 段或备份寄存器,复位后仍能读出。D,说明异常前运行在使用 PSP 的上下文,通常是 RTOS 任务中。