FreeRTOS 中断优先级、API 调用规则与高优先级数据交付
在 Cortex-M 上,FreeRTOS 通过
BASEPRI只屏蔽一部分中断,从而在保护内核链表的同时保留紧急中断的低延迟能力。代价是:高于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断不能调用任何 FreeRTOS API。本文解释这条规则的原因、中断被屏蔽时的真实行为,以及高优先级 ISR 如何安全地把数据交给任务。
关键词: FreeRTOS、BASEPRI、configMAX_SYSCALL_INTERRUPT_PRIORITY、FromISR、PendSV、中断屏蔽、环形缓冲、DMA、任务通知
一、先理解 Cortex-M 的优先级方向
在 Cortex-M 中,优先级数值越小,硬件优先级越高。
假设 MCU 实现了 4 位中断优先级:
优先级 0 最高
优先级 1
优先级 2
...
优先级 15 最低
若配置:
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
则规则为:
| 中断逻辑优先级 | 能否调用 FreeRTOS FromISR API |
|---|---|
| 0~4 | 不能调用任何 FreeRTOS API |
| 5~15 | 可以调用对应的 FromISR API |
| PendSV | 通常设置为最低优先级 15 |
“不能高于优先级 5”指的是不能拥有比 5 更高的硬件紧迫性,也就是优先级数值不能小于 5。
二、为什么 FreeRTOS 不直接关闭全部中断
任务调用:
xQueueSend(queue, &data, timeout);
时,FreeRTOS 可能需要修改:
- 队列读写指针;
- 队列消息数量;
- 等待发送或接收的任务链表;
- 就绪任务链表;
- 延时链表;
- 当前最高就绪优先级。
这些修改必须具有原子性。例如,向双向链表插入一个任务可能经历:
修改前一个节点的 next
↓
修改后一个节点的 prev
↓
修改新节点的 prev 和 next
↓
更新链表节点数量
如果修改到一半时被另一个同样操作 FreeRTOS 内核的中断抢占,第二次操作看到的就是一个暂时不一致的链表。
最简单的保护方式是关闭所有中断:
关闭全部中断
↓
修改内核数据
↓
重新打开中断
但这样会让电机保护、高速采样和精确时间戳等紧急中断也产生较大延迟。
因此 Cortex-M3/M4/M7 等 FreeRTOS Port 通常使用 BASEPRI:
屏蔽可能调用 FreeRTOS API 的中断
保留更高优先级紧急中断的响应能力
三、BASEPRI 怎样建立中断边界
进入 FreeRTOS 临界区时,Port 层会设置类似:
BASEPRI = configMAX_SYSCALL_INTERRUPT_PRIORITY;
假设边界是逻辑优先级 5:
优先级 0~4:不被 BASEPRI 屏蔽
优先级 5~15:被 BASEPRI 屏蔽
因此 FreeRTOS 临界区内:
高实时中断 0~4
├── 仍可立即抢占
└── 禁止调用 FreeRTOS API
RTOS 感知中断 5~15
├── 可以调用 FromISR API
└── FreeRTOS 临界区内会被暂时屏蔽
FreeRTOS 的设计契约是:
所有可能进入 FreeRTOS 内核的中断,都必须位于 FreeRTOS 临界区能够屏蔽的优先级范围内。
四、高优先级中断违规调用 API 会发生什么
假设 TaskLow 正在修改就绪链表:
TaskLow 调用 xQueueSend()
↓
进入内核临界区,BASEPRI 屏蔽优先级 5~15
↓
内核链表修改到一半
此时优先级 2 的中断到来。它不受 BASEPRI 屏蔽,可以立即抢占:
优先级 2 ISR 抢占
↓
违规调用 xQueueSendFromISR()
↓
再次修改队列和任务链表
可能造成:
- 链表前后指针不一致;
- 同一个任务被重复加入就绪链表;
- 任务从调度器中丢失;
- 队列消息数量错误;
- 任务永久阻塞;
- 调度器进入死循环;
- 随机 HardFault;
- 系统在很久以后才表现出故障。
真正破坏链表的时刻与系统崩溃的时刻可能相隔很久,因此这类问题非常难排查。
五、为什么 FromISR API 也受限制
FromISR 不表示“任何中断都可以调用”。它表示该 API:
- 不进行阻塞等待;
- 使用适合 ISR 的临界区操作;
- 通过
xHigherPriorityTaskWoken请求调度; - 不依赖普通任务上下文才能使用的功能。
但 FromISR API 仍然可能修改:
- Queue 内部状态;
- 事件等待链表;
- 就绪任务链表;
- 调度器状态。
它仍然依赖 configMAX_SYSCALL_INTERRUPT_PRIORITY 定义的屏蔽边界。
从 ISR 调用 FreeRTOS API 必须同时满足:
当前位于 ISR
+
调用的是 xxxFromISR()
+
ISR 优先级位于 syscall 安全范围
六、普通 API 与 FromISR API 的调用边界
| 调用位置 | 允许的调用 |
|---|---|
| 任务上下文 | xQueueSend()、xSemaphoreTake() 等普通 API |
| 合法优先级 ISR | xQueueSendFromISR()、xSemaphoreGiveFromISR() 等 |
| 高于 syscall 边界的 ISR | 不能调用任何 FreeRTOS API |
| NMI、HardFault | 不应调用 FreeRTOS API |
即使把普通 API 的等待时间设置为 0,也不代表它能从 ISR 调用:
// 错误:普通 API 不能在 ISR 中使用
xQueueSend(queue, &data, 0);
// 正确:合法优先级 ISR 使用 FromISR 版本
xQueueSendFromISR(queue, &data, &xHigherPriorityTaskWoken);
正确示例:
void USART1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
clear_uart_interrupt();
vTaskNotifyGiveFromISR(
uartTaskHandle,
&xHigherPriorityTaskWoken
);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
七、三个优先级配置的区别
1. configKERNEL_INTERRUPT_PRIORITY
用于 PendSV、SysTick 等内核异常,通常配置为最低优先级:
#define configKERNEL_INTERRUPT_PRIORITY \
(15U << (8U - configPRIO_BITS))
2. configMAX_SYSCALL_INTERRUPT_PRIORITY
这是写入 Cortex-M BASEPRI 寄存器使用的移位后数值:
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
(5U << (8U - configPRIO_BITS))
3. configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY
这是供 CMSIS、HAL 等优先级配置接口使用的未移位逻辑优先级:
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5U
使用 CMSIS 设置中断优先级时通常传入未移位值:
NVIC_SetPriority(USART1_IRQn, 5U);
而不是把已经移位的 configMAX_SYSCALL_INTERRUPT_PRIORITY 直接传给 NVIC_SetPriority()。
具体宏名称取决于 Port、CMSIS 和工程模板,应检查当前工程的 FreeRTOSConfig.h。
八、为什么 syscall 边界一般不能设置为 0
在 Cortex-M 中:
BASEPRI = 0
通常表示不根据 BASEPRI 屏蔽任何中断。
如果 configMAX_SYSCALL_INTERRUPT_PRIORITY 对应最高逻辑优先级 0,FreeRTOS 临界区就不能通过 BASEPRI 建立有效的中断屏蔽范围。
因此该配置通常必须是一个非零的有效优先级。
九、FreeRTOS 怎样发现优先级配置错误
开发阶段应启用:
#define configASSERT(x) /* 项目断言实现 */
部分 Cortex-M Port 会通过:
vPortValidateInterruptPriority();
检查:
- 当前 ISR 优先级是否允许调用 API;
- NVIC 优先级分组是否正确;
- 是否使用了不受支持的子优先级设置。
如果 ISR 优先级过高,程序会在问题发生的位置触发断言,而不是等到链表损坏后随机崩溃。
十、暂时屏蔽中断是延迟响应还是不响应
通常是延迟响应,不是直接丢失。
中断被屏蔽期间,硬件事件仍可能发生,并把中断设置为 Pending:
中断事件发生
↓
外设或 NVIC 设置 Pending
↓
当前被 BASEPRI/PRIMASK 屏蔽
↓
暂不进入 ISR
↓
解除屏蔽
↓
CPU 响应中断
例如:
10 μs:进入 FreeRTOS 临界区
15 μs:UART 中断发生,进入 Pending
20 μs:退出临界区
20 μs:CPU 响应 UART 中断
事件在 15 μs 已经发生,但 ISR 到 20 μs 才执行,因此增加了 5 μs 中断延迟。
十一、为什么 Pending 存在仍可能丢事件
NVIC 的 Pending 通常只是一个状态位,不是事件计数器。
如果同一个中断在屏蔽期间连续发生:
第一次事件 → Pending = 1
第二次事件 → Pending 已经是 1
第三次事件 → Pending 仍然是 1
解除屏蔽后,CPU 不一定执行三次 ISR,可能只执行一次。
1. 外设有 FIFO 或计数器
如果外设内部能够保存每个事件,例如:
- UART RX FIFO;
- DMA 传输计数;
- CAN 接收邮箱;
- 定时器捕获 FIFO;
- ADC 数据队列。
那么 ISR 延迟执行后仍可以批量处理积压数据:
UART 收到 3 字节
↓
3 字节保存在 FIFO
↓
解除屏蔽
↓
ISR 一次性读取 3 字节
2. 外设只有一个状态标志
如果只有一个状态位:
第一次溢出 → UIF = 1
第二次溢出 → UIF 仍然是 1
第三次溢出 → UIF 仍然是 1
ISR 只能知道“至少发生过一次”,无法知道实际发生次数。
因此屏蔽时间过长可能导致:
- 定时器事件合并;
- UART FIFO 溢出;
- ADC Overrun;
- 输入捕获被覆盖;
- GPIO 多个边沿合并;
- DMA 双缓冲来不及处理。
结论是:
Pending 通常保证 ISR 以后会运行,但不保证硬件发生的每一次事件都被单独记录。
十二、电平触发与边沿触发
1. 电平触发
只要中断条件仍然存在,中断请求就持续有效:
外设状态有效 ─────────────────
解除屏蔽
↓
进入 ISR
例如接收 FIFO 非空。只要 FIFO 中仍有数据,解除屏蔽后通常仍会响应。
2. 边沿触发
边沿触发只在信号跳变时产生事件:
____|‾‾‾
↑
边沿
如果 NVIC 或外设捕获到边沿,就会设置 Pending,之后可以延迟响应。
但在以下情况下可能合并或丢失:
- 输入脉冲短于硬件采样能力;
- 外设没有事件锁存;
- 多个边沿共用一个 Pending 位;
- 前一个事件尚未处理时后一个事件到来;
- 软件错误地提前清除了状态或 Pending。
十三、BASEPRI、PRIMASK 与 NVIC Disable
BASEPRI
按优先级屏蔽一部分可配置中断:
高优先级中断:仍可响应
低优先级中断:进入 Pending,延迟响应
FreeRTOS Cortex-M3/M4/M7 临界区通常使用它。
PRIMASK
屏蔽几乎所有可配置中断:
__disable_irq();
被屏蔽的中断通常仍可进入 Pending,重新启用后再响应。NMI 等不可屏蔽异常不受影响。
NVIC Disable
NVIC_DisableIRQ(UART_IRQn);
中断被禁用时,外设状态通常仍会变化,NVIC Pending 也可能被设置。重新启用后,如果 Pending 和外设条件仍存在,就会进入 ISR。
但如果软件在重新启用前清除了外设标志或 Pending 位,该事件就不会再响应。
十四、为什么 FreeRTOS 临界区必须短
临界区虽然通常不会直接丢掉 Pending 中断,但会增加:
- 最坏中断响应时间;
- 任务唤醒延迟;
- 数据积压;
- FIFO 溢出风险;
- 周期事件合并风险;
- 实时控制抖动。
临界区中不要执行:
taskENTER_CRITICAL();
printf(...); // 不合适
flash_erase(); // 不合适
while (condition) {} // 不合适
large_memcpy(...); // 通常不合适
taskEXIT_CRITICAL();
应只保护必要的共享数据更新:
taskENTER_CRITICAL();
shared_index++;
shared_state = new_state;
taskEXIT_CRITICAL();
高优先级中断怎样把数据交给任务
高优先级中断不能调用 FreeRTOS API,但它仍可以使用不依赖内核的硬件和内存机制,把工作转交给合法优先级中断或任务。
以下给出三种常用方案。
十五、方案一:无锁环形缓冲区加低优先级软件中断
这是兼顾低延迟与低丢包风险的常用结构:
高优先级采样 ISR
│ 写入 SPSC 环形缓冲区
│ 设置低优先级软件中断 Pending
▼
低优先级软件 ISR
│ 调用 vTaskNotifyGiveFromISR()
▼
处理任务
│ 批量读取环形缓冲区
▼
协议解析、滤波或存储
其中:
- 高优先级 ISR 不调用 FreeRTOS;
- 环形缓冲区保存每个数据项;
- 软件中断的 Pending 即使合并,也不会丢失已经进入缓冲区的数据;
- 低优先级软件中断处于 syscall 安全范围,可以通知任务。
1. 环形缓冲区定义
下面示例只允许一个生产者和一个消费者,即 SPSC:
#include <stdint.h>
#include <stdbool.h>
#define SAMPLE_RING_SIZE 64U
#define SAMPLE_RING_MASK (SAMPLE_RING_SIZE - 1U)
typedef struct {
uint16_t data[SAMPLE_RING_SIZE];
volatile uint32_t write_index;
volatile uint32_t read_index;
volatile uint32_t overflow_count;
} sample_ring_t;
static sample_ring_t g_sample_ring;
环形缓冲区大小取 2 的幂,便于使用掩码回绕。
2. 高优先级 ADC ISR
假设 ADC 中断优先级为 2,高于 syscall 边界 5:
void ADC_IRQHandler(void)
{
uint32_t write = g_sample_ring.write_index;
uint32_t next = (write + 1U) & SAMPLE_RING_MASK;
uint16_t value = (uint16_t)ADC1->DR;
if (next != g_sample_ring.read_index) {
g_sample_ring.data[write] = value;
/* 确保数据先写入,再发布新的 write_index。 */
__DMB();
g_sample_ring.write_index = next;
} else {
g_sample_ring.overflow_count++;
}
clear_adc_interrupt_flag();
/* DEFERRED_IRQn 必须是平台保留的、可软件触发的低优先级 IRQ。 */
NVIC_SetPendingIRQ(DEFERRED_IRQn);
}
这里没有调用任何 FreeRTOS API。
3. 低优先级延后处理中断
将 DEFERRED_IRQn 设置为优先级 6:
void DEFERRED_IRQHandler(void)
{
BaseType_t wake = pdFALSE;
vTaskNotifyGiveFromISR(sampleTaskHandle, &wake);
portYIELD_FROM_ISR(wake);
}
优先级配置:
NVIC_SetPriority(ADC_IRQn, 2U); // 高实时,不调用 RTOS
NVIC_SetPriority(DEFERRED_IRQn, 6U); // 可以调用 FromISR API
DEFERRED_IRQn 可以是平台专门保留的软件中断,或一个允许通过 NVIC 设置 Pending 的普通中断号。具体实现取决于 MCU 和 Port。
4. 任务批量读取
static bool sample_ring_pop(uint16_t *value)
{
uint32_t read = g_sample_ring.read_index;
if (read == g_sample_ring.write_index) {
return false;
}
__DMB();
*value = g_sample_ring.data[read];
g_sample_ring.read_index =
(read + 1U) & SAMPLE_RING_MASK;
return true;
}
void SampleTask(void *argument)
{
uint16_t sample;
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
while (sample_ring_pop(&sample)) {
process_sample(sample);
}
}
}
5. 这个示例的限制
- 只支持单生产者、单消费者;
write_index和read_index必须是 CPU 能原子访问的对齐宽度;- 多核系统需要使用真正的原子变量和跨核内存屏障;
- 缓冲区满时必须定义丢弃新数据还是覆盖旧数据;
- 缓冲区容量应根据最大延后时间和最高事件速率计算;
- 如果数据量很大,优先考虑 DMA。
缓冲区最小容量可以粗略按下式估算:
容量 ≥ 最大事件速率 × 最坏延后处理时间 × 安全系数
十六、方案二:DMA 双缓冲加合法优先级完成中断
对于 ADC、UART、SPI、I²S 等高速数据,最好让硬件直接通过 DMA 搬运:
定时器触发 ADC
↓
DMA 填充 Buffer A
│
├── CPU 同时处理 Buffer B
│
▼
DMA 完成中断,优先级 6
↓
vTaskNotifyGiveFromISR()
↓
任务处理已完成缓冲区
1. 缓冲区与状态
#define SAMPLE_COUNT 256U
static uint16_t sample_buffer[2][SAMPLE_COUNT];
static volatile uint32_t completed_buffer;
2. DMA 完成中断
DMA 完成中断设置在 syscall 安全范围:
void DMA1_Stream0_IRQHandler(void)
{
BaseType_t wake = pdFALSE;
if (dma_transfer_complete()) {
clear_dma_transfer_complete();
completed_buffer = dma_get_completed_buffer_index();
vTaskNotifyGiveFromISR(sampleTaskHandle, &wake);
portYIELD_FROM_ISR(wake);
}
}
3. 处理任务
void SampleTask(void *argument)
{
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
uint32_t index = completed_buffer;
process_sample_block(
sample_buffer[index],
SAMPLE_COUNT
);
}
}
4. 所有权状态
双缓冲应明确状态:
FREE → DMA_OWNED → CPU_READY → CPU_OWNED → FREE
不能让 DMA 和 CPU 同时写同一个缓冲区。
5. Cache 注意事项
如果 CPU 带 D-Cache,而 DMA 与 Cache 不一致:
- CPU 写、DMA 读:启动 DMA 前 Clean Cache;
- DMA 写、CPU 读:DMA 完成后 Invalidate Cache;
- 缓冲区按 Cache Line 对齐;
- 避免 DMA 缓冲区与其他变量共享 Cache Line;
- 也可以通过 MPU 将 DMA 区域设置为不可缓存。
volatile 不能解决 DMA 与 Cache 一致性问题。
十七、方案三:原子事件计数加任务周期读取
如果事件只需要计数,不需要保存每个数据样本,可以使用原子计数器。
高优先级 ISR:
#include <stdatomic.h>
static atomic_uint_fast32_t event_count;
void FAST_EVENT_IRQHandler(void)
{
clear_fast_event_flag();
atomic_fetch_add_explicit(
&event_count,
1U,
memory_order_release
);
}
任务周期读取并清零:
void EventTask(void *argument)
{
for (;;) {
vTaskDelay(pdMS_TO_TICKS(10));
uint32_t count = atomic_exchange_explicit(
&event_count,
0U,
memory_order_acquire
);
if (count != 0U) {
process_event_count(count);
}
}
}
这种方式适合:
- 脉冲计数;
- 性能统计;
- 不要求立即唤醒任务的事件;
- 多次事件可以合并处理的场景。
需要确认所用宽度的原子操作在目标架构和工具链上是无锁的。不能让原子库在高优先级 ISR 内部通过锁或不可控临界区实现。
十八、三种交付方式怎样选择
| 场景 | 推荐方式 |
|---|---|
| 每次事件的数据都要保留,数据量较小 | SPSC 环形缓冲区 + 低优先级软件 IRQ |
| 高速连续数据或大数据块 | DMA Ping-Pong + 合法优先级完成 IRQ |
| 只关心事件次数,可以延迟处理 | 原子事件计数 + 任务周期读取 |
| 只需一个最新状态 | 原子变量或硬件寄存器 + 任务读取 |
| 复杂多生产者数据 | 降低 ISR 优先级并使用 FreeRTOS Queue,或设计专用并发结构 |
高优先级 ISR 的基本原则是:
快速采集
↓
保存到预分配内存或硬件 FIFO
↓
触发低优先级延后处理
↓
由合法优先级 ISR 通知任务
↓
任务完成复杂工作
十九、设计检查表
中断优先级
- 是否确认 Cortex-M 优先级数值越小、硬件优先级越高?
- 所有调用
FromISRAPI 的 IRQ 是否位于 syscall 安全范围? - 是否区分移位后的
configMAX_SYSCALL_INTERRUPT_PRIORITY和未移位的 Library 优先级? - PendSV 是否设置为最低优先级?
- 是否启用
configASSERT?
API 调用
- ISR 是否只调用明确带有
FromISR后缀的 API? - 是否正确处理
xHigherPriorityTaskWoken? - ISR 退出前是否调用
portYIELD_FROM_ISR()? - NMI、HardFault 和超高优先级 ISR 是否完全不调用 FreeRTOS?
数据交付
- 是否定义生产者和消费者数量?
- 是否定义缓冲区所有权?
- 缓冲区满时是丢新、丢旧还是触发故障?
- 是否按最坏延迟计算缓冲区容量?
- DMA 缓冲区是否处理 Cache 一致性?
- 中断被屏蔽期间多个事件是否可能合并?
- 外设 FIFO 是否可能溢出?
实时性
- FreeRTOS 临界区是否足够短?
- 是否测量最大关中断时间?
- 是否测量最坏 ISR 延迟,而不是只看平均值?
- 是否对事件突发和中断风暴进行压力测试?
二十、总结
configMAX_SYSCALL_INTERRUPT_PRIORITY 划分了两个中断世界:
高实时中断
- 延迟最低
- 不受 FreeRTOS BASEPRI 临界区屏蔽
- 不能调用任何 FreeRTOS API
- 使用硬件 FIFO、DMA、无锁缓冲或原子变量交付数据
RTOS 感知中断
- 可以调用 FromISR API
- 可以唤醒任务并请求 PendSV
- FreeRTOS 临界区内会被暂时屏蔽
中断被暂时屏蔽时,ISR 通常只是延迟执行,NVIC 会保留 Pending 状态;但 Pending 位不能累计事件次数。如果屏蔽时间过长,而外设 FIFO、计数器或软件缓冲区又无法保存所有事件,就可能出现事件合并、数据覆盖或真正的数据丢失。
最稳妥的架构是让高优先级 ISR 只做快速采集和最小状态发布,再通过预分配缓冲区、DMA 或低优先级软件中断,把复杂工作交给 FreeRTOS 任务。