任务管理基础:任务创建与删除

各位同学,今天我们来聊聊FreeRTOS最核心的东西——任务管理。说白了,任务就是RTOS里干活的最小单元。你想想看,一个单片机要同时处理LED闪烁、串口打印、按键扫描,没有任务管理,全靠一个大循环硬撑,那代码写起来得多痛苦?

我个人习惯,拿到一个新项目,第一件事就是先把任务框架搭好。任务怎么创建、怎么删除、优先级怎么配、堆栈给多大,这些基础打不牢,后面调试起来会非常头疼。我记得刚用FreeRTOS那会儿,就因为堆栈配小了,程序跑着跑着就莫名其妙复位,查了整整两天才找到原因。

xTaskCreate:任务的诞生

创建任务,用的是 xTaskCreate() 这个API。它的原型长这样:

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

这里有个坑,我必须要提醒你。usStackDepth 的单位是「字」,不是字节!在32位MCU上,一个字是4字节。所以如果你写 128,实际分配的堆栈大小是 128 * 4 = 512 字节。我曾经见过有人把这个当字节写,结果堆栈溢出,程序跑飞,查了半天才反应过来。

注意:堆栈大小不要拍脑袋定。我的经验是:先给一个保守值(比如256字),跑起来后用 uxTaskGetStackHighWaterMark() 查看实际使用量,再调整到合适的值。这样既省内存,又不会溢出。

vTaskDelete:任务的终结

有创建就有删除。vTaskDelete() 用来删除一个任务。调用后,任务会被从就绪列表、阻塞列表等所有内核数据结构中移除。但要注意,任务自己占用的堆栈和TCB(任务控制块)所占的内存,不会自动释放

为什么会这样?因为FreeRTOS不知道你用的是动态内存分配还是静态内存分配。如果你是用 pvPortMalloc 分配的,那删除任务后需要手动调用 vPortFree 释放。嗯,这里要特别小心,我曾经在一个项目中频繁创建和删除任务,结果内存泄漏,跑了三天后系统挂了。

// 创建任务
TaskHandle_t xLEDTaskHandle = NULL;
xTaskCreate(vLEDTask, "LED", 128, NULL, 1, &xLEDTaskHandle);

// 删除任务
if (xLEDTaskHandle != NULL) {
    vTaskDelete(xLEDTaskHandle);
    xLEDTaskHandle = NULL;  // 记得置空,避免野指针
}
小技巧:任务句柄用完记得置NULL。不然任务删了,句柄还指向原来的内存地址,就成了野指针。这个习惯我在吃过一次亏后就养成了。

任务优先级与堆栈配置

优先级,说白了就是任务的「插队权」。数值越大,优先级越高。FreeRTOS支持抢占式调度,高优先级任务就绪时,会立即抢占低优先级任务的CPU使用权。

配置优先级时,我建议你遵循一个原则:够用就好。不要为了省事把所有任务都设成同一个优先级,也不要搞太多优先级层级。一般来说,3-5个优先级就够用了。优先级太多,调度开销会变大,而且容易出优先级反转的问题。

优先级 典型用途 堆栈建议
最高(如5) 时间敏感任务(如电机控制) 256-512字
中等(如3) 周期性任务(如LED闪烁) 128-256字
最低(如1) 后台任务(如串口打印) 128-256字

堆栈配置这块,我踩过的坑最多。你想想看,任务函数里调用的函数、局部变量、中断嵌套,都会消耗堆栈。特别是用printf这类函数,堆栈消耗非常大。我一般会给串口打印任务多分配一些堆栈,比如256字起步。

任务状态转换:运行、就绪、阻塞、挂起

FreeRTOS的任务有四种状态,理解它们之间的转换关系,是掌握任务调度的关键。

  • 运行态(Running):正在使用CPU。单核MCU上,同一时刻只有一个任务处于运行态。
  • 就绪态(Ready):任务可以运行,但CPU被更高优先级的任务占着。说白了就是「排队等CPU」。
  • 阻塞态(Blocked):任务在等待某个事件,比如延时到期、队列有数据、信号量可用。此时任务不参与调度,不消耗CPU。
  • 挂起态(Suspended):通过 vTaskSuspend() 主动让任务休眠。只有调用 vTaskResume() 才能唤醒。

状态转换的典型场景是这样的:

  1. 任务A创建后进入就绪态。
  2. 调度器选中任务A,它进入运行态。
  3. 任务A调用 vTaskDelay(100),进入阻塞态。
  4. 100ms后延时到期,任务A回到就绪态。
  5. 如果此时没有更高优先级的任务,任务A再次进入运行态。

嗯,这里要注意一个细节:vTaskDelay() 是相对延时,从调用时刻开始算。而 vTaskDelayUntil() 是绝对延时,适合做周期性任务,不会因为任务执行时间抖动而累积误差。

实战:创建LED闪烁与串口打印任务

光说不练假把式。我们直接上手写一个实际例子。这个项目里,我创建两个任务:一个负责LED闪烁,一个负责串口打印。LED任务优先级设为2,串口任务优先级设为1。

#include "FreeRTOS.h"
#include "task.h"

// LED闪烁任务
void vLEDTask(void *pvParameters) {
    (void)pvParameters;  // 避免编译器警告
    
    // 初始化LED引脚(假设PA0)
    // GPIO_Init();
    
    for (;;) {
        // 点亮LED
        // GPIO_SetBits(LED_PIN);
        vTaskDelay(pdMS_TO_TICKS(500));  // 延时500ms
        
        // 熄灭LED
        // GPIO_ResetBits(LED_PIN);
        vTaskDelay(pdMS_TO_TICKS(500));  // 延时500ms
    }
}

// 串口打印任务
void vUARTTask(void *pvParameters) {
    (void)pvParameters;
    
    // 初始化串口
    // UART_Init(115200);
    
    uint32_t ulCount = 0;
    
    for (;;) {
        printf("系统运行时间: %lu 秒\r\n", ulCount++);
        vTaskDelay(pdMS_TO_TICKS(1000));  // 每秒打印一次
    }
}

int main(void) {
    // 硬件初始化
    // SystemClock_Config();
    // GPIO_Init();
    // UART_Init();
    
    // 创建任务
    xTaskCreate(vLEDTask, "LED", 128, NULL, 2, NULL);
    xTaskCreate(vUARTTask, "UART", 256, NULL, 1, NULL);
    
    // 启动调度器
    vTaskStartScheduler();
    
    // 正常情况下不会执行到这里
    for (;;);
}
核心要点:
  • LED任务优先级高(2),串口任务优先级低(1)。当LED任务阻塞时,串口任务才能运行。
  • 串口任务的堆栈给了256字,因为printf比较吃堆栈。LED任务128字就够了。
  • 两个任务都用了 vTaskDelay() 实现周期性执行,没有浪费CPU空转。

你可能会问:为什么LED任务优先级要设得比串口高?其实这取决于你的需求。如果LED闪烁的实时性要求高,比如做呼吸灯效果,那优先级就得高一些。如果只是简单指示,优先级低一点也无所谓。我个人习惯,把时间敏感的任务优先级设高,把后台打印类的任务优先级设低。

好了,任务管理的基础就讲到这里。下一章我们会深入任务间的通信机制,到时候你会看到,任务之间怎么传数据、怎么同步,那才是RTOS真正发挥威力的地方。