4. RTOS环境下的中断处理:中断与任务通信

中断服务程序(ISR)跑得再快,最终还是要跟任务层打交道。你想想看,按键按下后,ISR只是清除了抖动、记录了时间戳,但真正要处理这个按键事件的逻辑——比如切换界面、发送数据——总不能全塞在ISR里吧?

RTOS给我们提供了三种经典的通信手段:信号量、消息队列、事件标志组。我个人习惯把这三样东西比作“中断与任务之间的桥梁”。选对了桥,数据流就顺畅;选错了,要么丢数据,要么把ISR拖死。

4.1 为什么不能在ISR里做太多事?

先说个原则:ISR要短、平、快。我在项目中遇到过一位同事,把整个LCD刷新逻辑写进了GPIO中断里。结果呢?中断嵌套一多,系统直接死机。后来我帮他改成“ISR只发信号,任务慢慢处理”,问题就解决了。

RTOS环境下,ISR里不能调用阻塞式API。比如等待信号量、等待队列消息——这些都会让ISR卡住。你想想看,中断上下文里没有任务调度,一卡住整个系统就挂了。

警告:ISR中绝对不要调用阻塞型API。所有RTOS都提供了“FromISR”后缀的专用函数,比如 xSemaphoreGiveFromISR()、xQueueSendFromISR()。用错了,系统直接崩溃。

4.2 信号量:最简单的“通知”机制

信号量就像一面旗子。ISR举旗,任务看到旗子就干活。最常用的是二值信号量(Binary Semaphore)。

我曾经在一个电机控制项目里用过这个模式:霍尔传感器触发中断,ISR释放信号量,控制任务获取信号量后计算转速。代码结构非常清晰。

// ISR中的写法
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中的写法
void vControlTask(void *pvParameters)
{
    while(1)
    {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE)
        {
            // 处理中断事件
            vCalculateSpeed();
        }
    }
}
技巧:注意 xHigherPriorityTaskWoken 这个变量。如果ISR释放信号量后,等待该信号量的任务优先级比当前被中断的任务高,就需要立即切换。不写这个,实时性会打折扣。

4.3 消息队列:传递数据的最佳选择

信号量只能发“通知”,不能带数据。如果你需要把中断里采集到的数据传给任务,就得用消息队列。

举个例子:ADC中断采集到温度值,ISR把温度值塞进队列,任务从队列里取出来做滤波和显示。这样ISR只负责“放”,任务只负责“取”,互不干扰。

// ISR中发送数据
void ADC_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint32_t adc_value = ADC1->DR;
    xQueueSendFromISR(xAdcQueue, &adc_value, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中接收数据
void vAdcProcessTask(void *pvParameters)
{
    uint32_t received_value;
    while(1)
    {
        if(xQueueReceive(xAdcQueue, &received_value, portMAX_DELAY) == pdTRUE)
        {
            vFilterAndDisplay(received_value);
        }
    }
}

这里有个坑:队列长度要设置合理。我见过有人把队列长度设成1,结果中断来得快,队列满了就丢数据。建议根据中断频率和任务处理速度,留出足够的缓冲。

通信方式 适用场景 数据量 典型延迟
二值信号量 简单事件通知 无数据 最低
计数信号量 资源计数、事件计数 计数值 低
消息队列 数据传递 多字节 中等
事件标志组 多条件同步 位标志 低

4.4 事件标志组:处理“多条件”中断

有时候一个中断需要触发多个任务,或者一个任务需要等待多个中断条件都满足。这时候事件标志组就派上用场了。

我记得在一个多传感器数据采集项目里,系统需要等温度、湿度、气压三个传感器都采集完毕,才启动数据融合任务。用事件标志组,每个传感器中断设置自己的标志位,融合任务等待所有标志位都置1再执行。

// ISR中设置事件位
void TEMP_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xEventGroupSetBitsFromISR(xEventGroup, TEMP_BIT, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中等待多个事件
void vFusionTask(void *pvParameters)
{
    EventBits_t uxBits;
    while(1)
    {
        uxBits = xEventGroupWaitBits(
            xEventGroup,
            TEMP_BIT | HUMI_BIT | PRESS_BIT,
            pdTRUE,  // 退出时清除标志位
            pdTRUE,  // 等待所有位都置位
            portMAX_DELAY
        );
        vDataFusion();
    }
}
核心要点:事件标志组的优势在于“按位组合”。你可以用AND模式等待所有条件满足,也可以用OR模式等待任意一个条件触发。这在复杂的中断协同场景下非常灵活。

4.5 中断与任务通信的完整流程

说了这么多,咱们画张图把整个流程串起来。从GPIO引脚电平变化,到ISR处理,再到任务响应,每一步都有讲究。

RTOS中断与任务通信流程 GPIO引脚中断 ISR处理 去抖动 + 记录时间戳 信号量 消息队列 事件标志组 任务处理 解析数据 / 执行动作 关键原则: 1. ISR只做最轻量的工作:去抖动、记录时间戳、发送信号/数据 2. 所有业务逻辑放在任务中处理,任务可以阻塞等待 3. 使用FromISR后缀的API,并正确处理任务切换请求

4.6 避坑指南:我踩过的几个雷

做了这么多年嵌入式,中断通信这块的坑我基本都踩过。分享几个典型的:

  • 信号量“饿死”问题:我曾经在一个项目中,高优先级任务频繁获取信号量,低优先级任务一直拿不到。后来加了优先级继承或使用队列才解决。
  • 队列溢出丢数据:ADC中断频率200kHz,队列长度只设了10。结果数据大量丢失。后来根据“最大中断间隔 × 任务处理时间”算出了合理队列深度。
  • 事件标志位没清零:有一次调试,任务总是莫名其妙被唤醒。查了半天发现事件标志组没设置“退出时自动清除”。这个细节很容易忽略。
特别注意:在ISR中调用 portYIELD_FROM_ISR() 时,一定要检查 xHigherPriorityTaskWoken 是否为 pdTRUE。如果不做这个判断,每次ISR都强制任务切换,系统性能会大幅下降。

4.7 性能调优:响应时间与吞吐量的平衡

最后聊点实战调优。中断响应时间和系统吞吐量往往是一对矛盾。我个人习惯这样权衡:

  • 追求低延迟:用信号量 + 高优先级任务。ISR释放信号量后立即切换,任务几乎零延迟响应。代价是其他任务可能被饿死。
  • 追求高吞吐:用消息队列 + 中等优先级任务。ISR只管往队列里塞数据,任务按自己的节奏处理。适合数据采集类应用。
  • 追求灵活性:用事件标志组 + 多任务协同。适合需要多个条件触发的复杂场景。

嗯,这里要注意:没有万能的方案。我一般会先画一张时序图,把中断频率、任务处理时间、允许的最大延迟标出来,再选通信方式。纸上谈兵总比焊板子改代码来得快。