3. 任务创建与管理:任务控制块、任务创建函数、任务删除与挂起

好,咱们接着往下聊。上一章我们把FreeRTOS的内核跑起来了,系统时钟滴答滴答在跳。但一个光杆司令的操作系统有什么用?你得有任务让它调度才行。

这一章,我们就来聊聊任务是怎么被“生”出来的,又是怎么被“干掉”或者“雪藏”的。说白了,就是任务的三板斧:创建、删除、挂起。

3.1 任务控制块(TCB)—— 任务的“身份证”

每个任务在FreeRTOS里都有一个对应的结构体,叫任务控制块(Task Control Block,简称TCB)。你可以把它想象成任务的身份证,里面记录了这个任务的所有关键信息。

我个人习惯把TCB看作是任务的“档案袋”。系统调度器每次切换任务,都要先翻翻这个档案袋,看看当前任务的状态、优先级、堆栈指针在哪儿。

TCB里都存了啥?我挑几个重要的说:

  • 任务栈指针:每个任务都有自己的栈,用来保存局部变量和函数调用信息。这个指针指向栈顶。
  • 任务状态:运行态、就绪态、阻塞态、挂起态……系统靠这个字段知道你现在能不能被调度。
  • 优先级:数字越小优先级越低?还是越大?FreeRTOS里,数值越大优先级越高。别搞反了,我刚开始就犯过这个错。
  • 任务名称:调试用的,方便你认出这是哪个任务。
  • 事件列表项:用于任务同步和通信,后面章节会细讲。

你想想看,如果没有TCB,调度器就像个没有花名册的班主任,根本不知道班里有哪些学生、谁在干什么。所以,TCB是任务管理的基石。

核心要点:TCB是FreeRTOS管理任务的核心数据结构。每个任务创建时,系统都会自动分配一个TCB。你不需要手动操作它,但理解它的结构,能帮你更好地理解任务调度。

3.2 任务创建函数 —— xTaskCreate()

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

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

嗯,参数有点多,但别怕。我一个个给你拆开讲。

第一个参数:任务函数

任务函数必须是一个无限循环,不能返回。为什么?因为一旦返回,系统就不知道这个任务该干嘛了,会触发断言。我在项目中遇到过新手把任务函数写成普通函数,执行完就return了,结果系统直接崩溃。

正确的写法是这样的:

void vTask1(void *pvParameters)
{
    while(1)
    {
        // 你的任务代码
        vTaskDelay(pdMS_TO_TICKS(1000)); // 延时1秒
    }
}

第二个参数:任务名称

纯调试用的,长度不能超过 configMAX_TASK_NAME_LEN。我一般用“Task_LED”、“Task_UART”这种命名方式,一看就知道是干嘛的。

第三个参数:栈深度

这个参数很关键。它指定了任务栈的大小,单位是“字”。在32位处理器上,一个字就是4字节。所以如果你写128,实际分配的栈大小就是512字节。

注意:栈给大了浪费内存,给小了一不小心就栈溢出。我曾经在一个项目中,因为栈给得太小,任务跑着跑着就莫名其妙地复位了。查了两天才发现是栈溢出了。建议你至少给128个字起步,复杂任务给256甚至512。

第四个参数:任务参数

你可以给任务函数传递一个指针参数。比如你想让同一个任务函数控制两个不同的LED,就可以传不同的参数进去。这个设计很灵活。

第五个参数:优先级

数值越大,优先级越高。0是最低优先级。注意,configMAX_PRIORITIES 定义了系统支持的最大优先级数量,别超了。

第六个参数:任务句柄

这个句柄是输出参数。创建成功后,系统会返回一个 TaskHandle_t 类型的值。你可以用这个句柄来删除、挂起、恢复这个任务。如果你不需要,可以传NULL。

好了,参数都讲完了。来个完整的例子:

TaskHandle_t xTask1Handle = NULL;

void vTask1(void *pvParameters)
{
    while(1)
    {
        // 翻转LED
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void main(void)
{
    // 硬件初始化...
    
    xTaskCreate(
        vTask1,             // 任务函数
        "Task_LED",         // 任务名称
        128,                // 栈深度
        NULL,               // 无参数
        2,                  // 优先级为2
        &xTask1Handle       // 保存句柄
    );
    
    vTaskStartScheduler();  // 启动调度器
}

你看,创建任务其实就这么简单。调用 xTaskCreate(),然后启动调度器,任务就跑起来了。

3.3 任务删除与挂起 —— 让任务“消失”或“休眠”

任务创建好了,但有时候我们需要让它停下来。FreeRTOS提供了两种方式:删除和挂起。

删除任务:vTaskDelete()

这个函数会彻底干掉一个任务。被删除的任务,它的TCB和栈空间都会被系统回收。调用方式很简单:

vTaskDelete(xTask1Handle); // 删除指定任务
// 或者
vTaskDelete(NULL); // 删除当前任务

注意,删除任务后,这个任务就彻底没了。你不能恢复它,只能重新创建。我一般只在任务生命周期结束时才用删除,比如某个初始化任务执行完就自我删除。

小技巧:如果你在任务函数里调用 vTaskDelete(NULL),这个任务会在执行完当前代码后自我销毁。记得在删除前释放所有占用的资源,比如动态分配的内存、打开的外设等。

挂起任务:vTaskSuspend() 和 vTaskResume()

挂起和删除不同。挂起只是让任务“睡觉”,它的TCB和栈都还在,只是调度器不再调度它。你可以随时用 vTaskResume() 把它唤醒。

vTaskSuspend(xTask1Handle); // 挂起任务
// 一段时间后...
vTaskResume(xTask1Handle);  // 恢复任务

挂起任务有什么用?我举个例子。假设你有一个数据采集任务,平时不需要采集,只有用户按下按钮时才需要。那你就可以在按钮中断里挂起/恢复这个任务,而不是频繁地创建和删除。

挂起和删除的区别,我整理了一个表格:

操作 TCB和栈 能否恢复 资源占用 适用场景
vTaskDelete 释放 不能,需重新创建 任务生命周期结束
vTaskSuspend 保留 能,调用vTaskResume 仍有内存占用 临时暂停任务

嗯,这里要注意一点:挂起任务时,如果任务正在执行某个临界区代码,最好等它退出临界区再挂起。否则可能会引发资源竞争问题。我曾经在一个多任务系统中,因为挂起了一个正在操作共享缓冲区的任务,导致另一个任务读到了脏数据。排查了好久才发现是挂起时机不对。

3.4 避坑指南 —— 我踩过的那些坑

最后,分享几个我实际项目中遇到的坑,希望能帮你少走弯路。

  • 栈溢出检测:FreeRTOS提供了栈溢出检测功能,需要在 FreeRTOSConfig.h 中开启 configCHECK_FOR_STACK_OVERFLOW。我建议你开发阶段一定要开,上线前再关掉以节省性能。
  • 任务优先级反转:低优先级任务持有资源,高优先级任务等待,中间优先级任务抢跑。这个问题很经典,解决方案是使用互斥量(Mutex)而不是二值信号量。
  • 删除任务前释放资源:如果你在任务里动态分配了内存、打开了文件或外设,删除前一定要释放。否则会造成内存泄漏或资源泄漏。
  • 挂起任务与中断的交互:在中断服务函数里调用 xTaskResumeFromISR() 来恢复任务,而不是 vTaskResume()。后者不能在中断里用。

好了,这一章的内容就到这里。任务创建、删除、挂起,是FreeRTOS任务管理的基础。你只要掌握了这三个API,就能搭建起一个多任务系统的基本框架。下一章,我们会深入任务调度机制,看看FreeRTOS到底是怎么决定“下一个该谁跑”的。