任务与任务控制块:任务的概念、任务状态与TCB结构体设计

各位同学,咱们今天聊聊RTOS里最核心的概念——任务。说白了,任务就是一段独立执行的程序,它有自己的栈、自己的优先级,看起来就像是一个“小CPU”在独自运行。我刚开始接触RTOS时,总觉得任务和函数调用差不多,后来踩了坑才明白,这俩完全是两码事。

任务到底是什么?

任务在RTOS里,是一个无限循环的执行单元。它不像普通函数那样调用完就返回,而是永远在“干活-等待-干活”的循环里打转。举个例子:

void task_led_blink(void *param) {
    while(1) {
        GPIO_SetBits(LED_PIN);
        vTaskDelay(500);  // 阻塞500ms
        GPIO_ResetBits(LED_PIN);
        vTaskDelay(500);
    }
}

你看,这个任务永远不会结束。它每次执行完一轮,就主动让出CPU,等时间到了再继续。这就是任务和普通函数的本质区别——任务有生命周期,函数只有调用期。

个人经验: 我曾在某个项目中,把任务写成了“执行一次就返回”的形式,结果系统跑了几分钟就卡死了。后来排查才发现,任务退出后,RTOS以为它还在运行,但栈已经被回收了……嗯,从那以后我写任务必加while(1)。

任务的五种状态

任务在运行过程中,会在不同状态间切换。我习惯把任务状态比作一个人的日常:

  • 就绪(Ready):任务已经准备好,就等CPU分配时间片。就像你排队等公交,车来了就能上。
  • 运行(Running):任务正在占用CPU执行。相当于你正在开车。
  • 阻塞(Blocked):任务在等待某个事件(延时、信号量、消息队列)。就像你在等红灯,车停着但发动机没熄火。
  • 挂起(Suspended):任务被强制暂停,不参与调度。相当于你把车停在停车场,钥匙拔了。
  • 初始(Dormant/Initial):任务刚创建,还没被调度器激活。就像车刚造好,还没上路。

为什么会这样设计?你想想看,如果所有任务都在抢CPU,系统就乱套了。状态机就是为了让任务有序地共享CPU资源。

状态转换图

下面这张图,是我自己画的任务状态转换关系。你仔细看,箭头上的条件就是触发状态变化的关键:

任务状态转换图 初始 就绪 运行 阻塞 挂起 创建/恢复 调度器选择 时间片用完/被抢占 等待事件 事件到达 vTaskSuspend() vTaskResume() 说明: 箭头上的文字是触发状态转换的条件。注意:阻塞和挂起的区别在于——阻塞是任务主动等待,挂起是被动暂停。
关键点: 任务在任何时刻只能处于一种状态。调度器通过状态机来管理任务,确保CPU资源被合理分配。我曾经在调试一个多任务系统时,发现某个任务一直不执行,查了半天才发现它被意外挂起了——状态机没处理好,任务永远进不了就绪队列。

任务控制块(TCB)结构体设计

TCB是RTOS管理任务的核心数据结构。每个任务都有一个TCB,里面存着任务的所有“户口信息”。我设计TCB时,一般会包含以下几类字段:

字段类别 具体字段 说明
标识信息 任务ID、任务名称 用于调试和日志输出
栈管理 栈顶指针、栈底指针、栈大小 任务切换时保存/恢复上下文
调度信息 优先级、状态、时间片 调度器根据这些字段决定谁运行
同步信息 等待事件类型、等待对象指针 用于阻塞状态下的唤醒
链表节点 就绪链表、阻塞链表、挂起链表 将TCB串入不同状态链表

下面是一个典型的TCB结构体定义,我习惯用C语言实现:

typedef struct tcb {
    // 标识信息
    uint32_t        task_id;        // 任务ID
    char            task_name[16];  // 任务名称
    
    // 栈管理
    uint32_t        *stack_top;     // 当前栈顶指针
    uint32_t        *stack_base;    // 栈底指针
    uint32_t        stack_size;     // 栈大小(单位:字)
    
    // 调度信息
    uint8_t         priority;       // 任务优先级(0最高)
    uint8_t         state;          // 任务状态
    uint32_t        time_slice;     // 时间片(tick数)
    
    // 同步信息
    uint32_t        wait_event;     // 等待的事件类型
    void            *wait_obj;      // 等待的对象指针
    
    // 链表节点
    struct list_node ready_node;    // 就绪链表节点
    struct list_node block_node;    // 阻塞链表节点
    struct list_node suspend_node;  // 挂起链表节点
} tcb_t;
避坑指南: 我曾经在设计TCB时,把栈指针和栈底指针搞反了,结果任务切换时栈溢出,系统直接崩溃。后来我加了一个栈溢出检测字段,每次切换任务时检查栈边界。嗯,这个习惯我一直保留到现在。

TCB的初始化流程

创建一个任务,本质上就是初始化一个TCB,然后把它挂到就绪链表里。流程如下:

  1. 分配TCB内存:从静态数组或动态堆中分配一个TCB结构体。
  2. 分配任务栈:为任务分配独立的栈空间。
  3. 初始化栈帧:在栈中预置任务入口地址、初始寄存器值。
  4. 设置调度参数:填写优先级、时间片、初始状态(通常为就绪)。
  5. 插入就绪链表:根据优先级将TCB插入就绪链表的合适位置。

你想想看,如果没有TCB,调度器怎么知道哪个任务该运行?说白了,TCB就是任务的“身份证”,调度器通过它来识别和管理所有任务。

个人习惯: 我一般会在TCB里加一个“调试预留”字段,比如任务运行次数、最大栈使用量。这些信息在性能调优时特别有用。有一次我发现某个任务栈使用量接近上限,赶紧给它加了栈空间,才避免了线上崩溃。

任务状态与TCB的关系

任务状态其实就存储在TCB的state字段里。调度器通过遍历不同状态链表,来决定下一步该运行谁。举个例子:

  • 当任务被创建时,TCB的state设为就绪,插入就绪链表。
  • 当调度器选中该任务时,state改为运行,从就绪链表移除。
  • 当任务调用vTaskDelay()时,state改为阻塞,插入阻塞链表。
  • 当阻塞时间到达,state改回就绪,重新插入就绪链表。

这个机制看起来简单,但实际实现时有很多细节。我记得有一次,任务在阻塞期间被意外删除了,但阻塞链表里还留着它的TCB指针,结果系统在遍历链表时访问了野指针……嗯,从那以后我每次删除任务都会检查它是否还在链表中。

小结

任务和TCB是RTOS的基石。任务定义了“做什么”,TCB定义了“怎么管”。理解任务状态转换和TCB结构,是后续学习调度算法、同步机制的基础。你只要把这张状态转换图记牢,把TCB结构体吃透,后面讲优先级调度、时间片轮转时就会轻松很多。