第三章 任务管理与调度:让多个任务有序运行

任务管理,说白了就是RTOS的「交警」。它决定哪个任务先跑、哪个任务等着、哪个任务该睡觉。我刚开始接触RTOS时,总觉得任务调度不就是个循环吗?后来在项目中吃了亏才明白——调度策略选错了,系统可能直接崩掉。

这一章,我们重点聊三个东西:任务状态机、任务创建/删除/挂起/恢复、调度策略。嗯,都是实战中天天要用的。

3.1 任务状态机:任务的一生

每个任务在RTOS里都有生命周期。不是简单的「跑或不跑」,而是有多个状态。我习惯把任务状态机画成一张图,贴在工位上。你想想看,一个任务从创建到销毁,会经历哪些状态?

核心状态(以FreeRTOS为例):

  • 就绪(Ready):任务已就绪,只等CPU分配时间片
  • 运行(Running):任务正在占用CPU
  • 阻塞(Blocked):任务在等待某个事件(延时、信号量、队列等)
  • 挂起(Suspended):任务被显式挂起,不参与调度
RTOS任务状态机 就绪 (Ready) 运行 (Running) 阻塞 (Blocked) 挂起 (Suspended) 调度器选择 时间片用完/抢占 等待事件 事件到达 vTaskSuspend() vTaskResume() 创建 (Created) xTaskCreate() 删除 (Deleted) vTaskDelete()

我的经验:调试任务卡死时,先查任务状态。如果任务一直处于就绪但不运行,大概率是优先级问题;如果任务卡在阻塞状态,检查它等的事件有没有人发。

3.2 任务创建与删除

任务创建是RTOS的起点。每个任务都需要一个独立的栈空间和任务控制块(TCB)。我见过不少新手把栈开得特别大,结果内存不够用。其实栈大小要根据任务的实际调用深度来算。

3.2.1 任务创建

// FreeRTOS 任务创建示例
void vTaskFunction(void *pvParameters) {
    // 任务主体,通常是一个死循环
    for (;;) {
        // 做任务该做的事
        vTaskDelay(pdMS_TO_TICKS(100));  // 延时100ms
    }
}

void main(void) {
    TaskHandle_t xTaskHandle = NULL;
    
    // 创建任务
    xTaskCreate(
        vTaskFunction,      // 任务函数指针
        "Task1",            // 任务名称(调试用)
        256,                // 栈深度(单位:字,不是字节)
        NULL,               // 任务参数
        2,                  // 优先级(数值越大优先级越高)
        &xTaskHandle        // 任务句柄(可选)
    );
    
    // 启动调度器
    vTaskStartScheduler();
}

注意:任务函数绝对不能返回!如果函数执行完毕,系统会调用vTaskDelete(NULL)自行删除任务。但如果你在任务里用了return,后果很严重——系统会崩溃。我曾经在一个项目里犯过这个错,排查了两天才找到原因。

3.2.2 任务删除

任务删除有两种场景:任务自己删除自己,或者别的任务删除它。我建议尽量让任务自己结束生命周期,这样更安全。

// 任务自己删除自己
void vTaskFunction(void *pvParameters) {
    for (;;) {
        if (/* 任务完成条件 */) {
            vTaskDelete(NULL);  // 传入NULL表示删除自己
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// 其他任务删除此任务
void vOtherTask(void *pvParameters) {
    // 假设xTaskHandle是全局的
    if (xTaskHandle != NULL) {
        vTaskDelete(xTaskHandle);
        xTaskHandle = NULL;  // 记得置空,防止野指针
    }
}

避坑指南:删除任务后,任务占用的栈空间会被回收,但任务申请的动态内存(如malloc)不会自动释放。我曾经在删除任务前忘了释放一块缓冲区,结果内存泄漏,系统跑了三天后挂了。所以,删除前记得清理资源。

3.3 任务挂起与恢复

挂起和阻塞不一样。阻塞是任务主动等待事件,挂起是外部强制让任务「睡觉」。挂起的任务不参与调度,直到被显式恢复。

操作 API函数 说明
挂起任务 vTaskSuspend(TaskHandle_t) 任务进入挂起状态,不参与调度
恢复任务 vTaskResume(TaskHandle_t) 任务从挂起状态回到就绪状态
从中断恢复 xTaskResumeFromISR(TaskHandle_t) 在中断服务函数中恢复任务
// 挂起与恢复示例
TaskHandle_t xSensorTaskHandle = NULL;

void vSensorTask(void *pvParameters) {
    for (;;) {
        // 读取传感器数据
        uint32_t sensor_val = read_sensor();
        process_data(sensor_val);
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

void vControlTask(void *pvParameters) {
    for (;;) {
        // 某些条件下挂起传感器任务
        if (/* 省电模式 */) {
            vTaskSuspend(xSensorTaskHandle);
        }
        
        // 条件满足时恢复
        if (/* 退出省电模式 */) {
            vTaskResume(xSensorTaskHandle);
        }
        
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

关键区别:

  • 阻塞:任务自己调用延时或等待事件,可以设置超时
  • 挂起:外部强制暂停任务,没有超时机制,必须显式恢复

3.4 调度策略:谁先跑?跑多久?

调度策略是RTOS的核心。选错了,系统响应慢;选对了,任务切换丝滑。我见过有人把优先级设得乱七八糟,结果高优先级任务一直占着CPU,低优先级任务饿死了。

3.4.1 优先级抢占调度

这是最常用的调度策略。每个任务有一个优先级,数值越大优先级越高。调度器永远运行当前就绪态中优先级最高的任务。

举个例子:

// 三个任务,优先级不同
void vHighTask(void *pvParameters) {  // 优先级 3
    for (;;) {
        // 高优先级任务
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void vMidTask(void *pvParameters) {   // 优先级 2
    for (;;) {
        // 中等优先级任务
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

void vLowTask(void *pvParameters) {   // 优先级 1
    for (;;) {
        // 低优先级任务
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

运行逻辑是这样的:

  1. 三个任务都就绪时,vHighTask先跑
  2. vHighTask阻塞(延时)后,vMidTask跑
  3. vMidTask阻塞后,vLowTask跑
  4. vHighTask延时结束,立即抢占当前任务

优先级反转:这是抢占式调度的经典问题。低优先级任务持有高优先级任务需要的资源,导致高优先级任务被阻塞。我曾经在一个电机控制项目里遇到这个问题,电机响应延迟了200ms,差点把设备烧了。解决方案是优先级继承或使用互斥量(Mutex)。

3.4.2 时间片轮转调度

同优先级任务之间,用时间片轮转。每个任务跑一个固定时间片(通常是1-10ms),时间到了就切换到下一个同优先级任务。

特性 优先级抢占 时间片轮转
适用场景 任务优先级差异明显 同优先级任务需要公平调度
CPU分配 高优先级任务优先 同优先级任务均分时间
实时性 高(响应快) 中等(取决于时间片大小)
典型配置 configUSE_PREEMPTION = 1 configUSE_TIME_SLICING = 1
// FreeRTOSConfig.h 中的调度配置
#define configUSE_PREEMPTION    1   // 启用抢占式调度
#define configUSE_TIME_SLICING  1   // 启用时间片轮转
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1  // 硬件优化任务选择

我的建议:大多数嵌入式系统用优先级抢占就够了。时间片轮转适合那些没有明显优先级差异、但都需要公平CPU时间的任务。比如,多个数据采集任务,优先级相同,用时间片轮转让它们轮流采集。

3.5 实战中的调度策略选择

选调度策略,说白了就是回答两个问题:

  1. 任务之间有没有明确的优先级关系?有 -> 用抢占式
  2. 同优先级任务需要公平调度吗?需要 -> 开启时间片轮转

我做过一个物联网网关项目,里面有三种任务:

  • 通信任务(优先级5):处理网络数据,要求低延迟
  • 传感器任务(优先级3):采集数据,可以容忍一定延迟
  • 日志任务(优先级1):写SD卡,不着急

这种场景,抢占式调度是唯一选择。通信任务必须优先响应,否则丢包。日志任务优先级最低,有空再写。

总结一下调度策略的核心原则:

  • 实时性要求高的任务,给高优先级
  • 同优先级任务,用时间片轮转保证公平
  • 避免优先级反转,用互斥量或优先级继承
  • 不要把所有任务都设成同一个优先级——那就成裸机轮询了

嗯,任务管理和调度这块,说难不难,说简单也不简单。关键是多动手、多调试。我刚开始用RTOS时,光任务状态就调了好几个晚上。但一旦理解了状态机的流转逻辑,后面就顺了。