3. 中断服务程序(ISR)中的API调用规范与陷阱

中断服务程序,简称ISR,是嵌入式系统里最特殊的一段代码。它跑在中断上下文里,跟普通任务完全不一样。我见过太多工程师在ISR里随便调用FreeRTOS的API,结果系统莫名其妙地崩溃。说白了,ISR里调用API是有严格规矩的,踩了雷,轻则任务卡死,重则整个系统挂掉。

这一章,我就把ISR中API调用的那些门道,掰开揉碎了讲清楚。

3.1 为什么ISR里不能随便调API?

先想一个问题:FreeRTOS的任务调度,是靠什么触发的?

答案是:PendSV异常和SysTick定时器中断。这两个东西,优先级都比普通外设中断低。

你想想看,如果在一个高优先级的中断里,你调用了vTaskDelay()或者xQueueSend(),这些API内部可能会触发任务切换。但此时CPU正在处理中断,PendSV异常被阻塞了,任务切换根本执行不了。这就造成了死锁——API等着切换,切换等着中断退出。

我在项目中遇到过这种情况:一个UART接收中断里调用了xQueueSend(),结果队列满了,API内部试图挂起当前任务。但中断还没退出,调度器没法运行,系统直接卡死在中断里。嗯,从那以后,我再也不敢在ISR里乱调API了。

3.2 哪些API可以在ISR中调用?

FreeRTOS很贴心,专门为ISR准备了一套“安全版”API。它们的命名规则很简单:在普通API后面加FromISR后缀。

比如:

  • xQueueSend()xQueueSendFromISR()
  • xSemaphoreGive()xSemaphoreGiveFromISR()
  • xTaskNotifyGive()xTaskNotifyGiveFromISR()

这些FromISR版本的API,内部实现做了特殊处理:

  • 不会阻塞:它们不会等待某个资源,如果资源不可用,直接返回错误码
  • 不会触发任务切换:它们只是记录一个“需要切换”的标志,真正的切换等中断退出后才执行
  • 需要传递一个参数:最后一个参数是BaseType_t *pxHigherPriorityTaskWoken,用来告诉调用者“有没有更高优先级的任务被唤醒了”

核心原则:在ISR中,只调用带FromISR后缀的API。普通API一个都不能碰。

3.3 那个神秘的pxHigherPriorityTaskWoken参数

这个参数,是ISR中API调用的关键。很多新手直接传NULL,结果系统跑着跑着就出问题。

它的作用是什么?

假设你在一个UART中断里,通过xQueueSendFromISR()往队列里放了一个数据。这个操作可能唤醒一个正在等待队列数据的任务。如果那个任务的优先级比当前被打断的任务高,那么系统应该在中断退出后立即切换到那个高优先级任务。

但FreeRTOS不会在ISR内部直接切换。它只是把*pxHigherPriorityTaskWoken设置为pdTRUE。然后你在ISR的最后,检查这个值,如果为真,就调用portYIELD_FROM_ISR()来触发一次任务切换。

看个例子:

void UART_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint8_t data;

    // 读取硬件寄存器获取数据
    data = UART->DR;

    // 发送到队列,注意最后一个参数
    xQueueSendFromISR(xDataQueue, &data, &xHigherPriorityTaskWoken);

    // 如果有更高优先级的任务被唤醒,就切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

我个人习惯,每次在ISR里调用FromISR函数,都会先定义一个局部变量xHigherPriorityTaskWoken并初始化为pdFALSE。然后在ISR末尾统一检查并调用portYIELD_FROM_ISR()。这样结构清晰,不容易漏掉。

警告:千万不要在多个FromISR调用之间重复使用同一个xHigherPriorityTaskWoken变量而不重置。我曾经见过一个同事,在同一个ISR里调用了两次xQueueSendFromISR(),但只定义了一个变量,第二次调用时变量已经被第一次设为pdTRUE了,结果第二次调用传进去的是pdTRUE,导致API内部逻辑混乱。嗯,调试了整整一个下午才找到原因。

3.4 ISR中绝对不能做的事

除了API调用规范,ISR本身也有一些铁律。我列个清单:

禁止行为 原因
调用vTaskDelay() 阻塞当前任务,但ISR不是任务,无法被阻塞
调用xQueueReceive()(非FromISR版) 如果队列为空,会阻塞等待,ISR不允许阻塞
调用xSemaphoreTake()(非FromISR版) 同样会阻塞,ISR里不能等
调用vTaskSuspend() 挂起任务涉及调度,ISR中不能直接操作
调用malloc()free() 堆操作可能触发锁,ISR中不能使用锁
执行耗时操作(如浮点运算、大循环) 中断服务时间过长,会影响系统实时性

说白了,ISR应该短小精悍。它的职责就是:接收事件、记录数据、通知任务。剩下的处理工作,交给任务去做。

3.5 中断嵌套与优先级配置

FreeRTOS支持中断嵌套,但配置不当会出大问题。我见过最典型的错误,就是把FreeRTOS的调度器中断(SysTick和PendSV)优先级设得太低,结果被其他中断长时间抢占,导致系统节拍丢失。

配置中断优先级时,记住几个要点:

  • SysTick和PendSV的优先级:设置为最低优先级。这样任何其他中断都能抢占它们,保证外设中断的实时性
  • 临界区保护:FreeRTOS的临界区是通过关中断实现的。如果某个中断的优先级高于configMAX_SYSCALL_INTERRUPT_PRIORITY,那么临界区无法屏蔽它。这意味着,这个中断里绝对不能调用任何FreeRTOS API
  • 优先级分组:ARM Cortex-M系列支持优先级分组,建议使用NVIC_PriorityGroup_4(4位抢占优先级,0位子优先级),这样优先级管理最简单

小技巧:在FreeRTOSConfig.h中,configMAX_SYSCALL_INTERRUPT_PRIORITY这个宏定义了“安全中断”的最高优先级。所有低于这个优先级的中断,都可以安全调用FromISR API。高于这个优先级的中断,连FromISR API都不能调。我一般把这个值设为5(假设优先级范围是0-15,0最高),这样大部分外设中断都能用FromISR API,只有最紧急的(比如看门狗、电源管理)才设更高优先级。

3.6 实战:一个安全的ISR设计模式

说了这么多理论,来点实际的。下面是我在项目中常用的ISR设计模式:

// 定义一个结构体,用于在ISR和任务之间传递数据
typedef struct {
    uint8_t data;
    uint32_t timestamp;
} SensorData_t;

// 队列句柄,在任务中创建
QueueHandle_t xSensorQueue;

// 定时器中断,每1ms触发一次
void TIM_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    SensorData_t sensorData;

    // 清除中断标志
    TIM->SR = ~TIM_SR_UIF;

    // 读取传感器数据(假设是ADC值)
    sensorData.data = ADC->DR;
    sensorData.timestamp = xTaskGetTickCountFromISR();

    // 发送到队列
    xQueueSendFromISR(xSensorQueue, &sensorData, &xHigherPriorityTaskWoken);

    // 检查是否需要切换任务
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务:处理传感器数据
void vSensorTask(void *pvParameters)
{
    SensorData_t receivedData;

    for (;;) {
        // 等待队列数据,阻塞100ms
        if (xQueueReceive(xSensorQueue, &receivedData, pdMS_TO_TICKS(100)) == pdPASS) {
            // 处理数据...
            processSensorData(&receivedData);
        }
    }
}

这个模式的好处很明显:

  • ISR只做最少的操作:读取数据、发送队列
  • 数据处理在任务中完成,不阻塞中断
  • 通过队列解耦,ISR和任务互不干扰

3.7 避坑指南:我踩过的那些雷

最后,分享几个我亲身经历过的坑:

  • 坑一:忘记检查pxHigherPriorityTaskWoken。有一次我写了一个按键中断,调用了xSemaphoreGiveFromISR(),但没检查返回值,也没调用portYIELD_FROM_ISR()。结果按键响应总是慢半拍,因为任务切换要等到下一个SysTick才触发。加了portYIELD_FROM_ISR()后,响应速度立竿见影。
  • 坑二:在ISR里打印调试信息。我曾经在ISR里用printf()打印日志,结果系统频繁死机。后来才发现,printf()内部用了互斥锁,而ISR里不能使用锁。从那以后,我改用环形缓冲区+任务打印的方式。
  • 坑三:中断优先级配置冲突。有一次我把两个外设的中断优先级设成了相同值,结果它们同时触发时,系统行为变得不可预测。后来查手册才知道,相同优先级的中断,硬件会按中断号顺序处理,但FreeRTOS的临界区无法正确处理这种情况。我的建议是:每个外设中断都分配不同的优先级,哪怕只差1级。

嗯,ISR中的API调用规范,说白了就是一句话:用FromISR版本,传对参数,及时切换。但真正用好它,需要你对FreeRTOS的内核机制有深入理解。希望这一章的内容,能帮你少走一些弯路。


公众号:蓝海资料掘金营,微信deep3321