任务与任务控制块:任务的定义、任务状态与TCB结构体详解

各位同学,咱们今天来聊聊RTOS里最核心的概念——任务。说白了,任务就是一段有独立功能的代码,它有自己的生命周期。我刚开始接触RTOS时,总觉得任务跟函数调用差不多,后来踩了不少坑才明白,这两者完全是两码事。

任务的定义:它到底是什么?

任务,在RTOS里就是一个无限循环的函数。它有自己的栈空间、优先级和状态。你想想看,一个单片机要同时处理按键扫描、屏幕刷新、串口通信,靠一个main函数里的超级循环,迟早会出问题。

任务长什么样?看个例子:

void Task_LED(void *param)
{
    while(1)
    {
        LED_Toggle();
        vTaskDelay(500);  // 延时500ms
    }
}

嗯,这里要注意:任务函数不能返回。一旦返回,RTOS会认为这个任务结束了,会调用一个错误处理函数。我在项目中遇到过新手把任务写成普通函数,执行完就return了,结果系统直接崩溃。

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

任务有四种状态,我习惯用「人」来类比,这样好理解:

状态 类比 说明
就绪(Ready) 排队等公交 任务已经准备好,只等CPU分配时间片
运行(Running) 正在上车 CPU正在执行这个任务的代码
阻塞(Blocked) 等红灯 任务在等待某个事件(延时、信号量、队列等)
挂起(Suspended) 下车休息 任务被主动暂停,需要别人唤醒

为什么会这样设计?因为CPU只有一个,但任务可能有十几个。RTOS通过状态切换,让每个任务都有机会运行。

关键点:同一时刻,只有一个任务处于运行状态。其他任务要么就绪、要么阻塞、要么挂起。

我记得有一次调试一个通信协议栈,发现某个任务一直不执行。查了半天,原来是它一直在阻塞状态等一个永远不会来的信号量。说白了,就是「等红灯等到天荒地老」。

任务状态转换

任务状态之间怎么切换?我画个简单的流程:

  • 就绪 → 运行:调度器选中这个任务,给它CPU使用权
  • 运行 → 就绪:时间片用完,或者被更高优先级任务抢占
  • 运行 → 阻塞:任务主动调用延时函数,或等待资源
  • 阻塞 → 就绪:等待的条件满足(延时到、信号量释放等)
  • 运行 → 挂起:调用挂起函数
  • 挂起 → 就绪:调用恢复函数

避坑指南:我曾经在项目中把任务挂起后忘了恢复,结果那个任务再也没运行过。后来我养成了习惯:挂起任务时,一定在注释里写明「谁、在什么条件下、负责恢复它」。

任务控制块(TCB)结构体详解

TCB是RTOS管理任务的核心数据结构。每个任务都有一个TCB,就像每个人都有身份证。TCB里存了什么?我直接上代码:

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]; // 任务名称
    // ... 还有其他成员,不同RTOS略有差异
} tskTCB;

我来逐个解释:

  • pxTopOfStack:栈顶指针。任务切换时,CPU的寄存器值就保存在这里。我习惯叫它「任务的现场快照」。
  • xStateListItem:把任务挂到就绪列表、阻塞列表或挂起列表里。说白了,就是用来排队用的。
  • xEventListItem:任务在等待某个事件时,用这个成员挂到事件等待列表里。
  • uxPriority:优先级。数值越大优先级越高(FreeRTOS是这样,其他RTOS可能相反)。
  • pxStack:栈的起始地址。任务自己的栈空间,用来存局部变量和函数调用信息。
  • pcTaskName:任务名字,调试时特别有用。我一般用「Task_LED」「Task_UART」这种命名方式。

注意:TCB本身也占用内存。如果你创建100个任务,就有100个TCB。每个TCB大约几十到上百字节,算一下就知道内存够不够了。我曾经在资源紧张的单片机上,为了省内存,把任务名长度从16字节砍到8字节。

任务创建时发生了什么?

当你调用xTaskCreate()时,RTOS内部做了三件事:

  1. 分配TCB内存和任务栈内存
  2. 初始化TCB各成员,包括栈顶指针、优先级、任务名等
  3. 把任务加入就绪列表

嗯,这里有个细节:初始化栈顶指针时,RTOS会往栈里预填一些值,模拟任务刚被中断时的状态。这样第一次切换到这个任务时,就能直接从栈里恢复寄存器,开始执行任务函数。

说白了,TCB就是RTOS用来「记住」每个任务的小本本。调度器通过查这个小本本,就知道哪个任务该运行了、哪个任务在等什么、哪个任务被暂停了。

个人经验:调试任务相关问题时,我经常直接打印TCB的内容。比如检查pxTopOfStack是否异常、uxPriority是否被意外修改。有一次发现任务优先级莫名其妙变了,查了半天是内存越界写坏了TCB。从那以后,我都在TCB结构体末尾加一个魔数(Magic Number),定期检查它是否被篡改。

好了,任务和TCB的基础知识就讲到这里。下一节我们会聊聊调度器是怎么利用TCB来做任务切换的。你想想看,如果让你设计调度器,你会怎么从就绪列表里选下一个任务?