4. 任务控制块TCB:任务调度的“身份证”

好,咱们今天来聊聊FreeRTOS里最核心的数据结构——任务控制块,也就是TCB。我常说,搞懂了TCB,你就搞懂了FreeRTOS的一半。为什么这么说?你想想看,操作系统要管理任务,总得有个东西来记录每个任务的信息吧?TCB就是这个“东西”。

说白了,TCB就是每个任务在系统里的“身份证”。系统通过它来识别任务、管理任务、调度任务。没有TCB,任务就是一盘散沙。

4.1 TCB结构体深度剖析

我们先来看看TCB长什么样。在FreeRTOS的源码里,TCB的定义在tasks.c文件中。我直接贴出核心部分,咱们一行一行地啃。

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;

嗯,这里要注意,volatile关键字不能少。为什么?因为栈顶指针可能在中断里被修改,编译器不能优化它。我曾经在一个项目里吃过这个亏,忘了加volatile,结果任务切换时栈指针乱飞,查了两天才找到原因。

咱们拆开来看几个关键成员:

  • pxTopOfStack:这是最重要的成员之一。它指向当前任务的栈顶。每次任务切换时,CPU的栈指针(SP)就会从这里恢复。说白了,它就是任务上下文的“入口”。
  • xStateListItem:这个列表项用来把TCB挂到不同的状态链表上。比如就绪链表、阻塞链表、挂起链表。任务状态变了,它就从A链表移到B链表。
  • xEventListItem:这个专门用于事件等待。比如任务在等一个队列消息,它就会被挂到队列的等待链表上。和xStateListItem分工明确。
  • uxPriority:优先级。数值越大,优先级越高。FreeRTOS支持抢占式调度,高优先级任务就绪了,低优先级任务就得让路。
  • pxStack:指向任务栈的起始地址。注意,它和pxTopOfStack不同。一个是栈底,一个是栈顶。分配栈空间时,我们通常用pvPortMalloc或者静态数组。

核心要点:TCB就是任务的“档案袋”。系统调度时,只看TCB,不看任务函数本身。任务函数只是代码,TCB才是灵魂。

4.2 任务栈的分配与管理

任务栈,说白了就是每个任务私有的“临时记事本”。局部变量、函数调用、中断嵌套,全得靠它。我见过不少新手,栈给得太小,结果任务跑着跑着就崩了。

FreeRTOS支持两种栈分配方式:

  1. 动态分配:用xTaskCreate创建任务时,系统自动调用pvPortMalloc分配栈空间。简单省事,但要注意内存碎片。
  2. 静态分配:用xTaskCreateStatic,你得自己提供栈空间和TCB内存。适合对实时性要求高的场景,比如航空、医疗设备。

我个人习惯用动态分配,除非项目有明确的内存限制。为什么呢?因为省心。你想想看,每个任务要多大栈,很难精确估算。动态分配可以随时调整,静态分配一旦定下来,改起来麻烦。

栈的大小怎么定?这里有个经验公式:

// 假设任务里嵌套了3层函数调用,每层用20字节局部变量
// 再加上中断嵌套,预留100字节
#define TASK_STACK_SIZE    ( 3 * 20 + 100 ) / sizeof( StackType_t )

当然,这只是估算。实际项目中,我建议你留出30%的余量。我曾经在一个物联网项目里,把栈设得刚刚好,结果产品发布后,用户反馈偶尔死机。一查,是某个极端情况下栈溢出了。从那以后,我至少多给50%的余量。

避坑指南:使用uxTaskGetStackHighWaterMark()函数可以查看栈的剩余空间。我建议你在开发阶段,每个任务都打印一下这个值,看看实际用了多少栈。这样心里有底。

4.3 任务上下文:切换的“快照”

任务上下文,说白了就是CPU寄存器的“快照”。当任务A切换到任务B时,系统要把A的寄存器全部保存到它的栈里,然后从B的栈里恢复B的寄存器。这个过程叫上下文切换。

FreeRTOS的上下文切换,核心就是保存和恢复以下寄存器:

寄存器组 说明 保存位置
R0-R12 通用寄存器 任务栈
R13 (SP) 栈指针 TCB的pxTopOfStack
R14 (LR) 链接寄存器 任务栈
R15 (PC) 程序计数器 任务栈
xPSR 程序状态寄存器 任务栈

嗯,这里要注意,Cortex-M内核的硬件会自动压栈一部分寄存器。当发生PendSV异常时,CPU会自动把R0-R3、R12、LR、PC、xPSR压到当前任务的栈里。然后软件只需要手动保存R4-R11即可。这个设计很巧妙,减少了软件的工作量。

我画个简单的流程图,帮你理解:

任务A运行中
    ↓
触发PendSV异常
    ↓
硬件自动保存:R0-R3, R12, LR, PC, xPSR 到任务A的栈
    ↓
软件手动保存:R4-R11 到任务A的栈
    ↓
更新TCB的pxTopOfStack = 当前SP
    ↓
选择下一个任务B
    ↓
从任务B的TCB恢复pxTopOfStack
    ↓
软件手动恢复:R4-R11 从任务B的栈
    ↓
硬件自动恢复:R0-R3, R12, LR, PC, xPSR 从任务B的栈
    ↓
任务B开始运行

你看,整个过程就像“换碟片”。A的碟片(上下文)先存好,然后换上B的碟片。FreeRTOS的调度器就是那个“换碟机”。

警告:千万不要在临界区里调用可能导致任务切换的函数!比如vTaskDelayxQueueSend等。我曾经在一个项目里,在关中断后调用了vTaskDelay,结果系统直接卡死。因为关中断后,PendSV异常无法触发,任务切换就失效了。

4.4 总结一下

TCB、任务栈、上下文,这三者是FreeRTOS任务调度的基石。TCB是“身份证”,记录任务的所有信息;任务栈是“工作台”,存放局部变量和上下文;上下文是“快照”,保证任务切换后能无缝衔接。

我个人觉得,理解这三者的关系,比背调度算法更重要。因为算法是死的,但系统设计是活的。你只有吃透了TCB,才能在遇到问题时,快速定位是栈溢出、还是上下文损坏、还是优先级配置不对。

好,这一章就到这里。下一章我们聊聊任务状态与状态迁移,看看任务是如何在就绪、阻塞、挂起之间“跳舞”的。