2. 任务创建与管理:xTaskCreate函数详解、任务优先级、任务堆栈、任务句柄

好,咱们今天来聊聊FreeRTOS里最核心的东西——任务创建与管理。说实话,我刚接触嵌入式实时系统那会儿,觉得任务不就是个函数嘛,有啥好研究的?结果第一次做项目就栽了跟头——任务跑着跑着就崩了,堆栈溢出,优先级搞反,各种问题。后来才明白,任务创建这事儿,看似简单,里面的门道可不少。

2.1 xTaskCreate函数——任务的“出生证明”

在FreeRTOS里,创建一个任务,最常用的就是xTaskCreate()这个函数。它的原型长这样:

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,      // 任务函数指针
    const char * const pcName,      // 任务名称(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 堆栈深度(单位是字,不是字节)
    void *pvParameters,             // 传递给任务函数的参数
    UBaseType_t uxPriority,         // 任务优先级(0最低,configMAX_PRIORITIES-1最高)
    TaskHandle_t *pxCreatedTask     // 任务句柄(输出参数)
);

返回值是pdPASSerrCOULD_NOT_ALLOCATE_REQUIRED_MEMORY。嗯,说白了就是成功或失败。我习惯每次创建任务后都检查一下返回值,别问为什么,问就是吃过亏——有一次堆内存不够,任务没创建成功,程序静悄悄地跑偏了,查了两天才找到原因。

关键点:堆栈深度单位是“字”,在ESP32上1个字=4字节。所以如果你写1024,实际分配的是4096字节的堆栈空间。这个很多人搞混,我刚开始也犯过这错。

2.2 任务优先级——谁先跑,谁后跑

任务优先级,说白了就是决定哪个任务先获得CPU使用权。数值越大,优先级越高。在ESP32的FreeRTOS里,默认configMAX_PRIORITIES是25,也就是优先级范围0~24。

这里有个坑:优先级0是空闲任务的优先级。你想想看,如果你把所有任务优先级都设成0,那空闲任务就没法运行了?其实不会,空闲任务优先级就是0,但它是特殊的。不过我个人建议,用户任务优先级从1开始用,留点余地。

我在项目中遇到过这样一个场景:一个数据采集任务和一个网络发送任务。采集任务要求实时性高,我给了优先级5;网络发送任务偶尔延迟没关系,给了优先级2。结果呢?采集任务一直占着CPU,网络发送任务饿死了。后来我把网络发送任务里加了个vTaskDelay(),让它在等待时主动让出CPU,问题就解决了。

避坑指南:我曾经把两个任务设成相同优先级,以为它们会轮流跑。实际上,相同优先级的任务确实会时间片轮转,但前提是configUSE_TIME_SLICING要开启(默认开启)。如果你关了时间片轮转,同优先级任务就得靠taskYIELD()手动让出CPU了。

2.3 任务堆栈——别让你的任务“爆栈”

任务堆栈,就是每个任务私有的“内存小仓库”。局部变量、函数调用、中断保存的上下文,全放这儿。堆栈大小设小了,任务跑着跑着就崩了;设大了,浪费宝贵的RAM。

那怎么确定堆栈大小呢?我一般用两种方法:

  1. 估算:看任务函数里用了多少局部变量、调用了多深的函数嵌套。比如一个任务里有个512字节的数组,再加上几层函数调用,我至少给1024字(4096字节)。
  2. 实测:FreeRTOS提供了uxTaskGetStackHighWaterMark()函数,可以查看任务堆栈剩余的最小值。我习惯在任务稳定运行后,调用这个函数看看还剩多少,然后留出30%的余量。
// 获取任务堆栈剩余空间(单位:字)
UBaseType_t uxHighWaterMark;
uxHighWaterMark = uxTaskGetStackHighWaterMark(NULL); // NULL表示当前任务
printf("任务堆栈剩余:%d 字\n", uxHighWaterMark);

注意:ESP32的WiFi和蓝牙协议栈会占用大量堆栈空间。如果你在任务里调用WiFi相关API,堆栈至少给4096字(16384字节)。我有个同事没注意这个,WiFi一连接任务就崩,查了一整天。

2.4 任务句柄——任务的“身份证”

任务句柄,本质上是一个指向任务控制块(TCB)的指针。有了它,你就可以对任务进行各种操作:删除、挂起、恢复、改变优先级等等。

创建任务时,如果你不需要操作这个任务,可以把最后一个参数传NULL。但我建议你永远不要传NULL。为什么?因为后面调试、维护的时候,你很可能需要操作这个任务。我习惯在全局或结构体里保存所有任务句柄,方便统一管理。

TaskHandle_t xTask1Handle = NULL;
TaskHandle_t xTask2Handle = NULL;

void create_tasks(void)
{
    xTaskCreate(task1_func, "Task1", 2048, NULL, 5, &xTask1Handle);
    xTaskCreate(task2_func, "Task2", 2048, NULL, 3, &xTask2Handle);
}

// 后面想删除任务1
void delete_task1(void)
{
    if (xTask1Handle != NULL) {
        vTaskDelete(xTask1Handle);
        xTask1Handle = NULL; // 记得置空,防止野指针
    }
}

嗯,这里有个小细节:删除任务后,句柄要置NULL。不然你后面不小心用了这个句柄,那就是野指针操作,后果嘛……你懂的。

2.5 完整示例——创建两个任务

说了这么多,咱们来个完整的例子。创建两个任务:一个LED闪烁任务(优先级5),一个串口打印任务(优先级3)。

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

#define LED_GPIO 2

TaskHandle_t xLedTaskHandle = NULL;
TaskHandle_t xPrintTaskHandle = NULL;

// LED闪烁任务
void led_task(void *pvParameters)
{
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
    while (1) {
        gpio_set_level(LED_GPIO, 1);
        vTaskDelay(pdMS_TO_TICKS(500));
        gpio_set_level(LED_GPIO, 0);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// 串口打印任务
void print_task(void *pvParameters)
{
    int count = 0;
    while (1) {
        printf("系统运行中... 计数:%d\n", count++);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void app_main(void)
{
    // 创建LED任务,优先级5,堆栈2048字
    BaseType_t ret1 = xTaskCreate(led_task, "LED", 2048, NULL, 5, &xLedTaskHandle);
    if (ret1 != pdPASS) {
        printf("LED任务创建失败!\n");
    }

    // 创建打印任务,优先级3,堆栈2048字
    BaseType_t ret2 = xTaskCreate(print_task, "Print", 2048, NULL, 3, &xPrintTaskHandle);
    if (ret2 != pdPASS) {
        printf("打印任务创建失败!\n");
    }

    // 主任务可以删除了,或者让它干别的
    vTaskDelete(NULL);
}

个人经验:我习惯在app_main()里只做任务创建和初始化工作,然后把自己删掉。这样系统就完全由FreeRTOS调度了,主任务不占任何资源。干净利落。

2.6 常见问题与避坑

问题 原因 解决方法
任务创建返回失败 堆内存不足 增大configTOTAL_HEAP_SIZE,或减少任务堆栈大小
任务跑着跑着就崩了 堆栈溢出 uxTaskGetStackHighWaterMark()检查,增大堆栈
低优先级任务一直不运行 高优先级任务没有主动让出CPU 在高优先级任务里加vTaskDelay()taskYIELD()
任务句柄无效 句柄被覆盖或未初始化 创建时检查返回值,删除后置NULL

好了,关于任务创建与管理,核心就是这些。记住:堆栈给够、优先级合理、句柄管好,你的任务系统就稳了。下一章咱们聊聊任务的状态与调度,看看任务到底是怎么在“运行、就绪、阻塞、挂起”之间切换的。