4、任务创建与管理:xTaskCreate、任务优先级、任务状态、任务删除

好,咱们进入正题。任务创建与管理,这是 FreeRTOS 里最基础、也最核心的操作。说白了,你写嵌入式程序,如果不用 RTOS,那就是一个大 while(1) 循环在那里转。但用了 RTOS 之后,你的代码就被拆成了一个个独立的任务,每个任务都有自己的“灵魂”。

我个人习惯,拿到一个新项目,第一件事就是先把任务框架搭起来。任务怎么建、优先级怎么分、什么时候删,这些搞清楚了,后面的开发会顺很多。今天我就把这几个关键点掰开揉碎了讲给你听。

4.1 xTaskCreate:任务的“出生证明”

每个任务在 FreeRTOS 里都是一个独立的执行流。要创建一个任务,就得用 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 当成字节数来填。比如想给 512 字节,就填 512。错了!在 32 位处理器上,一个“字”是 4 字节,所以 512 字其实是 2048 字节。我曾经在项目里见过有人填了 128,结果任务一跑就栈溢出,系统直接挂掉。排查了半天才发现是栈大小没算对。

举个例子,创建一个简单的 LED 闪烁任务:

void vLEDTask(void *pvParameters)
{
    while(1)
    {
        GPIO_Toggle(LED_PIN);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void main(void)
{
    TaskHandle_t xLEDHandle = NULL;

    xTaskCreate(
        vLEDTask,       // 任务函数
        "LED Task",     // 任务名
        128,            // 栈大小:128字 = 512字节
        NULL,           // 无参数
        2,              // 优先级 2
        &xLEDHandle     // 保存句柄
    );

    vTaskStartScheduler();
}

你看,任务函数必须是一个无限循环。如果函数返回了,FreeRTOS 会调用 vTaskDelete(NULL) 把这个任务干掉。所以,任务函数里千万别写 return,除非你打算让它“自杀”。

我的小技巧: 调试阶段,我会给每个任务分配一个比实际需求大 1.5 倍的栈空间。等稳定了再用 uxTaskGetStackHighWaterMark() 查看实际使用量,然后精调。这样既不会浪费内存,也不会因为栈溢出而翻车。

4.2 任务优先级:谁先跑,谁后跑

优先级这个东西,说白了就是给任务排个队。数字越大,优先级越高。FreeRTOS 支持 0 到 configMAX_PRIORITIES - 1 的优先级范围。0 是最低优先级,一般留给空闲任务用。

我见过不少新手喜欢把所有任务都设成同一个优先级。结果呢?系统变成了“轮询调度”,每个任务轮流跑一个时间片。如果你的任务对实时性有要求,比如按键响应、通信处理,那这种设计就很容易出问题。

为什么会这样?因为 FreeRTOS 的调度策略是:高优先级任务就绪时,立即抢占低优先级任务。所以,你应该把对时间敏感的任务放在高优先级,把后台处理、数据记录这类任务放在低优先级。

举个例子,一个血氧仪的任务优先级分配:

任务名称 优先级 说明
vSensorReadTask 5 传感器数据采集,需要高实时性
vDisplayUpdateTask 3 屏幕刷新,中等优先级
vLogDataTask 1 数据记录到 Flash,可以慢慢来
Idle Task 0 系统空闲任务,最低

你看,传感器读取任务优先级最高,因为它要保证采样间隔稳定。显示任务可以稍微慢一点,人眼看不出来。日志任务嘛,只要不丢数据,晚几毫秒写进去都行。

注意: 优先级不是越高越好。如果高优先级任务一直不阻塞(比如一直 while 循环里空转),低优先级任务永远得不到 CPU 时间。这就是所谓的“优先级反转”或“饿死”问题。我曾经在一个项目中,把通信任务优先级设得太高,结果它一直占着 CPU,其他任务全卡死了。后来加了个 vTaskDelay() 才解决。

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

一个任务在 FreeRTOS 里,有四种状态。你想想看,就像一个人一天的状态:正在干活(运行)、等着干活(就绪)、在睡觉等闹钟(阻塞)、被关禁闭了(挂起)。

  • 运行态(Running):任务正在占用 CPU。单核处理器上,同一时刻只有一个任务处于运行态。
  • 就绪态(Ready):任务已经准备好,随时可以运行,但 CPU 被更高优先级的任务占着。
  • 阻塞态(Blocked):任务在等待某个事件,比如延时到期、信号量可用、队列有数据。这是最常见的状态。
  • 挂起态(Suspended):任务被 vTaskSuspend() 挂起,只有调用 vTaskResume() 才能恢复。

我个人习惯,在设计任务时,尽量让任务在大部分时间处于阻塞态。为什么?因为阻塞态的任务不消耗 CPU 时间。如果一个任务一直在就绪态空转,那它就是在浪费电、浪费算力。

举个例子,一个按键扫描任务:

void vKeyScanTask(void *pvParameters)
{
    while(1)
    {
        if(Key_Read() != KEY_NONE)
        {
            // 处理按键
        }
        // 关键:让任务阻塞 20ms,而不是一直轮询
        vTaskDelay(pdMS_TO_TICKS(20));
    }
}

你看,加了 vTaskDelay() 之后,这个任务每 20ms 才检查一次按键,其余时间都在睡觉。CPU 就可以去干别的事了。

4.4 任务删除:该放手时就放手

任务不是创建了就一定要跑到天荒地老。有些任务,干完活就该删掉。比如血氧仪开机时,有一个自检任务,检查完传感器、屏幕、存储都正常后,这个任务就没用了,可以删掉。

删除任务用 vTaskDelete() 函数:

void vSelfTestTask(void *pvParameters)
{
    // 执行自检
    if(SelfTest_Pass())
    {
        // 自检通过,删除自己
        vTaskDelete(NULL);  // NULL 表示删除当前任务
    }
    else
    {
        // 自检失败,进入错误处理
        ErrorHandler();
    }
}

这里有个细节:vTaskDelete(NULL) 是删除当前任务。如果你想删除其他任务,需要传入那个任务的句柄。比如:

TaskHandle_t xTestHandle = NULL;

void vMainTask(void *pvParameters)
{
    // 创建自检任务
    xTaskCreate(vSelfTestTask, "SelfTest", 256, NULL, 3, &xTestHandle);
    
    // 等待一段时间后,如果自检任务还没结束,强制删除它
    vTaskDelay(pdMS_TO_TICKS(5000));
    if(xTestHandle != NULL)
    {
        vTaskDelete(xTestHandle);
        xTestHandle = NULL;
    }
}
避坑指南: 我曾经犯过一个错误——删除了一个任务,但忘了把它的句柄置为 NULL。结果后面代码里又用这个句柄去操作任务,直接导致系统崩溃。所以,删除任务后,一定要把句柄清掉。另外,被删除的任务占用的栈空间和 TCB(任务控制块)会被 FreeRTOS 自动回收,但任务自己动态分配的内存(比如 malloc)不会自动释放,你得在任务退出前自己清理。

嗯,到这里,任务创建、优先级、状态、删除这几个核心概念就讲完了。你想想看,其实任务管理就像管理一个团队:创建任务就是招人,优先级就是定岗位级别,状态就是看每个人在干什么,删除任务就是让人离职。搞清楚了这些,你的 RTOS 程序就有了一个稳固的骨架。

下一章,我会讲任务间的通信与同步——队列、信号量、互斥量。这些才是让任务之间“说话”的关键。到时候见。