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。

十三、BASEPRIPRIMASK 与 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_indexread_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 优先级数值越小、硬件优先级越高?
  • 所有调用 FromISR API 的 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 任务。