3. 任务创建与删除:动态创建任务、静态创建任务、任务删除API、任务句柄与优先级

任务,是RTOS世界里最核心的概念。说白了,一个任务就是一段永不返回的独立函数,加上它专属的栈空间和任务控制块(TCB)。你创建多少个任务,系统就能“同时”处理多少件事——当然,单核CPU下其实是分时轮转,但感觉上就像并行一样。

这一章,我们聚焦任务的生命周期起点和终点:怎么创建、怎么删除。我见过不少新手在这块栽跟头,尤其是栈溢出和句柄失效的问题。咱们一步步来。

3.1 动态创建任务:简单灵活,但别忘检查返回值

动态创建,就是让内核在堆内存里自动分配TCB和栈空间。你只需要调用一个API,剩下的交给系统。以FreeRTOS为例,核心函数是 xTaskCreate()。

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

返回值只有两种:pdPASS 表示成功,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY 表示堆内存不够。我个人的习惯是,每次调用完 xTaskCreate 都立刻检查返回值,哪怕是在原型验证阶段。为什么?因为堆碎片问题很难复现,等产品跑到客户现场才出问题,那就晚了。

小技巧: 动态创建时,栈大小别拍脑袋定。先给一个偏大的值(比如512字),跑起来后用 uxTaskGetStackHighWaterMark() 查看实际使用峰值,再调整到合理范围。我一般留30%的余量。

3.2 静态创建任务:硬实时场景的可靠选择

静态创建,需要你自己提供TCB和栈的内存空间。内核不再碰堆,所有内存都在编译期就分配好了。这在航空、医疗、车规等不允许动态内存分配的场景下是标配。

对应的API是 xTaskCreateStatic():

TaskHandle_t xTaskCreateStatic(
    TaskFunction_t pvTaskCode,
    const char * const pcName,
    configSTACK_DEPTH_TYPE usStackDepth,
    void *pvParameters,
    UBaseType_t uxPriority,
    StackType_t *puxStackBuffer,    // 你提供的栈数组
    StaticTask_t *pxTaskBuffer      // 你提供的TCB结构体
);

注意,返回值不再是 BaseType_t,而是 TaskHandle_t。如果返回NULL,说明你提供的缓冲区指针是空的——这通常不会发生,除非你传了NULL进去。

对比项 动态创建 静态创建
内存来源 堆(Heap) 编译期分配的全局/静态数组
分配时机 运行时 编译时
失败风险 堆不足或碎片 几乎为零(只要缓冲区存在)
适用场景 原型、产品原型、内存充裕 安全关键、硬实时、无堆环境
代码复杂度 低 稍高(需手动管理缓冲区)

我曾经在一个工业控制器项目里,因为动态创建任务导致偶发性的任务创建失败,排查了整整两天。最后发现是堆被一个周期性的大数据块申请给碎片化了。从那以后,只要任务数量固定,我全改用静态创建。省心。

3.3 任务句柄:任务的“身份证”

任务句柄(TaskHandle_t),本质上是一个指向任务TCB的指针。你创建任务时拿到它,后续所有操作——删除、挂起、恢复、修改优先级——都靠它来定位任务。

动态创建时,句柄通过最后一个参数返回:

TaskHandle_t xHandle = NULL;
xTaskCreate(vTaskFunction, "Task1", 256, NULL, 1, &xHandle);
// 现在 xHandle 指向这个任务的TCB

静态创建时,句柄就是返回值:

TaskHandle_t xHandle = xTaskCreateStatic(...);

如果你在任务内部想获取自己的句柄,可以用 xTaskGetCurrentTaskHandle()。这在任务需要删除自己时特别有用。

注意: 任务句柄在任务被删除后变成野指针。如果你还保留着旧句柄并试图操作它,轻则触发断言,重则直接HardFault。我建议在删除任务后,立即将句柄变量置为NULL。

3.4 优先级:谁先跑,谁后跑

优先级决定了任务在就绪队列里的排队顺序。数值越大,优先级越高。FreeRTOS中,configMAX_PRIORITIES 定义了优先级总数,通常设为5到32之间。

几个关键规则:

  • 高优先级任务就绪时,立即抢占低优先级任务。 这是抢占式调度的核心。
  • 相同优先级的任务,按时间片轮转。 每个任务运行一个时间片后,切换到同优先级的下一个任务。
  • 空闲任务优先级最低(0)。 它只在没有其他任务就绪时运行。

我见过一个常见的坑:把多个实时性要求不同的任务设成同一个优先级。结果一个高计算量的任务霸占了CPU,导致另一个对延迟敏感的任务响应变慢。正确的做法是,把对延迟敏感的任务优先级设高,计算密集型任务优先级设低。

3.5 任务删除API:优雅地结束任务

删除任务用 vTaskDelete()。参数是你要删除的任务句柄。如果传入NULL,表示删除当前正在运行的任务(即自杀)。

// 删除指定任务
vTaskDelete(xTaskHandle);

// 删除自己(当前任务)
vTaskDelete(NULL);

删除后,任务TCB和栈空间会被释放回堆(动态创建时),或者标记为空闲(静态创建时)。但注意:删除操作不会等待任务执行完当前代码,它立即把任务从所有内核队列中移除。如果任务持有互斥量、信号量或锁,删除前必须手动释放,否则会造成资源泄漏。

避坑指南: 我曾经在一个多任务通信的项目里,一个任务在持有队列互斥锁时被另一个任务删除了。结果整个队列系统死锁,所有等待该队列的任务全部卡死。从那以后,我定了一条铁律:任务在删除前,必须释放所有占用的内核资源。

3.6 知识结构图:任务创建与删除全景

下面这张图把任务从生到死的完整路径画了出来。你可以看到动态和静态两条分支,以及句柄和优先级在整个生命周期中的位置。

任务创建与删除全景图 任务创建入口 动态创建 (xTaskCreate) 静态创建 (xTaskCreateStatic) 任务就绪(获得句柄) 优先级决定调度顺序 任务运行中 vTaskDelete() 删除任务 动态创建:堆分配TCB+栈 | 静态创建:编译期分配 | 删除后句柄失效,需置NULL

3.7 综合示例:动态创建与删除

下面是一个完整的示例,演示了如何动态创建两个任务,然后由其中一个任务删除另一个:

TaskHandle_t xTask1Handle = NULL;
TaskHandle_t xTask2Handle = NULL;

void vTask1(void *pvParameters) {
    // 任务1:每秒打印一次,5秒后删除任务2
    for(;;) {
        vTaskDelay(pdMS_TO_TICKS(1000));
        static int count = 0;
        if(++count >= 5) {
            if(xTask2Handle != NULL) {
                vTaskDelete(xTask2Handle);
                xTask2Handle = NULL;  // 立即置NULL,防止野指针
            }
        }
    }
}

void vTask2(void *pvParameters) {
    // 任务2:每200ms翻转一次LED
    for(;;) {
        vTaskDelay(pdMS_TO_TICKS(200));
        // 翻转GPIO
    }
}

void main(void) {
    // 创建任务1,优先级2
    xTaskCreate(vTask1, "Task1", 256, NULL, 2, &xTask1Handle);
    // 创建任务2,优先级1
    xTaskCreate(vTask2, "Task2", 256, NULL, 1, &xTask2Handle);
    // 启动调度器
    vTaskStartScheduler();
    // 正常情况下不会执行到这里
    for(;;);
}

这个例子虽然简单,但包含了创建、获取句柄、删除、置NULL的完整流程。你可以在自己的开发板上跑一下,观察任务2被删除后,LED不再翻转,而任务1继续打印——这就是任务删除的直观效果。

补充一点: 如果任务2持有某个外设的独占访问权(比如SPI总线),删除前必须释放。否则任务1再去访问SPI时,可能发现总线状态异常。我习惯在任务函数末尾(虽然它不会正常返回)或者删除前,加一个清理函数,释放所有资源。