FreeRTOS中断管理
中断是 MCU 的硬件机制。STM32 通过 NVIC 管理外设中断,每个中断对应一个中断服务例程,也就是 ISR。以 STM32F4 这类 Cortex-M4 为例,中断优先级数字越小,硬件优先级越高;数字越大,硬件优先级越低。
FreeRTOS 运行以后,任务调度和中断并不是两套互不相关的东西。任务切换依赖 SysTick、PendSV 这些异常,中断里也经常需要释放信号量、写队列、发送任务通知,把外设事件交给任务处理。因此中断优先级和 FreeRTOS API 的调用规则需要先明确。
一、STM32 中断优先级
STM32 的中断由 NVIC 管理。优先级通常分成抢占优先级和子优先级,抢占优先级决定能不能互相嵌套,子优先级用于同抢占优先级下的响应顺序。
在 FreeRTOS 工程里,一般会把优先级分组配置成所有有效位都用于抢占优先级,比如 STM32 HAL 里常见的 NVIC_PRIORITYGROUP_4。这样 FreeRTOS 用 BASEPRI 屏蔽中断时,判断逻辑比较直接。以 STM32F4 常见的 4 个有效优先级位为例,中断优先级数值范围是 0 到 15,其中 0 最高,15 最低。
这里要注意一个方向相反的地方:FreeRTOS 任务优先级是数字越大优先级越高,而 Cortex-M 中断优先级是数字越小优先级越高。两者不要混在一起理解。
如果工程里有多个外设中断,可以先按“是否需要调用 FreeRTOS API”分一遍。比如电机保护类中断只需要关 PWM、清标志,不需要发队列,可以放在更高硬件优先级;串口接收、DMA 完成、ADC 半满/全满这类中断一般要通知任务处理数据,就要放在 FreeRTOS 允许调用 API 的优先级范围内。
STM32 HAL 里设置中断优先级大致是这个形式:
1 | HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); |
如果 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 是 5,那么上面的 USART1_IRQn 可以在 ISR 中调用 FromISR API。若写成 HAL_NVIC_SetPriority(USART1_IRQn, 3, 0),这个中断硬件优先级更高,但 ISR 中就不能再调用 FreeRTOS API。
二、FreeRTOS 相关配置
CubeMX 里配置 FreeRTOS 时,和中断关系最密切的是这两个参数。
configLIBRARY_LOWEST_INTERRUPT_PRIORITY
这个参数表示 MCU 可配置的最低中断优先级数值。STM32F4 常见配置下是 15。FreeRTOS 通常会把 SysTick 和 PendSV 设置到最低优先级,让任务切换不影响更紧急的外设中断。
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY
这个参数表示“允许调用 FreeRTOS 中断安全 API 的最高硬件优先级”。常见默认值是 5。由于 Cortex-M 的优先级数字越小越高,所以含义是:
优先级数值 0 到 4 的中断优先级更高,不受 FreeRTOS 的临界段屏蔽,ISR 中不能调用任何 FreeRTOS API。
优先级数值 5 到 15 的中断属于 FreeRTOS 可管理范围,ISR 中可以调用带 FromISR 后缀的中断安全 API。
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 不能设置为 0。如果设成 0,就相当于把最高优先级中断也放进 FreeRTOS 可屏蔽范围,这和 Cortex-M 端口的设计不匹配,容易直接触发断言或产生很隐蔽的问题。
FreeRTOSConfig.h 里还可能看到 configMAX_SYSCALL_INTERRUPT_PRIORITY。这个宏通常是给内核底层用的,数值已经左移到 Cortex-M 寄存器需要的位置;configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 更接近用户在 HAL/CubeMX 里看到的优先级数字。平时配置外设中断时,重点看后者,不要把两个宏的数值直接混用。
常见配置大致如下:
1 |
configLIBRARY_... 这一组是给用户配置时看的数字,和 HAL_NVIC_SetPriority() 里填的优先级更接近。没有 LIBRARY 的那一组已经移位到寄存器格式,FreeRTOS 内核自己会用。手动改配置时,最怕把 5 和 5 << 4 混着写。
三、可屏蔽中断和不可屏蔽中断
这里说的“可屏蔽中断”,不是硬件上的 NMI。它指的是 FreeRTOS 通过 BASEPRI 能够临时屏蔽的一部分普通中断。
以 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5 为例:
FreeRTOS 不可屏蔽中断:优先级数值为
0到4。这些中断优先级高,不能被 FreeRTOS 临界段屏蔽,所以 ISR 中不能调用 FreeRTOS API,包括带FromISR后缀的 API。FreeRTOS 可屏蔽中断:优先级数值为
5到15。这些中断可以被 FreeRTOS 临界段临时屏蔽,ISR 中允许调用xQueueSendFromISR()、xSemaphoreGiveFromISR()、vTaskNotifyGiveFromISR()这类中断安全 API。
实际分配中断优先级时,可以按这个思路处理:实时性要求很高、只操作硬件寄存器、不需要通知任务的中断,可以放在 0 到 4;需要和任务通信的外设中断,例如串口接收、DMA 完成、ADC 采样完成,通常放在 5 到 15,并且只调用 FromISR 版本的 API。
判断一个中断应该放在哪个范围,可以直接问两个问题:
ISR 里是否要调用队列、信号量、事件组、任务通知等 FreeRTOS API。
ISR 是否必须在 FreeRTOS 临界段期间也保持响应。
如果答案是“要调用 FreeRTOS API”,中断优先级必须放在 FreeRTOS 可管理范围内。如果答案是“必须始终保持最高响应”,那就不要在这个 ISR 里调用 FreeRTOS API,只做硬件级处理。
四、普通 API 和 FromISR API
任务上下文和中断上下文使用的 API 不一样。
普通 API 用在任务函数里,例如:
1 | vTaskDelay(pdMS_TO_TICKS(10)); |
这些函数可能阻塞当前任务,也可能直接触发调度器处理任务状态。ISR 不是任务,不能阻塞等待,也没有任务栈切换的语义,所以不能在中断里调用这些普通 API。
中断里要使用带 FromISR 后缀的函数,例如:
1 | xQueueSendFromISR(queueHandle, &data, &xHigherPriorityTaskWoken); |
这类函数不会阻塞 ISR。它们内部会用适合中断上下文的方式修改内核对象,并通过 pxHigherPriorityTaskWoken 告诉调用者:这次操作是否唤醒了一个比当前被中断任务优先级更高的任务。
典型写法如下:
1 | void USART1_IRQHandler(void) |
如果 xHigherPriorityTaskWoken 被置为 pdTRUE,portYIELD_FROM_ISR() 会请求在 ISR 退出后进行一次上下文切换。注意这里不是在 ISR 中直接运行任务,而是在中断退出路径上切到更合适的任务。
实际使用时,xHigherPriorityTaskWoken 通常在 ISR 开头初始化为 pdFALSE。如果一个 ISR 里连续调用了多个 FromISR API,可以共用同一个变量,只要最后统一调用一次 portYIELD_FROM_ISR(xHigherPriorityTaskWoken)。这个变量的含义不是“是否有任务被唤醒”,而是“是否有更高优先级任务需要立刻运行”。如果唤醒的是同优先级或更低优先级任务,它不一定会被置为 pdTRUE。
任务侧一般配合阻塞式等待。例如串口接收任务可以一直阻塞在队列上:
1 | void UartRxTask(void *argument) |
这样 ISR 只负责把字节放进队列,解析协议、处理字符串、刷新状态都在任务里完成。这个模式比在中断里直接做完整业务处理稳定得多。
再看一个按键外部中断释放二值信号量的例子。这个场景不关心按键值本身,只关心“按键事件发生了”,所以二值信号量比较合适。
1 | static SemaphoreHandle_t keySemHandle; |
1 | void EXTI0_IRQHandler(void) |
这个例子里,ISR 不做按键业务判断,只负责清中断标志并释放信号量。按键消抖可以放在任务里处理,也可以由定时器或状态机处理,尽量不要在 EXTI 中断里 HAL_Delay()。keySemHandle 必须在中断启用前创建好,否则中断提前触发时会拿到空句柄。
CreateSyncObjects() 可以放在创建任务之前调用,或者放在系统初始化阶段调用。关键点是:先创建同步对象,再启用对应的外部中断。
五、ISR 和任务调度
只要 CPU 正在执行 ISR,普通任务代码就不会同时运行。即使是优先级最高的任务,也不能抢占硬件中断。任务优先级只在任务之间比较,中断优先级由 NVIC 决定。
因此 ISR 执行时间会直接影响系统响应。如果一个中断函数里做滤波、打印、复杂计算,任务调度就会被推迟。实际写法一般是 ISR 只做必要动作:
读取或写入外设寄存器。
清除中断标志。
把数据放入缓冲区,或者通过队列、信号量、任务通知唤醒任务。
退出 ISR,让任务完成耗时处理。
比如 ADC 中断里只把采样值放入缓冲区,缓冲区满后释放一个信号量;滤波、频谱计算、显示更新都放到任务里做。
一个常见流程是:
DMA 或 ADC 中断到来。
ISR 判断是半缓冲完成还是全缓冲完成。
ISR 只记录当前可用缓冲区编号,然后释放二值信号量或发送任务通知。
数据处理任务被唤醒,读取对应缓冲区,完成滤波、均值、显示或上传。
这样写的好处是 ISR 时间比较固定,任务处理时间即使长一点,也不会一直占着中断上下文。需要注意的是,缓冲区所有权要明确:ISR 写哪个缓冲区,任务读哪个缓冲区,不能让任务还没处理完时 ISR 又覆盖同一块数据。
如果只需要通知任务“有一批新数据可以处理”,任务通知比信号量和队列更轻。下面是 ADC DMA 完成回调通知任务的写法。HAL 的这些回调函数是在中断上下文里被调用的,所以仍然要使用 FromISR API。
1 |
|
任务侧用 ulTaskNotifyTake() 等通知:
1 | void AdcProcessTask(void *argument) |
pdTRUE 表示退出时把通知值清零,更接近二值信号量的用法。如果希望记录多次通知次数,可以用 pdFALSE,每次退出时只把通知值减一。
这个例子里把 HAL_ADC_Start_DMA() 放在任务句柄赋值之后,是为了避免 DMA 中断先到、回调里 adcTaskHandle 还是空值。实际工程中也可以在系统初始化阶段启动 DMA,但要保证任务句柄已经准备好。
六、SysTick 和 PendSV
FreeRTOS 在 Cortex-M 上通常使用 SysTick 作为系统节拍。SysTick 周期到来后,内核会增加 Tick 计数,检查延时任务是否到期,必要时发起任务切换请求。
真正执行上下文切换的是 PendSV。PendSV 是可挂起异常,FreeRTOS 会把它设置成最低优先级。这样做的原因是:任务切换不应该打断更高优先级的外设中断,而是等当前中断处理完之后,再统一保存当前任务现场、恢复下一个任务现场。
如果 SysTick 和 PendSV 都处于挂起状态,硬件会按异常优先级顺序处理。由于它们通常都是最低优先级,具体顺序不会改变调度结果。关键点是:SysTick 负责产生节拍和提出调度请求,PendSV 负责真正切换上下文。
在 STM32CubeMX 工程里,使用 FreeRTOS 后还要注意 HAL 时基的问题。FreeRTOS 通常要接管 SysTick 作为系统节拍,如果 HAL 也继续依赖 SysTick 做 HAL_Delay() 的时基,就容易和 RTOS 的 Tick 设计混在一起。常见做法是给 HAL 单独配置 TIM6、TIM7 这类基础定时器,让 SysTick 专门服务 FreeRTOS。
七、中断屏蔽和临界段
FreeRTOS 里常见的中断屏蔽和临界段接口有几类。
- 任务上下文中的中断屏蔽
1 | taskDISABLE_INTERRUPTS(); |
在 Cortex-M 端口里,这类操作通常通过 BASEPRI 屏蔽 FreeRTOS 可管理范围内的中断,也就是优先级数值大于等于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 的中断。优先级更高的中断,例如 0 到 4,仍然可以响应。
- 任务上下文中的临界段
1 | taskENTER_CRITICAL(); |
临界段内部有嵌套计数,允许同一个任务多次进入临界段,退出次数对应以后才真正恢复中断屏蔽状态。临界段应该尽量短,只保护必要的共享数据访问。
- ISR 中的临界段
1 | UBaseType_t uxSavedInterruptStatus; |
ISR 里不能直接使用任务版本的临界段宏,应该使用 taskENTER_CRITICAL_FROM_ISR() 和 taskEXIT_CRITICAL_FROM_ISR()。进入时会保存当前中断屏蔽状态,退出时按保存值恢复,避免破坏中断嵌套关系。
临界段和中断屏蔽里都不能调用会阻塞或会主动触发任务等待的 API,例如 vTaskDelay()、带非零等待时间的队列发送/接收、信号量获取等。临界段的目的只是保护短小的原子操作,不是把一段复杂业务代码整个包起来。
这里还要区分临界段和互斥量。临界段更适合保护几个变量的读写,执行时间应该非常短;互斥量更适合保护外设访问、文件系统访问、显示屏刷新这类可能耗时的资源。临界段靠屏蔽一部分中断和禁止任务切换来保证原子性,互斥量靠任务阻塞和调度来排队,两者解决的问题不同。
如果共享数据同时被任务和 ISR 访问,优先考虑下面几种方式:
ISR 写入环形缓冲区,任务读取,读写指针用临界段保护。
ISR 只发送任务通知,真正的数据处理全部放在任务里。
对于很小的标志变量,可以用临界段保护读改写,避免任务读到中间状态。
不要在 ISR 中使用互斥量。互斥量依赖优先级继承,而 ISR 不是任务,不能参与这种机制。
下面是一个很简化的串口环形缓冲区例子。ISR 是生产者,任务是消费者。真实工程里还要处理溢出计数、协议边界、DMA 空闲中断等问题,但这个例子能说明“共享指针要保护”的原因。
1 |
|
任务读取时,用很短的临界段保护 rxTail 和 rxHead 的关系:
1 | void UartParserTask(void *argument) |
临界段里只做取一个字节和移动指针,不在里面解析协议。这样既能避免任务读指针时被 ISR 改写,又不会长时间屏蔽中断。
这里把 __HAL_UART_ENABLE_IT() 放在任务句柄赋值之后,也是同样的原因:先让接收任务准备好,再打开串口接收中断。否则中断提前进入,uartTaskHandle 还没赋值,通知就没有目标。
八、注意事项
任务优先级和中断优先级方向相反:FreeRTOS 任务优先级数字越大越高,Cortex-M 中断优先级数字越小越高。
优先级高于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 的 ISR,也就是优先级数值更小的 ISR,不能调用任何 FreeRTOS API。
能和 FreeRTOS 通信的 ISR,也只能调用带 FromISR 后缀的 API,不能调用普通任务 API。
ISR 中唤醒任务后,需要根据 pxHigherPriorityTaskWoken 调用 portYIELD_FROM_ISR(),否则高优先级任务可能要等到下一次调度点才运行。
ISR 要尽量短。硬件中断会打断任务,ISR 执行时间太长,所有任务响应都会被延迟。
临界段只能保护短小代码,不要在临界段或中断屏蔽状态下调用阻塞函数。
配置中断优先级时要统一看 CubeMX、FreeRTOSConfig.h 和实际代码。最容易出问题的是:CubeMX 里把某个外设中断设成了 0 到 4,但代码里又调用了 xQueueSendFromISR() 或 xSemaphoreGiveFromISR()。这种错误不一定每次都马上表现出来,但一旦触发,问题通常很难定位。







