FreeRTOS 任务调度与优先级反转实战:一次”偶发卡死”的定位全过程

2026年9月13日 0 By admin

做裸机转 RTOS 的这些年,我最深的体会是:FreeRTOS 入门一天,调通三个月。任务能跑起来不代表调度没问题,很多问题要等量产之后、在某些极端时序下才暴露。这篇文章记录我遇到的一次经典问题——优先级反转导致的偶发卡死,以及我后来总结的一套调度相关的设计检查清单。

问题现场:偶发的”系统没反应”

那是一个多传感器采集设备,架构很常规:


xTaskCreate(task_sensor,  "sensor",  512, NULL, 4, NULL);  /* 采集 */
xTaskCreate(task_process, "process", 1024, NULL, 3, NULL); /* 处理 */
xTaskCreate(task_log,     "log",     512, NULL, 2, NULL);  /* 打日志 */
xTaskCreate(task_idle_check, "watch", 256, NULL, 1, NULL); /* 看门狗喂狗 */

现象是:设备运行几小时到几天后,随机出现”采集停了但设备没复位”。现场复现极其困难,接上调试器又一切正常——典型的 Heisenbug

第一步:别急着猜,先让死因可见

我的习惯是先加观测手段,而不是逐行看代码。这里用了两个手段:

  1. 喂狗任务检测各任务心跳:每个任务定期刷新自己的时间戳,看门狗任务检查超时并记录哪个任务先停。
  1. 保留 vTaskList() 输出:卡死瞬间通过串口把任务状态、优先级、栈水位打出来。

结果很快出来了:process 任务永远处于 Blocked,sensor 任务正常刷新心跳,但共享缓冲区再也没人消费。

根因:教科书级别的优先级反转

代码里,process(优先级 3)和 log(优先级 2)都要访问日志缓冲区,用的是互斥量:


/* log 任务(优先级 2):长时间持有日志锁 */
void task_log(void *arg)
{
    for (;;) {
        xSemaphoreTake(log_mutex, portMAX_DELAY);
        uart_write_flush(log_buf);        /* 阻塞发送,耗时可达几十 ms */
        xSemaphoreGive(log_mutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

/* process 任务(优先级 3):等日志锁 */
void task_process(void *arg)
{
    for (;;) {
        xSemaphoreTake(log_mutex, portMAX_DELAY); /* 卡在这里 */
        log_write(...);
        xSemaphoreGive(log_mutex);
        ...
    }
}

还有一个中间优先级 4 的 sensor 任务,它不需要日志锁,但计算密集。于是时序变成:


log(2) 持有锁 → process(3) 等锁 → sensor(4) 抢占 CPU
→ log(2) 得不到 CPU,无法释放锁
→ process(3) 被 sensor(4) 无限期压制

低优先级任务持锁,高优先级任务等锁,中间优先级任务把持锁者饿死——这就是 Mars Pathfinder 当年的同款问题。为什么平时不复现?因为 uart_write_flush 大多数时候很快发完,只有串口线长、波特率低、对端流控等条件凑齐时,锁持有时间才长到被 sensor 撞上。

修复方案(按推荐顺序)

方案一:开启优先级继承(FreeRTOS 默认已支持)

xSemaphoreCreateMutex() 创建的互斥量自带优先级继承:高优先级任务等锁时,持锁的低优先级任务会被临时提升。我们的代码用的确实是 mutex,为什么没生效?

排查发现产品配置里没开这个:


/* FreeRTOSConfig.h */
#define configUSE_MUTEXES              1
#define configUSE_PRIORITY_INHERITANCE 1   /* 有的版本叫这个,新版默认开启 */

更关键的是:持锁期间绝对不能有阻塞调用uart_write_flush 在锁内阻塞发送,即使有优先级继承,几十 ms 的持锁时间依然是灾难。修复后:


void task_log(void *arg)
{
    for (;;) {
        int len = dequeue_log(log_buf);    /* 先拷出来 */
        uart_write_flush(log_buf, len);    /* 锁外慢速发送 */
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

持锁只做内存拷贝,耗时微秒级,反转窗口直接消失。

方案二:队列替代共享锁

其实日志这个场景根本不需要互斥量——它是典型的生产者-消费者,用队列最干净:


QueueHandle_t log_queue;

/* 生产者:只投递,永不阻塞在锁上 */
void log_write(const char *s)
{
    xQueueSend(log_queue, s, 0);   /* 满了就丢,日志可容忍 */
}

/* 消费者:独占缓冲区,天然无竞争 */
void task_log(void *arg)
{
    char buf[128];
    for (;;) {
        if (xQueueReceive(log_queue, buf, portMAX_DELAY) == pdTRUE)
            uart_write_flush(buf, strlen(buf));
    }
}

我的经验法则:能用队列解决的并发,不要用互斥量。队列把”谁在什么时刻持有锁”这个心智负担整个消掉了。

方案三:用二值信号量要注意——它没有优先级继承

很多老代码用 xSemaphoreCreateBinary() 做互斥,这是明确的反模式:二值信号量不继承优先级,反转必然存在。信号量的语义是”事件通知”,不是”资源保护”。改造老代码时遇到二值信号量当锁用的,一律换 mutex。

由此总结的 RTOS 调度设计清单

这次事故之后,我给团队定了几条硬规矩,review 时逐条过:

  1. 持锁代码必须短小、无阻塞:锁内禁止任何可能阻塞的调用(UART、Flash 擦写、vTaskDelay、队列带超时的收发)。
  1. 保护资源用 mutex,通知事件用信号量,二者不可混用。
  1. 能队列不互斥:生产者-消费者模型优先用队列。
  1. 警惕中间优先级:任何持锁任务,检查它和”等锁者”之间是否存在不需要该锁但 CPU 密集的任务。
  1. 开 configUSE_TIME_SLICING 并理解同优先级轮转:同优先级任务的相互影响也要评估。
  1. 栈和优先级留文档:每个任务一行注释说明为什么是这个优先级,后来者才知道能不能动。

附:快速验证手法

改完之后怎么确认反转窗口真的消失了?一个土办法但很有效:写一个”捣乱任务”主动制造最坏时序:


void task_chaos(void *arg)
{
    for (;;) {
        volatile uint32_t x = 0;
        for (int i = 0; i < 2000000; i++) x += i; /* 纯 CPU 空转 */
        vTaskDelay(1);
    }
}

把它放到所有业务任务之间的优先级上跑一晚上,如果系统不卡,基本可以认为反转窗口已消除。压力测试不是测性能,是测时序的最坏情况

RTOS 的坑大多不在 API,而在对调度时序的想象力不足。把”谁会阻塞谁”想清楚,一半的问题就不会发生。下一篇打算写 FreeRTOS 栈溢出的三种检测手段与踩坑,欢迎交流。