FreeRTOS 任务调度与优先级反转实战:一次”偶发卡死”的定位全过程
做裸机转 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。
第一步:别急着猜,先让死因可见
我的习惯是先加观测手段,而不是逐行看代码。这里用了两个手段:
- 喂狗任务检测各任务心跳:每个任务定期刷新自己的时间戳,看门狗任务检查超时并记录哪个任务先停。
- 保留 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 时逐条过:
- 持锁代码必须短小、无阻塞:锁内禁止任何可能阻塞的调用(UART、Flash 擦写、vTaskDelay、队列带超时的收发)。
- 保护资源用 mutex,通知事件用信号量,二者不可混用。
- 能队列不互斥:生产者-消费者模型优先用队列。
- 警惕中间优先级:任何持锁任务,检查它和”等锁者”之间是否存在不需要该锁但 CPU 密集的任务。
- 开 configUSE_TIME_SLICING 并理解同优先级轮转:同优先级任务的相互影响也要评估。
- 栈和优先级留文档:每个任务一行注释说明为什么是这个优先级,后来者才知道能不能动。
附:快速验证手法
改完之后怎么确认反转窗口真的消失了?一个土办法但很有效:写一个”捣乱任务”主动制造最坏时序:
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 栈溢出的三种检测手段与踩坑,欢迎交流。