3、任务创建与删除:任务创建API(如xTaskCreate)、任务删除API、任务参数传递

好,咱们进入第三个核心知识点。任务创建与删除,说白了就是RTOS里的「生老病死」。一个嵌入式系统,说白了就是一堆任务在抢CPU时间片。怎么让任务「生」出来?怎么让它「死」掉?参数怎么传进去?这些搞不清楚,程序跑起来就是一团乱麻。

我记得刚接触FreeRTOS那会儿,觉得xTaskCreate不就是个函数嘛,调一下就行了。结果呢?任务栈设太小,跑着跑着就崩了。参数传了个局部变量指针,任务还没启动,变量先被回收了……嗯,这些坑我今天都给你指出来。

3.1 任务创建API:xTaskCreate

任务创建,最核心的就是xTaskCreate这个函数。你想想看,操作系统要管理一个任务,它得知道什么?任务函数入口、任务名字、栈大小、参数、优先级、句柄。这些信息,全得通过xTaskCreate告诉内核。

函数原型长这样:

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

这里我要特别强调一点:栈深度单位是字。在32位处理器上,一个字是4字节。如果你设了128,实际栈大小是512字节。我见过太多人把这个搞错,栈设太小,任务一跑就栈溢出,系统直接挂掉。

返回值说明:

  • pdPASS:任务创建成功
  • errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY:内存不足,创建失败

举个实际例子。我在做一个继电保护装置时,需要创建一个数据采集任务:

TaskHandle_t xDataAcqHandle = NULL;

void vDataAcquisitionTask(void *pvParameters)
{
    // 参数解析
    uint32_t ulSampleRate = *(uint32_t *)pvParameters;
    
    for(;;)
    {
        // 采集模拟量数据
        vTaskDelay(pdMS_TO_TICKS(1000 / ulSampleRate));
    }
}

void vCreateTasks(void)
{
    uint32_t ulRate = 4000;  // 4kHz采样率
    
    if(xTaskCreate(
        vDataAcquisitionTask,   // 任务函数
        "DataAcq",              // 任务名
        256,                    // 栈深度256字 = 1024字节
        (void *)&ulRate,        // 传递采样率参数
        3,                      // 优先级3
        &xDataAcqHandle         // 保存句柄
    ) != pdPASS)
    {
        // 创建失败处理
        // 我一般会在这里记录错误日志
    }
}

个人经验:任务栈大小怎么定?我习惯先给个保守值,比如256字。然后用uxTaskGetStackHighWaterMark()函数在任务运行稳定后查看实际使用峰值,再调整到合适大小。别一开始就给太大,浪费RAM;也别太小,容易崩。

3.2 任务参数传递

参数传递这块,看着简单,其实坑不少。xTaskCreate的pvParameters参数是个void指针,你可以传任何东西进去。但这里有个关键问题:参数的生命周期

我曾经犯过一个低级错误:

void vBadExample(void)
{
    uint32_t ulLocalVar = 100;
    
    xTaskCreate(vTask, "Task", 128, &ulLocalVar, 1, NULL);
    
    // 函数返回后,ulLocalVar被销毁!
    // 但任务可能还没开始运行,或者正在使用这个地址
}

为什么会这样?因为任务创建后,不一定立即执行。它可能被调度器挂起,等一会儿才拿到CPU。到那时候,ulLocalVar所在的栈空间已经被其他函数覆盖了。你想想看,任务拿到的参数值是什么?鬼知道。

正确的做法有两种:

  1. 传静态变量或全局变量——生命周期贯穿整个程序运行
  2. 传堆上分配的内存——用pvPortMalloc分配,任务用完再释放

我个人更推荐第一种,简单可靠:

static uint32_t ulTaskParam = 100;  // 静态变量,生命周期长

void vGoodExample(void)
{
    xTaskCreate(vTask, "Task", 128, (void *)&ulTaskParam, 1, NULL);
}

如果参数是结构体,也可以传结构体指针。但记住,结构体也必须是静态的或者堆分配的。

3.3 任务删除API:vTaskDelete

有生就有死。任务删除用vTaskDelete。这个函数很简单,但用不好也会出问题。

void vTaskDelete(TaskHandle_t xTaskToDelete);

参数传NULL,表示删除自己。传有效句柄,删除指定任务。

重要警告:

  • 任务删除后,它占用的栈和TCB会被释放回堆
  • 但任务持有的互斥量、信号量等资源不会自动释放
  • 如果任务有动态分配的内存,删除前必须自己释放

我遇到过这样一个案例:一个通信任务,里面用pvPortMalloc分配了缓冲区。任务退出时直接调了vTaskDelete(NULL),结果缓冲区泄漏了。系统跑了几天,堆内存耗尽,所有任务都创建不出来了。

正确的做法是:

void vCommunicationTask(void *pvParameters)
{
    uint8_t *pucBuffer = (uint8_t *)pvPortMalloc(256);
    
    if(pucBuffer == NULL)
    {
        vTaskDelete(NULL);  // 内存分配失败,直接退出
    }
    
    for(;;)
    {
        // 正常通信逻辑
    }
    
    // 任务要删除了,先释放资源
    vPortFree(pucBuffer);
    vTaskDelete(NULL);
}

3.4 任务删除的时机问题

这里有个细节:任务删除后,句柄还有效吗?

答案是:无效。任务被删除后,它的TCB被回收了,句柄指向的内存已经不属于你了。如果你还拿着这个句柄去操作任务,比如挂起、恢复、改优先级,那结果就是——未定义行为。轻则程序跑飞,重则系统崩溃。

我建议的做法是:删除任务后,立即把句柄置为NULL:

if(xDataAcqHandle != NULL)
{
    vTaskDelete(xDataAcqHandle);
    xDataAcqHandle = NULL;  // 防止野指针
}

避坑指南:我曾经在一个项目中,用定时器回调函数里删除了一个任务。结果呢?任务正在运行时被删了,它占用的资源还没释放完,系统直接hard fault。记住:不要在中断或回调中删除任务,除非你非常清楚自己在做什么。最好在任务自己的代码里,或者通过消息队列通知任务自己退出。

3.5 任务创建与删除的典型应用场景

在实际的保护装置中,任务创建和删除常用于以下场景:

场景 说明 注意事项
系统初始化 创建所有常驻任务 确保栈大小合理,优先级正确
动态任务 根据外部事件临时创建任务 注意内存碎片,用完即删
故障恢复 删除异常任务,重新创建 先释放资源,再删任务
低功耗模式 删除非必要任务,降低功耗 恢复时重新创建,注意状态保存

嗯,任务创建与删除,说白了就是RTOS里的「内存管理」加「生命周期管理」。你只要记住三点:参数生命周期要长、删除前释放资源、句柄置空防野指针。这三点做到了,任务管理这块基本不会出大问题。

下一节,咱们聊聊任务的状态与调度。任务创建好了,它怎么跑?怎么停?怎么切换?这些才是RTOS的精髓所在。