第四章 任务管理与调度:让系统真正“多任务”起来

任务调度,说白了就是操作系统决定“谁先跑、谁后跑、谁歇会儿”的规则。很多刚入行的朋友觉得调度就是配个优先级完事,其实坑多着呢。我见过一个产品,就因为优先级反转没处理好,整个系统卡死,最后只能按复位键——那场面,真叫一个尴尬。

这一章,咱们把任务创建、删除、挂起、恢复,优先级配置,调度策略,还有那个让人头疼的优先级反转与继承,一次性讲透。

4.1 任务的生命周期:创建、删除、挂起、恢复

一个任务从生到死,经历哪些状态?我习惯用一张图来记:

任务状态迁移图 就绪态 (Ready) 运行态 (Running) 阻塞态 (Blocked) 挂起态 (Suspended) 调度器选择 时间片用完/被抢占 等待资源/事件 资源就绪/事件发生 vTaskSuspend() vTaskResume() vTaskSuspend() 注意:挂起态是主动让任务“休眠”,不参与调度;阻塞态是等待资源,资源就绪后自动回到就绪态

4.1.1 任务创建

创建任务,每个RTOS都有自己的API。以FreeRTOS为例,核心函数就是xTaskCreate()。我习惯把任务栈大小设成4字节对齐,并且留够余量——曾经有个同事栈只给了128字节,结果任务跑起来就栈溢出,查了两天才找到原因。

// FreeRTOS 任务创建示例
TaskHandle_t xTaskHandle = NULL;

void vTaskFunction(void *pvParameters) {
    // 任务主体,通常是一个死循环
    for (;;) {
        // 做点有意义的事
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void vCreateMyTask(void) {
    BaseType_t xReturned;
    xReturned = xTaskCreate(
        vTaskFunction,       // 任务函数指针
        "MyTask",            // 任务名称(调试用)
        256,                 // 栈深度,单位是字(4字节)
        NULL,                // 参数
        2,                   // 优先级,数值越大优先级越高
        &xTaskHandle         // 任务句柄,用于后续操作
    );

    if (xReturned != pdPASS) {
        // 创建失败,通常是内存不足
        // 我建议这里加个断言或者错误日志
    }
}
小技巧:任务栈大小怎么估?我一般先给个保守值(比如512字),跑起来后用uxTaskGetStackHighWaterMark()看剩余栈空间,再逐步调小。别一开始就抠门,系统跑飞了更浪费时间。

4.1.2 任务删除

任务删除不是真的“销毁”,而是把任务从调度队列里移除,回收栈和TCB内存。注意:千万别在中断里直接删任务!

// 删除任务
void vDeleteMyTask(void) {
    if (xTaskHandle != NULL) {
        vTaskDelete(xTaskHandle);  // 传入NULL表示删除自己
        xTaskHandle = NULL;        // 好习惯:置空句柄
    }
}
警告:任务删除后,它持有的互斥量、信号量、动态分配的内存不会自动释放。我曾经踩过这个坑——删了一个任务,结果它占的互斥量没释放,其他任务全堵死了。所以删除前,务必手动释放所有资源。

4.1.3 任务挂起与恢复

挂起和阻塞不一样。挂起是“我主动不跑了,谁叫我都别理我”;阻塞是“我在等资源,资源来了我就继续”。

// 挂起任务
vTaskSuspend(xTaskHandle);   // 任务进入挂起态

// 恢复任务
vTaskResume(xTaskHandle);    // 任务回到就绪态

// 从中断恢复任务(更高效)
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskResumeFromISR(xTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);

挂起常用于低功耗场景:系统空闲时挂起所有非关键任务,只留一个低功耗管理任务。等需要干活了,再逐个恢复。

4.2 优先级配置:别让“高优先级”变成“高负担”

优先级是调度的核心。配置不对,系统要么响应慢,要么低优先级任务永远饿死。

优先级数值 含义(FreeRTOS) 典型用途
0 最低优先级(空闲任务) 系统空闲钩子、背景统计
1~3 低优先级 后台处理、日志记录、非实时显示
4~6 中优先级 普通控制逻辑、通信协议栈
7~9 高优先级 传感器采集、快速响应控制
10~configMAX_PRIORITIES-1 最高优先级 硬实时任务、中断辅助任务

我个人习惯:优先级尽量少用,够用就行。一个系统里优先级超过5级,调度逻辑就会变得复杂,调试也费劲。你想想看,10个优先级,每个任务都觉得自己很重要,结果就是高优先级任务把CPU吃满,低优先级任务永远轮不到。

核心原则:优先级只反映“紧急程度”,不反映“重要程度”。一个不紧急但重要的后台任务,优先级设低一点没关系,只要给它足够的时间片就行。

4.3 调度策略:抢占式 vs 时间片轮转

调度策略就两种主流:抢占式和时间片轮转。很多RTOS两者都支持,可以混用。

4.3.1 抢占式调度

抢占式,说白了就是“高优先级任务一来,低优先级任务立马让位”。这是实时系统的基石。

// FreeRTOS 配置抢占式调度(默认就是)
// FreeRTOSConfig.h
#define configUSE_PREEMPTION    1   // 1=抢占式,0=合作式

// 典型场景:高优先级任务就绪,立即抢占
void vHighPriorityTask(void *pvParameters) {
    for (;;) {
        // 处理紧急事件
        // 处理完后,可以主动让出CPU
        taskYIELD();
    }
}

抢占式的优点:实时性好,高优先级任务响应快。缺点:如果高优先级任务一直不阻塞,低优先级任务就永远没机会跑——这就是“优先级饿死”。

4.3.2 时间片轮转

时间片轮转,就是同等优先级的任务轮流跑,每人分一个时间片。时间片到了,自动切换到下一个。

// FreeRTOS 配置时间片轮转
#define configUSE_TIME_SLICING  1   // 1=启用时间片轮转
#define configTICK_RATE_HZ      1000 // 系统时钟节拍,单位Hz

// 时间片长度 = 1 / configTICK_RATE_HZ = 1ms
// 每个同优先级任务跑1ms,然后切换

时间片轮转适合那些“大家差不多重要”的场景。比如多个传感器数据采集任务,优先级相同,轮流采集,谁也不饿着谁。

我的经验:时间片别设太短。太短了上下文切换开销大,CPU都花在切换上了。我一般设1~10ms,具体看任务的计算量。如果任务计算量很小(比如几十微秒),那就别用时间片了,用抢占式+事件驱动更高效。

4.4 优先级反转与继承:一个让系统“假死”的经典问题

优先级反转,是实时系统里最隐蔽、最坑人的问题之一。我当年在一个无人机飞控项目里就遇到过——飞着飞着,电机突然停了,差点炸机。查了三天,最后发现是优先级反转导致高优先级任务拿不到互斥量。

4.4.1 什么是优先级反转?

看个经典场景:三个任务,优先级从高到低:H(高)、M(中)、L(低)。

  1. L任务拿到互斥量,开始干活。
  2. H任务就绪,抢占L,但H也需要同一个互斥量——拿不到,H被阻塞。
  3. M任务就绪,抢占L(因为M优先级比L高),M开始跑。
  4. M一直跑,L一直没机会释放互斥量,H就一直等。

结果:最高优先级的H,被中等优先级的M“间接”阻塞了。这就是优先级反转。

危害:优先级反转会导致高优先级任务的响应时间不可预测。严重时,高优先级任务可能被阻塞几秒甚至几分钟——这在实时系统里是致命的。

4.4.2 优先级继承:解决方案

优先级继承的思路很简单:当低优先级任务持有高优先级任务需要的互斥量时,临时把低优先级任务的优先级提升到和高优先级任务一样高。这样,低优先级任务就能尽快跑完、释放互斥量。

// FreeRTOS 使用优先级继承的互斥量
// 注意:不要用普通的二进制信号量,要用互斥量
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void vHighTask(void *pvParameters) {
    for (;;) {
        // 请求互斥量
        if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
            // 访问共享资源
            // ...
            xSemaphoreGive(xMutex);
        }
    }
}

void vLowTask(void *pvParameters) {
    for (;;) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 低优先级任务持有互斥量时,如果高优先级任务在等,
        // 低优先级任务会被临时提升优先级
        // 处理完赶紧释放
        xSemaphoreGive(xMutex);
    }
}
关键区别:
  • 二进制信号量:不支持优先级继承,容易导致优先级反转。
  • 互斥量:支持优先级继承,能有效避免优先级反转。

所以,保护共享资源时,我永远用互斥量,不用二进制信号量。除非你明确知道自己在做什么。

4.4.3 优先级继承的局限

优先级继承不是万能的。它只能解决“直接”优先级反转,解决不了“链式”优先级反转(多个互斥量嵌套)。链式反转需要更复杂的协议,比如优先级天花板协议。

不过说实话,在大多数嵌入式系统里,只要做到以下几点,优先级反转基本不会出现:

  • 尽量少用互斥量,能用队列传递数据就别共享内存。
  • 如果必须用互斥量,保持临界区代码极短(几十微秒级别)。
  • 避免互斥量嵌套——一个任务不要同时持有多个互斥量。
  • 高优先级任务尽量不要等低优先级任务的资源。

4.5 本章小结:调度调得好,系统稳如狗

任务调度看着简单,但坑都在细节里。我总结几个关键点:

  • 任务创建:栈大小留余量,用HighWaterMark调优。
  • 任务删除:先释放资源,再删任务。
  • 挂起/恢复:适合低功耗场景,别和阻塞搞混。
  • 优先级:够用就行,别超过5级。
  • 调度策略:抢占式保实时,时间片轮转保公平。
  • 优先级反转:用互斥量,别用二进制信号量。

嗯,这一章就到这儿。调度是RTOS的灵魂,把调度搞明白了,你的系统就成功了一半。