FreeRTOS中断优先级配置不对也会HardFault?这次用一个血的教训讲明白

一个偶发、难复现、看似没有任何规律的HardFault,往往会把人折磨的痛苦不堪,百思不得其解。最终靠JLink一层层扒寄存器,才发现罪魁祸首竟是一个“不起眼”的宏定义。本文用我自己写的demo,把排查全过程完整还原给你看。


一、那个让人崩溃的"幽灵Bug"

事情发生在客户的一个项目上,跑的FreeRTOS,功能本身不复杂,看起来人畜无害,但就是偶尔设备会随机死机。

不是每次都死,往往认为已经没有问题,测试已经通过(大概率测的也不到位),突然就HardFault了。最诡异的是,没有任何规律——有时串口发数据死了,有时空闲状态下也死了。断点打了无数个,log加满,就是抓不住确定性复现条件。

后来客户求助,经过不断尝试终于复现问题,然后就开始了分析流程。接上JLink调试器,从HardFault异常的现场反向推导,才一步步揪出了真凶。


二、“凶手”长什么样?一个精简Demo还原现场


要把原始工程拿出来讲,实在太复杂了。为了让你看清问题的核心,我特意写了一个极简demo,代码连100行都不到,但能100%稳定复现问题。

demo仓库地址:https://gitcode.com/qingshan1206/easy-freertos,这是一个使用QEMU模拟STM32官方开发板学习FreeRTOS的项目,可以使用VS Code在线调试。

这是一个错误配置的FreeRTOS工程,关键代码在 main.c 中:

int main(void)
{
    boardInit();
    //  错误配置:此处为了复现问题,强制 PendSV和SysTick 为优先级 14
    //  当然,客户的项目并不是简单的把优先级设为14了
    SCB->SHP[11] = 0xE0;
    SCB->SHP[10] = 0xE0;

    osKernelInitialize();
    // 创建 Logger 任务:每秒输出一次日志
    osThreadNew(loggerTask, NULL, &attr);
    // 创建 Trigger 任务:延迟 980ms 后软件触发 USART1 中断
    osThreadNew(trigTask, NULL, &attr);
    osKernelStart();
}

在 Trigger 任务中,我手动触发了一个 USART1 中断,并且把它的优先级设为 15(最低)

static void trigTask(void *arg)
{
    HAL_NVIC_SetPriority(USART1_IRQn, 15, 0);
    HAL_NVIC_EnableIRQ(USART1_IRQn);
    osDelay(980);
    NVIC_SetPendingIRQ(USART1_IRQn);  // 手动触发中断
}

而 USART1 的中断服务函数里,我故意写了一个超长的硬延时循环——目的是让中断「执行得久一点」,方便复现问题:

void USART1_IRQHandler(void)
{
    for (volatile uint32_t d = 0; d < 10000000; d++)
        __asm__ volatile("nop");
}

还需要注意,这个工程中 FreeRTOS 的 configLIBRARY_LOWEST_INTERRUPT_PRIORITY 被错误地写死成了 0xE(对应优先级14),而正确的配置应该是 0xF(即优先级15,全局最低)。

这个组合一跑,HardFault 几乎是必现的。


三、CM4中断优先级机制的「冷知识」

在深入排查之前,得先把基础知识补一下。很多人对 Cortex-M4 的中断优先级只有模糊认知,这里用一张图说清楚。
在这里插入图片描述
关键点有三个:

1. 两组寄存器,管两套中断:

  • NVIC 的 IP 寄存器(Interrupt Priority):管理外部中断的优先级。STM32L475 有80多个外部中断,每个占一个字节,只用高4位(bit [7:4]),低4位忽略。USART1 在 IP[37] 的位置。
  • SCB 的 SHPR 寄存器(System Handler Priority):管理系统异常(内核异常)的优先级。包括 SysTick、PendSV、SVC 等。STM32 用的是 configPRIO_BITS=4,只实现了4位优先级(bit [7:4])。

2. 优先级值越小,优先级越高:

这一点反直觉。优先级数值 0 是最高优先级,15 是最低优先级。换句话说,FreeRTOS 为了保证内核安全,明确规定 SysTick 和 PendSV 的优先级必须是全局最低的

3. SHPR 寄存器索引要搞对:

SHPR 索引 系统异常 说明
SHP[10] SysTick (异常号15) 操作系统时基
SHP[11] PendSV (异常号14) 上下文切换

那么在我们这个demo里是什么样的配置呢?
在这里插入图片描述
一目了然:SysTick 和 PendSV 的优先级是14,而 USART1 的优先级是15。这意味着内核中断反而比外设中断「优先级更高」,它们可以随时打断 USART1 的中断服务程序。这是一个致命的配置错误。


四、JLink上阵,寄存器一层层扒

好了,理论储备够了。实际跑起demo,连上JLink,让我们进入「破案」环节。

HardFault 发生后,CPU停在 HardFault_Handler 里。现场的关键寄存器值如下(为了方便展示,我写了一个 faultDebug() 函数把寄存器全部dump出来):

hfsr       : 0x40000000
cfsr       : 0x00040000
dfsr       : 0x00000000
bfar       : 0x00000000
mmfar      : 0x00000000
exc_return : 0xFFFFFFED

在这里插入图片描述

第一层:HFSR 告诉我们什么?

HFSR(HardFault Status Register)= 0x40000000,bit30 = 1。

bit30 叫 FORCED。当它为 1 时,说明这个 HardFault 不是直接触发的,而是由一个低优先级的异常(总线异常、用法异常或内存管理异常)升级上来的。这意味着真凶另有其人——我们需要继续查 CFSR。

第二层:从 CFSR 到 INVPC

CFSR(Configurable Fault Status Register)= 0x00040000。其他几个异常状态寄存器(DFSR、BFAR、MMFAR)全是 0,只有 CFSR 有异常信息。

CFSR 的第18 bit 为 1,对应的错误类型是 INVPC(Invalid PC Load,非法PC加载)。

简单说:CPU 在执行中断返回指令时,试图把某个非法值加载到 PC 寄存器里。
在这里插入图片描述

第三层:INVPC 的三种可能原因

出现 INVPC 异常,常见原因只有三种:

  1. 指令集状态错误 —— Cortex-M4 只支持 Thumb 指令集,如果 T 位(xPSR bit24)为 0 则表示 ARM 态,会触发此异常。
  2. 栈中的 PC 被破坏 —— 返回地址被栈溢出、野指针等篡改,加载了非法值。
  3. EXC_RETURN 值非法 —— 中断返回时,lr 寄存器的值(EXC_RETURN)不符合 ARM 规范。

接下来逐一排查,排查的思路如下图:
在这里插入图片描述

排查①:指令集状态 —— 排除

读取 xPSR 寄存器 = 0x61010003。bit24 = 1,表示 CPU 正处于 Thumb 状态。指令集本身没问题,排除第一条。
在这里插入图片描述

排查②:PC 值是否合法 —— 排除

lr = 0xFFFFFFED

EXC_RETURN 是一个特殊值,0xFFFFFFED 的解读如下:

  • bit3 = 0 → 返回线程模式(不是 Handler 模式)
  • bit2 = 1 → 使用 PSP(进程栈指针),不是 MSP
  • bit4 = 1 → 使用了浮点运算单元(FPU),栈帧带 FP 扩展

这说明异常发生前,CPU 处于线程模式,使用的是 PSP 栈,且开启了 FPU。于是我们通过 PSP 取出异常前的栈帧,查看里面的 PC 值:
在这里插入图片描述

PSP 栈帧中的 PC = 0x08003D16

对比反汇编文件,0x08003D16 正好落在 Flash 地址范围内(0x08000000 ~ 0x080FFFFF),对应的指令也是正常的 Thumb 代码。PC 值没有被破坏。

排除第二条。

排查③:EXC_RETURN 是否非法 —— 命中!

前面说了,lr = 0xFFFFFFED 表示 HardFault 前 CPU 即将返回到线程模式

那当时有没有其他中断正在活跃状态?如果有,ARM 规范明确规定:默认情况下,不允许在有活跃中断时返回线程模式。强制返回就会触发 INVPC 异常。

查 NVIC 的 IABR(Interrupt Active Bit Register)寄存器:
在这里插入图片描述

IABR[1] = 0x20  (bit5 = 1)

IABR[0] 表示外部中断 0-31 的活跃状态,IABR[1] 表示外部中断 32~63。IABR[1] 的第5位为1,对应中断号 32 + 5 = 37

查代码,37 号外部中断正是 USART1_IRQn

为了交叉验证,我还看了 MSP 栈帧中的 xPSR,其最低 9 bit 显示中断号为 0x35,减去内核异常的偏移 0x10,得到外部中断号 0x25 = 37。和 IABR 查到的完全一致。

结论:HardFault 发生前,USART1 中断正处于活跃状态。而 lr=0xFFFFFFED 却要求返回线程模式。这是个非法的操作——ARM 不允许这么做。INVPC 异常由此触发!


五、真相大白:PendSV 抢跑了

最后一个问题:USART1 中断正在执行期间,是谁在试图返回线程模式

只有一种可能:有一个优先级更高的中断打断了 USART1,并且在其中执行了上下文切换,试图回到线程模式,那么可以肯定的是,它的中断优先级肯定比USART1高。

通过查询 NVIC 的 ISER 寄存器,确认当前只有 USART1 是使能的外部中断。

那么能打断 USART1 的,还可能来自内核异常。继续查 SCB 的 SHPR 寄存器:
在这里插入图片描述

SHPR[10] = 0xE0  →  SysTick 优先级 = (0xE0 >> 4) = 14
SHPR[11] = 0xE0  →  PendSV  优先级 = (0xE0 >> 4) = 14

再核对 USART1 的优先级:
在这里插入图片描述

IP[36] = 0xF0  →  USART1 优先级 = (0xF0 >> 4) = 15

SysTick/PendSV 优先级(14) > USART1 优先级(15)

为了避免误判,我还专门确认了 ARM 的 CCR 寄存器的 NONBASETHRDENA 位(bit0)为 0,即没有使能「允许在活跃中断时返回线程模式」的特性,使用的是默认规则。
在这里插入图片描述
现在把整条故障链串起来,时间线是这样的:

  1. Logger 任务运行中,调用了 osDelay(1000),进入阻塞态。
  2. Trigger 任务在 980ms 时通过 NVIC_SetPendingIRQ 触发 USART1 中断。
  3. SysTick 定时到达 1000ms,Logger 延时到期,PendSV 被挂起(等待切换上下文)。
  4. USART1 中断刚进入,正在执行硬延时循环。
  5. PendSV(优先级14)抢断 USART1(优先级15),开始执行上下文切换。
  6. PendSV 完成切换后,准备返回线程模式。此时 lr = 0xFFFFFFED
  7. ARM 硬件检查发现:USART1 中断仍处于活跃状态!不允许返回线程模式!
  8. 触发 INVPC UsageFault → 升级为 HardFault。
    在这里插入图片描述

罪魁祸首找到了:configLIBRARY_LOWEST_INTERRUPT_PRIORITY 被错误配置为 0xE(优先级14),而不是最低优先级 0xF(优先级15)。

修复只需一行改动:

// ❌ 错误(Priority = 14,不是最低)
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY  ( 0xE << (8 - configPRIO_BITS) )

// ✅ 正确(Priority = 15,真正的全局最低)
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY  ( 0xF << (8 - configPRIO_BITS) )

六、回头看看,三个血泪教训

这个bug实际上是个低级错误,但是在不明原因的前提下能摸清楚来龙去脉确实需要下一些功夫,虽然最终的修复只有一行宏定义,但排查过程远比修复本身更重要。总结三个教训,希望你不要再踩同一个坑。

教训一:FreeRTOS 的中断优先级配置不是「建议」,是「铁律」

FreeRTOS 官方文档反复强调:SysTick 和 PendSV 的优先级必须设为最低

在 Cortex-M 架构上,中断优先级数值越小越高。FreeRTOS 使用 PendSV 来做上下文切换,如果 PendSV 的优先级不是最低,它就可能「抢跑」——在某个外设中断还没处理完的情况下强行切换任务,然后触发 ARM 保护机制。

如果 SysTick/PendSV 不是全局最低优先级,FreeRTOS 的上下文切换就是不安全的。

教训二:学会用寄存器「反向推导」

HardFault 不可怕,可怕的是不知道怎么看现场。

这次排查之所以能成功,核心就在于:不是瞎猜,而是从一个寄存器出发,逐层推导。HFSR → CFSR → INVPC → 三种可能原因 → 逐条验证 → 锁定 EXC_RETURN → IABR 确认活跃中断 → SHPR 确认优先级错配。

这就像一个侦探顺着案发现场的物证一步步还原犯罪过程。只要你把 ARM Cortex-M 的异常模型理解透彻,HardFault 就不再是「玄学」。

教训三:写 Demo 是隔离问题的最好方式

实际工程里的代码几千行,任务十几个,中断好几个,Bug 的排查难度指数级上升。

遇到诡异问题时,最好的办法就是写一个最小化的 demo,只保留能复现问题的核心逻辑,把其他干扰全部排除。就像本文的 05_err_kernel_prio 工程,不到100行代码,把问题本质暴露得清清楚楚。


附:Demo 源码

本文的完整 demo 代码可以在 easy-freertos 仓库的 projects/05_err_kernel_prio/ 目录下找到。你可以直接编译运行,在 QEMU 中复现这个 HardFault:

cmake -S . -B build -DBUILD_PROJECT=05_err_kernel_prio
cmake --build build

可以使用VS Code进行在线调试,调试的方法参见以前的文章告别硬件开发板,手把手教你零成本学习 FreeRTOS

你踩过什么中断优先级相关的坑吗?欢迎在评论区分享你的血泪史。

posted @ 2026-07-26 17:08  青衫嵌入式  阅读(0)  评论(0)    收藏  举报