一个中断把整个系统搞死了——FreeRTOS优先级踩坑实录

void UART_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    vTaskNotifyGiveFromISR(xTaskToNotify, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

这代码看起来没啥问题对吧?网上教程都这么写,官方demo也这么写。

之前一个项目里,整个系统跑了大概十几分钟就死一次——不是崩溃,是卡死,所有任务集体罢工,只剩那个最高优先级的UART处理任务在疯狂跑。但问题是,UART上根本没数据进来。

折腾了两天我才反应过来——问题根本就不在代码逻辑上,在任务优先级安排上。

递归优先级地狱

我那个系统的任务优先级大概是这样的:

优先级 任务
5 (最高) uart_task
4 sensor_task
3 led_task
2 watchdog_task
1 idle_task

uart_task我给了最高优先级,因为要响应串口命令嘛,怕丢数据。sensor_task跑传感器采集,led_task只是闪个灯。看起来很合理对吧?

问题是uart_task里我用了vTaskDelay(10)——就是跑完一轮处理逻辑之后delay 10ms再继续。结果你猜怎么着?因为它是最高优先级,delay结束了还是它跑,别的任务根本抢不过它。

但这不是死机的原因,真正的原因是中断。

传感器用的是I2C挂了个外部中断脚,每次数据准备好就触发中断。中断服务函数里调了xTaskNotifyFromISR,通知sensor_task去读数据。

一切看起来都很正常,直到我想来想去,拿逻辑分析仪看了一眼那个中断脚——

真相是中断在狂喷

中断脚每秒触发将近1000次。每次触发,ISR都试图唤醒sensor_task。但sensor_task优先级只有4,而uart_task优先级5一直占着CPU。sensor_task根本跑不了,它的notify pending flag就永远为true。每次中断再来,xTaskNotifyFromISR又在pending的基础上再加notification。

你以为这最多就是浪费点CPU?不,要命的是——FreeRTOS在调度器里有个逻辑:如果一个task有pending notification,并且它的优先级高于当前运行的任务,调度器会强行切换到它。问题是我所有中断都在UART_IRQHandler里,这个handler跑在中断上下文中,调度器只在中断退出时做一次context switch。

好戏来了:中断结束时,调度器一看有任务要切换,切出来,发现uart_task优先级最高,又切回uart_task。sensor_task虽然也有pending notification,但优先级不够,始终抢不到CPU。

但中断还在不断地触发。

FreeRTOS的tick中断会定期检查有没有更高优先级的任务就绪——但uart_task本来就已经是最高了。所以tick来了也白来,什么也不做。

然而问题出现在一个隐蔽的地方:xTaskNotifyGiveFromISR如果被频繁调用,会导致notification value溢出(不是数值溢出,是内部的pending状态机卡住)。FreeRTOS的notification机制在taskNOTIFICATION_WAITINGtaskNOTIFICATION_RECEIVING之间来回跳,在高频中断下会产生一种"死锁"效果——task的TCB被标记为pending,但调度器永远无法正确地把它切进来。

所以最后的现象就是:这个任务既不在running状态,也不在ready状态,卡在两个状态的缝隙里。系统就"死"了。

怎么救的

三个改动:

第一,降低uart_task优先级。

降到2,跟watchdog_task平级。这样sensor_task(优先级4)在收到notification时能被调度器切进去。uart_task靠delay让出CPU,delay结束再抢回来——反正串口数据有buffer,十毫秒的延迟死不了人。

第二,中断里加去抖。

void EXTI_Sensor_IRQHandler(void)
{
    static uint32_t last_trigger = 0;
    uint32_t now = HAL_GetTick();
    if (now - last_trigger < 10) return;  // 10ms去抖
    last_trigger = now;

    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    vTaskNotifyGiveFromISR(xSensorTaskHandle, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

传感器数据刷新率才20Hz,中断每秒1000次显然是干扰。加了去抖之后正常了。

第三,终极武器——在ISR里不要直接用notification唤醒高优先级任务,改成用队列。

void EXTI_Sensor_IRQHandler(void)
{
    uint8_t dummy = 1;
    xQueueSendFromISR(xSensorQueue, &dummy, NULL);
}

任务那头在队列上阻塞等待:

void vSensorTask(void *pvParameters)
{
    uint8_t dummy;
    while(1) {
        if(xQueueReceive(xSensorQueue, &dummy, portMAX_DELAY) == pdPASS) {
            // 读传感器
            HAL_I2C_Master_Receive(...);
        }
    }
}

队列的好处是,它不会像notification那样在高频场景下产生pending状态机的混乱。队列要么有数据要么没有,状态单一,不存在"半pending"这种尴尬情况。

一些后话

改完之后跑了三天没出问题。后来我把所有用到xTaskNotifyGiveFromISR的地方都重新审了一遍——发现有3个地方也有类似的隐患。那两个地方因为中断频率低所以没出问题,但天晓得哪天客户环境一变就炸了。

后来我跟一个做RTOS内核的朋友聊,他说其实FreeRTOS的notification设计初衷是"轻量单次通知",官方文档里也写了"only one notification state per task"——一个task只有1个notification状态位。高频触发的时候两个中断之间可能产生race,而队列是FIFO buffer,天然能扛住burst。

所以我现在有个习惯:中断频率 > 100Hz的场景,一律用队列,不用notification。 低频的触发(按键、报警量)才用notification。

其实吧,FreeRTOS的坑远不止这一个。光任务优先级这玩意儿,你在纸上画怎么都画得通,一上硬件就各种想不到的情况。我后来养了个习惯——每次调优先级都开着串口打印任务状态,用vTaskList或者uxTaskGetSystemState看一下每个任务的运行时间占比。看到某个任务占了99%的CPU但干的活很少的时候,心里就有数了。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐