2. 任务管理核心:任务控制块(TCB)数据结构深度解析、任务状态与状态迁移图、任务创建与删除的源码实现

各位同学,欢迎来到第二章。这一章我们啃的是RTOS的「心脏」——任务管理。说白了,你写的所有应用代码,最终都要靠任务来承载。而任务控制块(TCB),就是操作系统用来管理每个任务的「身份证」和「档案袋」。

我个人习惯,在分析任何RTOS源码之前,先把这个TCB结构体彻底吃透。因为后面所有的调度、挂起、延时操作,本质上都是在操作这个结构体里的字段。你想想看,如果连身份证都搞不清楚,怎么去管理几十上百个任务?

2.1 任务控制块(TCB)数据结构深度解析

先看一个典型的TCB定义。我以FreeRTOS为例,但其他RTOS大同小异。核心思想是一样的:保存任务的运行上下文、状态、优先级和栈指针

typedef struct tskTaskControlBlock {
    volatile StackType_t *pxTopOfStack;    // 当前栈顶指针,最关键!
    ListItem_t xStateListItem;             // 状态链表节点(就绪、阻塞、挂起)
    ListItem_t xEventListItem;             // 事件链表节点(等待信号量、队列)
    UBaseType_t uxPriority;                // 任务优先级
    StackType_t *pxStack;                  // 栈起始地址
    char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名字,调试用
    // ... 还有其他成员,比如栈大小、临界区嵌套计数器等
} tskTCB;

这里我重点说三个字段,也是我当年调试时踩坑最多的地方。

第一,pxTopOfStack。 这是TCB的灵魂。每次任务切换时,CPU的寄存器(R0-R15、PSP、LR等)都会被压入当前任务的栈中,然后pxTopOfStack指向这个保存位置。下次恢复时,直接从这取出来弹回寄存器。我在项目中遇到过,如果栈溢出把pxTopOfStack指向的内存踩坏了,任务一恢复就跑飞。嗯,这种问题查起来特别痛苦。

第二,xStateListItem。 这个节点把任务挂到不同的链表里。比如就绪链表、阻塞链表、挂起链表。调度器其实就是在这些链表之间「搬砖」——把任务从一个链表移到另一个链表。说白了,任务状态的改变,就是链表节点的移动。

第三,uxPriority。 优先级决定了谁先跑。但要注意,RTOS通常支持同优先级任务轮询调度。我见过有人把所有任务设成同一个优先级,结果系统跑起来像单任务一样,因为忘了开时间片轮转。

核心理解: TCB就是任务的「影子」。你创建了一个任务,操作系统就给它分配一个TCB。任务在跑,TCB在更新。任务被切换,TCB被保存。任务被删除,TCB被回收。没有TCB,任务就不存在。

2.2 任务状态与状态迁移图

任务有几种状态?很多初学者以为只有「运行」和「不运行」。其实RTOS里,任务有四种基本状态:运行态、就绪态、阻塞态、挂起态

我画个简单的迁移图给你看:

        创建
          |
          v
      就绪态  <---> 运行态
       |   ^         |   ^
       |   |         |   |
       v   |         v   |
      阻塞态         挂起态

解释一下:

  • 运行态:CPU正在执行这个任务。单核CPU同一时刻只有一个任务在运行。
  • 就绪态:任务已经准备好,随时可以跑,但CPU被别的任务占着。
  • 阻塞态:任务在等某个事件,比如等信号量、等队列消息、等延时结束。这时候它不消耗CPU。
  • 挂起态:任务被强制暂停,只能通过其他任务调用vTaskResume来恢复。

为什么会从运行态变成阻塞态?最常见的就是调用了vTaskDelay。我曾经有个项目,一个传感器采集任务用了vTaskDelay(10),结果发现采集周期不准。后来查资料才知道,vTaskDelay是相对延时,如果任务被中断打断,延时时间会漂移。换成vTaskDelayUntil才解决。这个坑我记了很久。

避坑指南: 我曾经把任务状态搞混,导致一个高优先级任务一直占着CPU,低优先级任务永远得不到执行。后来加了个taskYIELD()主动让出CPU才解决。记住:高优先级任务如果一直不阻塞,低优先级任务会饿死。

2.3 任务创建与删除的源码实现

好,理论讲完了,我们看代码。任务创建的核心函数是xTaskCreate。我简化一下关键流程:

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,    // 任务函数指针
    const char * const pcName,    // 任务名
    configSTACK_DEPTH_TYPE usStackDepth, // 栈大小(单位是字,不是字节)
    void *pvParameters,           // 任务参数
    UBaseType_t uxPriority,       // 优先级
    TaskHandle_t *pxCreatedTask   // 返回的任务句柄
) {
    TCB_t *pxNewTCB;
    StackType_t *pxStack;

    // 1. 分配栈空间
    pxStack = pvPortMalloc(usStackDepth * sizeof(StackType_t));
    if (pxStack == NULL) return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;

    // 2. 分配TCB空间
    pxNewTCB = pvPortMalloc(sizeof(TCB_t));
    if (pxNewTCB == NULL) {
        vPortFree(pxStack);
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // 3. 初始化TCB字段
    pxNewTCB->pxStack = pxStack;
    pxNewTCB->pcTaskName[0] = '\0';
    // ... 复制名字、设置优先级等

    // 4. 初始化栈(模拟第一次启动时的寄存器状态)
    pxNewTCB->pxTopOfStack = pxPortInitialiseStack(
        pxTopOfStack, pvTaskCode, pvParameters
    );

    // 5. 把任务加入就绪链表
    prvAddTaskToReadyList(pxNewTCB);

    return pdPASS;
}

这里有个关键步骤:初始化栈。你想想看,任务第一次被调度时,CPU从哪开始执行?答案就在栈里。pxPortInitialiseStack这个函数会把任务函数的地址、参数、初始的xPSR寄存器值都压入栈中。这样调度器第一次切换到这个任务时,从栈里弹出这些值,CPU就直接跳转到任务函数入口了。是不是很巧妙?

删除任务相对简单,调用vTaskDelete

void vTaskDelete(TaskHandle_t xTaskToDelete) {
    TCB_t *pxTCB;

    // 如果传入NULL,表示删除自己
    if (xTaskToDelete == NULL) {
        pxTCB = pxCurrentTCB;
    } else {
        pxTCB = (TCB_t *)xTaskToDelete;
    }

    // 1. 从所有链表中移除
    uxListRemove(&(pxTCB->xStateListItem));
    uxListRemove(&(pxTCB->xEventListItem));

    // 2. 释放栈空间
    vPortFree(pxTCB->pxStack);

    // 3. 释放TCB空间
    vPortFree(pxTCB);

    // 4. 触发一次调度(如果删除的是当前任务)
    if (xTaskToDelete == NULL) {
        taskYIELD();
    }
}
注意: 删除任务时,一定要确保任务不再使用任何资源。我曾经在项目中删了一个正在持有互斥信号量的任务,结果导致其他任务永远等不到信号量,整个系统死锁。正确的做法是:在任务退出前,先释放所有占用的资源。

最后说一句,任务创建和删除是RTOS最基础的操作。但越是基础,越容易出问题。我建议你在自己的开发板上,用xTaskCreate创建几个不同优先级的任务,然后用vTaskDelete动态删除它们,观察系统行为。只有亲手调过,才能真正理解TCB是怎么工作的。

下一章,我们会深入调度器的实现。到时候你会看到,调度器是如何在就绪链表里找到最高优先级的任务,然后完成上下文切换的。嗯,那才是RTOS真正精彩的地方。