任务管理基础:任务状态机与调度策略

任务管理,说白了就是RTOS的“心脏”。你写的所有业务逻辑,最终都要拆成一个一个的任务,交给内核去调度。我刚开始接触RTOS时,总觉得任务状态切换是个理论概念,直到有一次调试一个跑飞了的系统,才真正体会到——不理解状态机,你连Bug都找不到地方下手。

任务状态机:就绪/运行/阻塞/挂起

一个任务在它的生命周期里,会在几个状态之间跳来跳去。常见的RTOS一般定义四种基础状态:就绪(Ready)、运行(Running)、阻塞(Blocked)和挂起(Suspended)。有些内核还会细分出“休眠态”或“初始态”,但核心就是这四个。

核心要点:任何时刻,CPU上只能有一个任务处于运行态。其他任务要么等着被调度,要么在等某个事件,要么被显式暂停。

任务状态机核心流转图 就绪 (Ready) 运行 (Running) 阻塞 (Blocked) 挂起 (Suspended) 调度器选择 时间片耗尽/抢占 等待事件/资源 事件到达/超时 vTaskSuspend() vTaskResume() 也可从阻塞挂起 说明:实线为主要流转路径,虚线为可选路径。挂起态只能由其他状态显式进入或退出。

为什么会这样设计?你想想看,如果任务没有阻塞态,那它只能靠“忙等”来消耗CPU,这在实时系统里简直是灾难。我做过一个电机控制项目,一开始用轮询方式等编码器信号,结果CPU占用率直接飙到90%,其他任务全被饿死。后来改成阻塞等待中断事件,CPU占用率降到了15%。

任务优先级与调度策略

优先级是RTOS调度器的“指挥棒”。每个任务在创建时都会被赋予一个优先级,数值越小(或越大,取决于内核实现)表示优先级越高。调度器每次都会从就绪队列中挑出优先级最高的任务来运行。

调度策略 核心规则 典型场景
抢占式调度 高优先级任务就绪时,立即打断当前运行的低优先级任务 大多数RTOS默认策略,如FreeRTOS、uC/OS
时间片轮转 同优先级任务轮流获得固定时间片 多个同等重要的周期性任务
协作式调度 任务主动让出CPU,否则一直运行 极简系统或特定安全场景

个人经验:我建议你在设计初期就把优先级规划好,留出1-2个空档位。我曾经因为优先级排得太满,后期加一个紧急任务时不得不重构整个优先级表,那叫一个痛苦。

这里有个常见的坑:优先级反转。低优先级任务持有了高优先级任务需要的资源,导致高优先级任务被阻塞。解决方法是使用优先级继承协议或优先级天花板协议。嗯,这个后面讲互斥量时会细说。

任务创建与删除API实战

光说不练假把式。我们拿FreeRTOS的API来走一遍,其他RTOS大同小异。

任务创建:xTaskCreate()

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,      // 任务函数指针
    const char * const pcName,      // 任务名称(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字(word)
    void *pvParameters,             // 传递给任务函数的参数
    UBaseType_t uxPriority,         // 优先级
    TaskHandle_t *pxCreatedTask     // 返回的任务句柄
);

参数看起来多,其实就六样东西。我重点说两个容易翻车的地方:

  • 栈深度:单位是“字”,不是字节。在32位MCU上,一个“字”是4字节。如果你写1024,实际分配的栈空间是4096字节。我见过有人把栈深度写成256字节,结果任务一跑就栈溢出,系统直接挂掉。
  • 任务句柄:如果你不需要在其他地方操作这个任务(比如删除或挂起),可以传NULL。但我建议你始终保留句柄,调试时能救命。

避坑指南:我曾经在STM32F103上写了一个任务,栈深度只给了128字(512字节),任务里调用了sprintf()格式化一个长字符串。sprintf的栈开销非常大,直接导致栈溢出,系统随机死机。查了两天才定位到问题。从那以后,我每个任务创建后都会先跑一次栈使用量统计。

任务删除:vTaskDelete()

void vTaskDelete(TaskHandle_t xTaskToDelete);

参数传NULL表示删除自己。删除任务时要注意:

  • 被删除任务占用的栈和TCB(任务控制块)会被内核回收。
  • 如果任务持有互斥量、信号量等资源,删除前必须释放,否则会造成资源泄漏。
  • 删除操作不会立即释放内存给系统堆,而是归还给内核内部的内存池。

实战示例:创建两个任务并相互切换

void vTask1(void *pvParameters) {
    while(1) {
        // 任务1的业务逻辑
        vTaskDelay(pdMS_TO_TICKS(1000)); // 阻塞1000ms
    }
}

void vTask2(void *pvParameters) {
    while(1) {
        // 任务2的业务逻辑
        vTaskDelay(pdMS_TO_TICKS(500));  // 阻塞500ms
    }
}

void main(void) {
    // 初始化硬件和内核
    prvSetupHardware();
    
    TaskHandle_t xHandle1 = NULL;
    TaskHandle_t xHandle2 = NULL;
    
    // 创建任务,优先级分别为2和1
    xTaskCreate(vTask1, "Task1", 256, NULL, 2, &xHandle1);
    xTaskCreate(vTask2, "Task2", 256, NULL, 1, &xHandle2);
    
    // 启动调度器
    vTaskStartScheduler();
    
    // 正常情况下不会执行到这里
    while(1);
}

这个例子很简单,但包含了任务创建、阻塞延时和调度器启动的完整流程。任务1优先级高,但它调用了vTaskDelay()主动阻塞,所以任务2也能得到运行机会。这就是阻塞态的意义——让CPU不被一个任务独占。

关键点:vTaskDelay()让任务进入阻塞态,而不是忙等。调度器会在这段时间里运行其他就绪任务。这是RTOS和裸机轮询最大的区别。

任务删除在实际产品中用得不多,因为大多数任务一旦创建就会一直运行。但在动态加载模块或OTA升级场景中,删除旧任务、创建新任务是常规操作。记得在删除前做好资源清理,否则内存泄漏会让你头疼。