中断是 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
2
HAL_NVIC_SetPriority(USART1_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);

如果 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY5,那么上面的 USART1_IRQn 可以在 ISR 中调用 FromISR API。若写成 HAL_NVIC_SetPriority(USART1_IRQn, 3, 0),这个中断硬件优先级更高,但 ISR 中就不能再调用 FreeRTOS API。

二、FreeRTOS 相关配置

CubeMX 里配置 FreeRTOS 时,和中断关系最密切的是这两个参数。

  1. configLIBRARY_LOWEST_INTERRUPT_PRIORITY

这个参数表示 MCU 可配置的最低中断优先级数值。STM32F4 常见配置下是 15。FreeRTOS 通常会把 SysTick 和 PendSV 设置到最低优先级,让任务切换不影响更紧急的外设中断。

  1. configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY

这个参数表示“允许调用 FreeRTOS 中断安全 API 的最高硬件优先级”。常见默认值是 5。由于 Cortex-M 的优先级数字越小越高,所以含义是:

优先级数值 04 的中断优先级更高,不受 FreeRTOS 的临界段屏蔽,ISR 中不能调用任何 FreeRTOS API。

优先级数值 515 的中断属于 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
2
3
4
5
6
7
8
9
10
#define configPRIO_BITS                         4

#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5

#define configKERNEL_INTERRUPT_PRIORITY \
(configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))

#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
(configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))

configLIBRARY_... 这一组是给用户配置时看的数字,和 HAL_NVIC_SetPriority() 里填的优先级更接近。没有 LIBRARY 的那一组已经移位到寄存器格式,FreeRTOS 内核自己会用。手动改配置时,最怕把 55 << 4 混着写。

三、可屏蔽中断和不可屏蔽中断

这里说的“可屏蔽中断”,不是硬件上的 NMI。它指的是 FreeRTOS 通过 BASEPRI 能够临时屏蔽的一部分普通中断。

configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5 为例:

  1. FreeRTOS 不可屏蔽中断:优先级数值为 04。这些中断优先级高,不能被 FreeRTOS 临界段屏蔽,所以 ISR 中不能调用 FreeRTOS API,包括带 FromISR 后缀的 API。

  2. FreeRTOS 可屏蔽中断:优先级数值为 515。这些中断可以被 FreeRTOS 临界段临时屏蔽,ISR 中允许调用 xQueueSendFromISR()xSemaphoreGiveFromISR()vTaskNotifyGiveFromISR() 这类中断安全 API。

实际分配中断优先级时,可以按这个思路处理:实时性要求很高、只操作硬件寄存器、不需要通知任务的中断,可以放在 0 到 4;需要和任务通信的外设中断,例如串口接收、DMA 完成、ADC 采样完成,通常放在 5 到 15,并且只调用 FromISR 版本的 API。

判断一个中断应该放在哪个范围,可以直接问两个问题:

  1. ISR 里是否要调用队列、信号量、事件组、任务通知等 FreeRTOS API。

  2. ISR 是否必须在 FreeRTOS 临界段期间也保持响应。

如果答案是“要调用 FreeRTOS API”,中断优先级必须放在 FreeRTOS 可管理范围内。如果答案是“必须始终保持最高响应”,那就不要在这个 ISR 里调用 FreeRTOS API,只做硬件级处理。

四、普通 API 和 FromISR API

任务上下文和中断上下文使用的 API 不一样。

普通 API 用在任务函数里,例如:

1
2
3
vTaskDelay(pdMS_TO_TICKS(10));
xQueueSend(queueHandle, &data, pdMS_TO_TICKS(20));
xSemaphoreTake(semHandle, portMAX_DELAY);

这些函数可能阻塞当前任务,也可能直接触发调度器处理任务状态。ISR 不是任务,不能阻塞等待,也没有任务栈切换的语义,所以不能在中断里调用这些普通 API。

中断里要使用带 FromISR 后缀的函数,例如:

1
2
3
xQueueSendFromISR(queueHandle, &data, &xHigherPriorityTaskWoken);
xSemaphoreGiveFromISR(semHandle, &xHigherPriorityTaskWoken);
vTaskNotifyGiveFromISR(taskHandle, &xHigherPriorityTaskWoken);

这类函数不会阻塞 ISR。它们内部会用适合中断上下文的方式修改内核对象,并通过 pxHigherPriorityTaskWoken 告诉调用者:这次操作是否唤醒了一个比当前被中断任务优先级更高的任务。

典型写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void USART1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (/* 接收中断触发 */)
{
uint8_t data = 0;

/* 读取数据寄存器,清除中断标志 */
xQueueSendFromISR(uartRxQueue, &data, &xHigherPriorityTaskWoken);
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

如果 xHigherPriorityTaskWoken 被置为 pdTRUEportYIELD_FROM_ISR() 会请求在 ISR 退出后进行一次上下文切换。注意这里不是在 ISR 中直接运行任务,而是在中断退出路径上切到更合适的任务。

实际使用时,xHigherPriorityTaskWoken 通常在 ISR 开头初始化为 pdFALSE。如果一个 ISR 里连续调用了多个 FromISR API,可以共用同一个变量,只要最后统一调用一次 portYIELD_FROM_ISR(xHigherPriorityTaskWoken)。这个变量的含义不是“是否有任务被唤醒”,而是“是否有更高优先级任务需要立刻运行”。如果唤醒的是同优先级或更低优先级任务,它不一定会被置为 pdTRUE

任务侧一般配合阻塞式等待。例如串口接收任务可以一直阻塞在队列上:

1
2
3
4
5
6
7
8
9
10
11
12
void UartRxTask(void *argument)
{
uint8_t data;

for (;;)
{
if (xQueueReceive(uartRxQueue, &data, portMAX_DELAY) == pdTRUE)
{
/* 在任务中解析数据 */
}
}
}

这样 ISR 只负责把字节放进队列,解析协议、处理字符串、刷新状态都在任务里完成。这个模式比在中断里直接做完整业务处理稳定得多。

再看一个按键外部中断释放二值信号量的例子。这个场景不关心按键值本身,只关心“按键事件发生了”,所以二值信号量比较合适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static SemaphoreHandle_t keySemHandle;

void CreateSyncObjects(void)
{
keySemHandle = xSemaphoreCreateBinary();
configASSERT(keySemHandle != NULL);
}

void KeyTask(void *argument)
{
for (;;)
{
if (xSemaphoreTake(keySemHandle, portMAX_DELAY) == pdTRUE)
{
/* 按键事件处理、消抖后的状态更新等 */
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void EXTI0_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);

if (keySemHandle != NULL)
{
xSemaphoreGiveFromISR(keySemHandle, &xHigherPriorityTaskWoken);
}
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

这个例子里,ISR 不做按键业务判断,只负责清中断标志并释放信号量。按键消抖可以放在任务里处理,也可以由定时器或状态机处理,尽量不要在 EXTI 中断里 HAL_Delay()keySemHandle 必须在中断启用前创建好,否则中断提前触发时会拿到空句柄。

CreateSyncObjects() 可以放在创建任务之前调用,或者放在系统初始化阶段调用。关键点是:先创建同步对象,再启用对应的外部中断。

五、ISR 和任务调度

只要 CPU 正在执行 ISR,普通任务代码就不会同时运行。即使是优先级最高的任务,也不能抢占硬件中断。任务优先级只在任务之间比较,中断优先级由 NVIC 决定。

因此 ISR 执行时间会直接影响系统响应。如果一个中断函数里做滤波、打印、复杂计算,任务调度就会被推迟。实际写法一般是 ISR 只做必要动作:

  1. 读取或写入外设寄存器。

  2. 清除中断标志。

  3. 把数据放入缓冲区,或者通过队列、信号量、任务通知唤醒任务。

  4. 退出 ISR,让任务完成耗时处理。

比如 ADC 中断里只把采样值放入缓冲区,缓冲区满后释放一个信号量;滤波、频谱计算、显示更新都放到任务里做。

一个常见流程是:

  1. DMA 或 ADC 中断到来。

  2. ISR 判断是半缓冲完成还是全缓冲完成。

  3. ISR 只记录当前可用缓冲区编号,然后释放二值信号量或发送任务通知。

  4. 数据处理任务被唤醒,读取对应缓冲区,完成滤波、均值、显示或上传。

这样写的好处是 ISR 时间比较固定,任务处理时间即使长一点,也不会一直占着中断上下文。需要注意的是,缓冲区所有权要明确:ISR 写哪个缓冲区,任务读哪个缓冲区,不能让任务还没处理完时 ISR 又覆盖同一块数据。

如果只需要通知任务“有一批新数据可以处理”,任务通知比信号量和队列更轻。下面是 ADC DMA 完成回调通知任务的写法。HAL 的这些回调函数是在中断上下文里被调用的,所以仍然要使用 FromISR API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define ADC_DMA_BUF_LEN 128

static uint16_t adcDmaBuf[ADC_DMA_BUF_LEN];
static TaskHandle_t adcTaskHandle;

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if ((hadc->Instance == ADC1) && (adcTaskHandle != NULL))
{
vTaskNotifyGiveFromISR(adcTaskHandle, &xHigherPriorityTaskWoken);
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

任务侧用 ulTaskNotifyTake() 等通知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void AdcProcessTask(void *argument)
{
adcTaskHandle = xTaskGetCurrentTaskHandle();

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcDmaBuf, ADC_DMA_BUF_LEN);

for (;;)
{
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

/* 处理 DMA 缓冲区中的 ADC 数据 */
ProcessAdcBuffer();
}
}

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. 任务上下文中的中断屏蔽
1
2
3
taskDISABLE_INTERRUPTS();
/* 很短的临界操作 */
taskENABLE_INTERRUPTS();

在 Cortex-M 端口里,这类操作通常通过 BASEPRI 屏蔽 FreeRTOS 可管理范围内的中断,也就是优先级数值大于等于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 的中断。优先级更高的中断,例如 0 到 4,仍然可以响应。

  1. 任务上下文中的临界段
1
2
3
taskENTER_CRITICAL();
/* 访问共享变量 */
taskEXIT_CRITICAL();

临界段内部有嵌套计数,允许同一个任务多次进入临界段,退出次数对应以后才真正恢复中断屏蔽状态。临界段应该尽量短,只保护必要的共享数据访问。

  1. ISR 中的临界段
1
2
3
4
5
UBaseType_t uxSavedInterruptStatus;

uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();
/* ISR 内部的临界操作 */
taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus);

ISR 里不能直接使用任务版本的临界段宏,应该使用 taskENTER_CRITICAL_FROM_ISR()taskEXIT_CRITICAL_FROM_ISR()。进入时会保存当前中断屏蔽状态,退出时按保存值恢复,避免破坏中断嵌套关系。

临界段和中断屏蔽里都不能调用会阻塞或会主动触发任务等待的 API,例如 vTaskDelay()、带非零等待时间的队列发送/接收、信号量获取等。临界段的目的只是保护短小的原子操作,不是把一段复杂业务代码整个包起来。

这里还要区分临界段和互斥量。临界段更适合保护几个变量的读写,执行时间应该非常短;互斥量更适合保护外设访问、文件系统访问、显示屏刷新这类可能耗时的资源。临界段靠屏蔽一部分中断和禁止任务切换来保证原子性,互斥量靠任务阻塞和调度来排队,两者解决的问题不同。

如果共享数据同时被任务和 ISR 访问,优先考虑下面几种方式:

  1. ISR 写入环形缓冲区,任务读取,读写指针用临界段保护。

  2. ISR 只发送任务通知,真正的数据处理全部放在任务里。

  3. 对于很小的标志变量,可以用临界段保护读改写,避免任务读到中间状态。

不要在 ISR 中使用互斥量。互斥量依赖优先级继承,而 ISR 不是任务,不能参与这种机制。

下面是一个很简化的串口环形缓冲区例子。ISR 是生产者,任务是消费者。真实工程里还要处理溢出计数、协议边界、DMA 空闲中断等问题,但这个例子能说明“共享指针要保护”的原因。

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
#define UART_RX_BUF_SIZE 128

static uint8_t uartRxBuf[UART_RX_BUF_SIZE];
static volatile uint16_t rxHead = 0;
static volatile uint16_t rxTail = 0;
static TaskHandle_t uartTaskHandle;

void USART1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET)
{
uint8_t data = (uint8_t)(huart1.Instance->DR & 0xff);
uint16_t next = (uint16_t)((rxHead + 1) % UART_RX_BUF_SIZE);

if (next != rxTail)
{
uartRxBuf[rxHead] = data;
rxHead = next;
}

if (uartTaskHandle != NULL)
{
vTaskNotifyGiveFromISR(uartTaskHandle, &xHigherPriorityTaskWoken);
}
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

任务读取时,用很短的临界段保护 rxTailrxHead 的关系:

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
void UartParserTask(void *argument)
{
uartTaskHandle = xTaskGetCurrentTaskHandle();

__HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE);

for (;;)
{
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

for (;;)
{
uint8_t data;
BaseType_t hasData = pdFALSE;

taskENTER_CRITICAL();
if (rxTail != rxHead)
{
data = uartRxBuf[rxTail];
rxTail = (uint16_t)((rxTail + 1) % UART_RX_BUF_SIZE);
hasData = pdTRUE;
}
taskEXIT_CRITICAL();

if (hasData == pdFALSE)
{
break;
}

ParseOneByte(data);
}
}
}

临界段里只做取一个字节和移动指针,不在里面解析协议。这样既能避免任务读指针时被 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()。这种错误不一定每次都马上表现出来,但一旦触发,问题通常很难定位。