3. 任务创建与管理:xTaskCreate、任务优先级、任务删除与挂起

任务,是 FreeRTOS 里最核心的概念。说白了,你写的每个功能模块,最终都要包装成一个任务交给调度器去跑。我刚开始用 FreeRTOS 时,总觉得任务就是「一个死循环函数」,后来踩了坑才明白——任务的创建、优先级分配、删除和挂起,每一步都有讲究。

这一节,我们就来把这些东西彻底讲透。

3.1 xTaskCreate:任务的「出生证明」

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

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

嗯,参数不少。但别怕,我一个个说。

3.1.1 任务函数长什么样?

任务函数必须是一个永不返回的死循环。为什么?因为 FreeRTOS 调度器不会帮你清理任务退出后的资源。如果你让任务函数 return 了,系统大概率会崩溃。

void vTaskLED(void *pvParameters)
{
    // 初始化硬件
    gpio_set_direction(GPIO_NUM_2, GPIO_MODE_OUTPUT);

    while (1)
    {
        gpio_set_level(GPIO_NUM_2, 1);
        vTaskDelay(pdMS_TO_TICKS(500));
        gpio_set_level(GPIO_NUM_2, 0);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

注意看,vTaskDelay() 是让任务主动让出 CPU 的关键。没有它,低优先级任务永远得不到运行。

3.1.2 栈深度怎么定?

这是新手最容易翻车的地方。栈太小,任务跑着跑着就溢出;栈太大,浪费宝贵的 RAM。

我个人习惯:先给一个保守值,比如 2048 字(ESP32 一个字是 4 字节,所以 2048 字 = 8KB)。跑起来后用 uxTaskGetStackHighWaterMark() 查看剩余栈空间,再逐步调小。

我的经验: 一个简单的 GPIO 翻转任务,栈深度 512 字(2KB)就够。但如果任务里调用了 printf、sprintf 这类函数,栈消耗会暴涨。我曾经因为 printf 没控制好,导致栈溢出,系统随机重启——查了整整两天。

3.1.3 任务句柄有什么用?

任务句柄就像任务的「身份证」。后面你要删除、挂起、恢复这个任务,都得靠它。如果你不需要操作这个任务,可以传 NULL。

TaskHandle_t xLEDTaskHandle = NULL;

xTaskCreate(vTaskLED, "LED", 2048, NULL, 1, &xLEDTaskHandle);

3.2 任务优先级:谁先跑?

FreeRTOS 是抢占式调度器。优先级高的任务,只要就绪,就会立刻抢走 CPU。优先级数字越大,优先级越高。

优先级值 含义 典型用途
0 最低优先级(空闲任务) 系统空闲任务专用
1-5 低优先级 后台数据采集、日志输出
6-10 中优先级 传感器读取、状态更新
11-15 高优先级 通信协议处理、按键响应
16-24 实时优先级 控制环路、中断级任务
注意: 优先级不要超过 configMAX_PRIORITIES - 1。这个值在 FreeRTOSConfig.h 里定义,默认是 25。超过这个值,调度器会直接忽略。

你想想看,如果两个任务优先级相同,FreeRTOS 会怎么做?它会用时间片轮转,每个任务跑一个 tick 的时间,然后切换。但如果你把高优先级任务设计成死循环且不阻塞,低优先级任务就永远没机会跑。这就是所谓的「优先级反转」和「饥饿」问题。

我在项目中遇到过这种情况:一个高优先级的 WiFi 任务占着 CPU 不放,导致低优先级的传感器读取任务一直得不到执行,数据采集全乱了。后来加了一个 vTaskDelay() 才解决。

3.3 任务删除:什么时候该「杀」任务?

任务不是创建了就一定要跑到地老天荒。有些任务完成使命后,就该被清理掉。

void vTaskTempSensor(void *pvParameters)
{
    // 读取一次温度
    float temp = read_temperature();
    // 把温度值存到全局变量
    g_temperature = temp;
    // 任务完成,删除自己
    vTaskDelete(NULL);
}

注意 vTaskDelete(NULL) 的意思是「删除当前任务」。如果你有任务句柄,也可以删除其他任务:

vTaskDelete(xLEDTaskHandle);
重要: 删除任务后,任务占用的栈和 TCB(任务控制块)并不会自动释放回堆。你需要确保 configSUPPORT_DYNAMIC_ALLOCATION 为 1,并且 FreeRTOS 的堆管理机制正常工作。否则,频繁创建删除任务会导致内存碎片。

我曾经犯过一个错:在中断里直接调用 vTaskDelete()。结果系统直接挂掉。为什么?因为 vTaskDelete() 会触发上下文切换,而中断上下文不允许这样做。正确的做法是在中断里发一个信号量,让任务自己删除自己。

3.4 任务挂起与恢复:让任务「休眠」

挂起和删除不同。挂起的任务还在内存里,只是调度器不再调度它。恢复后,任务从挂起点继续执行。

// 挂起任务
vTaskSuspend(xLEDTaskHandle);

// 恢复任务
vTaskResume(xLEDTaskHandle);

挂起有什么用?我举两个场景:

  • 省电模式: 设备进入低功耗模式前,挂起所有非必要任务,只保留唤醒检测任务。
  • 调试: 某个任务行为异常,先挂起它,观察其他任务是否正常。
避坑指南: 我曾经在中断里调用 vTaskResume() 来恢复一个高优先级任务。结果那个任务一恢复就抢占了 CPU,导致中断处理被延迟。后来我改用 vTaskResumeFromISR(),并在中断退出时手动触发一次上下文切换,才解决问题。

3.5 知识结构图:任务生命周期

下面这张图,把任务的整个生命周期串起来了。从创建到运行,再到挂起、恢复、删除,一目了然。

任务生命周期流程图 xTaskCreate() 就绪态 (Ready) 运行态 (Running) 阻塞态 (Blocked) 挂起态 (Suspended) vTaskDelete() 说明: 实线箭头表示主动切换,虚线箭头表示条件满足后自动切换。挂起态只能通过 vTaskResume() 回到就绪态。

3.6 实战:创建一个温控任务

把上面这些知识点串起来,写一个完整的例子。这个任务每 2 秒读取一次温度,如果温度超过阈值,就点亮报警灯。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define TEMP_THRESHOLD 50.0f
#define ALARM_GPIO     GPIO_NUM_4

TaskHandle_t xTempTaskHandle = NULL;

void vTaskTemperature(void *pvParameters)
{
    float temperature = 0.0f;

    // 初始化报警灯
    gpio_set_direction(ALARM_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(ALARM_GPIO, 0);

    while (1)
    {
        // 模拟读取温度传感器
        temperature = read_temperature_sensor();

        if (temperature > TEMP_THRESHOLD)
        {
            gpio_set_level(ALARM_GPIO, 1);
        }
        else
        {
            gpio_set_level(ALARM_GPIO, 0);
        }

        // 每 2 秒执行一次
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

void app_main(void)
{
    xTaskCreate(
        vTaskTemperature,
        "TempTask",
        2048,
        NULL,
        5,
        &xTempTaskHandle
    );

    // 主函数可以继续做其他事情
}
关键点回顾:
  • 任务函数必须是一个死循环,不能 return。
  • 栈深度先给大一点,跑起来后再优化。
  • 优先级不要滥用,高优先级任务必须主动让出 CPU。
  • 删除任务前,确保任务不再被其他模块引用。
  • 挂起和恢复是轻量级操作,适合临时暂停任务。