3. 任务控制块(TCB):任务状态定义、任务栈结构、TCB数据结构设计

各位同学,咱们今天聊点实在的。任务控制块,简称 TCB,说白了就是每个任务在系统里的“身份证”和“档案袋”。你想想看,调度器要管理几十个任务,总得知道每个任务当前在干嘛、上次执行到哪了、自己的私有财产(栈空间)有多大吧?这些信息,全都塞在 TCB 里。

我记得刚入行那会儿,自己撸了一个简易调度器,TCB 就定义了一个栈指针和一个状态标志。结果跑起来各种诡异重启,查了三天才发现是任务栈溢出把 TCB 给踩了。嗯,从那以后,我对 TCB 的设计就再也不敢马虎了。

3.1 任务状态定义

任务在系统里不是一成不变的。它可能正在跑、可能等着被叫、也可能在睡大觉。我习惯把任务状态分成四种核心状态,再多就乱,再少又不够用。

状态名称 宏定义 含义 典型场景
就绪态 TASK_READY 任务已准备好,等待调度器分配 CPU 任务创建后、延时到期后
运行态 TASK_RUNNING 任务正在占用 CPU 执行 调度器选中该任务时
阻塞态 TASK_BLOCKED 任务因等待某事件而暂停 等待信号量、等待队列消息
挂起态 TASK_SUSPENDED 任务被强制暂停,不参与调度 调试阶段、低功耗模式

关键点: 运行态在单核 MCU 上同一时刻只能有一个任务处于该状态。就绪态和阻塞态是调度器最频繁操作的两个状态,它们的切换效率直接影响系统实时性。

实际项目中,我还会额外加一个 TASK_DELETED 状态,用来标记已经被删除但还没回收资源的任务。这样做的好处是——你想想看——如果某个任务正在跑的时候被删了,至少 TCB 还能留着做善后清理,不会直接崩掉。

3.2 任务栈结构

每个任务都有自己的栈,这是它的私有空间。局部变量、函数调用返回地址、中断现场,全往这里面塞。栈的大小怎么定?我踩过的坑是:宁大勿小,但别浪费。

任务栈的结构,从底到顶大致是这样:

高地址  ┌──────────────────────────┐  ← 栈顶指针 (SP)
        │    中断现场保存区        │  ← 发生中断时自动压入
        │  (xPSR, PC, LR, R0-R12) │
        ├──────────────────────────┤
        │    任务上下文            │  ← 任务切换时手动压入
        │  (R4-R11, 特殊寄存器)   │
        ├──────────────────────────┤
        │    局部变量区            │  ← 函数调用时分配
        │  (自动变量、临时变量)    │
        ├──────────────────────────┤
        │    函数调用栈帧          │  ← 嵌套调用时压入
        │  (返回地址、参数)        │
        ├──────────────────────────┤
        │    任务栈底标记          │  ← 用于栈溢出检测
低地址  └──────────────────────────┘  ← 栈起始地址

个人习惯: 我会在栈底填充一个固定模式(比如 0xDEADBEEF),然后在系统空闲时扫描栈空间。如果发现这个模式被破坏了,说明栈溢出了。这个方法简单粗暴,但非常有效。

栈大小的估算,我一般这样算:

  • 任务主函数的最大嵌套调用深度 × 每个栈帧大小
  • 加上中断嵌套的最大深度 × 中断现场大小
  • 再加 20% 的安全余量

举个例子,一个简单的按键扫描任务,我给 128 字节就够了。但一个带浮点运算的 PID 控制任务,我至少给 512 字节。别问我怎么知道的——有一次我给少了,系统跑了两个小时才随机崩溃一次,查得我差点把示波器砸了。

3.3 TCB 数据结构设计

TCB 是调度器的核心数据结构。设计得好,调度效率就高;设计得烂,整个系统都跟着遭殃。我个人习惯把 TCB 分成三个区域:标识区、控制区、资源区。

下面是我在多个项目中打磨出来的 TCB 结构体定义:

typedef struct tcb {
    /* === 标识区 === */
    uint8_t     task_id;            // 任务唯一ID,从0开始
    uint8_t     task_priority;      // 任务优先级,0最高
    char        task_name[16];      // 任务名称,方便调试

    /* === 控制区 === */
    uint8_t     task_state;         // 任务状态:就绪/运行/阻塞/挂起
    uint32_t    task_delay_ticks;   // 延时剩余节拍数
    uint32_t    task_timeout;       // 等待超时时间

    /* === 资源区 === */
    uint32_t    *task_stack_ptr;    // 当前栈指针 (SP)
    uint32_t    *task_stack_base;   // 栈底地址
    uint32_t    task_stack_size;    // 栈大小(字节)

    /* === 扩展区 === */
    void        *task_msg_queue;    // 任务消息队列指针
    uint32_t    task_flags;         // 自定义标志位
    struct tcb  *next;              // 链表指针,用于就绪队列
} TCB_t;

注意: task_stack_ptr 和 task_stack_base 是两个不同的东西。前者是动态变化的,指向当前栈顶;后者是固定的,指向栈的起始地址。我见过有人把这两个搞混,结果任务切换时栈指针飞到了外太空。

为什么要把 next 指针放在最后?因为在实际调度中,就绪队列的操作非常频繁,把链表指针放在结构体末尾,可以方便地通过偏移量访问,在某些汇编优化的场景下能省几条指令。

下面这张图展示了 TCB 与系统其他模块的关系:

TCB 与调度系统核心关系图 任务控制块 (TCB) 任务状态 · 栈指针 · 优先级 任务栈 局部变量 中断现场 函数调用栈帧 调度器 就绪队列 优先级仲裁 上下文切换 系统时钟节拍 SysTick 中断 任务函数实体 while(1) 循环 说明: TCB 是调度系统的中枢,连接任务栈(存储现场)、调度器(决策执行)、 系统时钟(驱动节拍)和任务函数(实际执行体)。四者缺一不可。

3.4 TCB 初始化与链表管理

TCB 不是定义出来就能用的,得初始化。我一般写一个 TCB_Init() 函数,干三件事:

  1. 清空整个 TCB 结构体,防止遗留数据污染
  2. 设置任务 ID、优先级、任务名
  3. 初始化栈指针,让 SP 指向栈顶

栈指针的初始化有个小技巧:先把栈顶地址减去中断现场的大小。因为任务第一次被调度时,会触发一个伪中断来加载上下文,如果 SP 没预留好位置,第一次压栈就把栈给压爆了。

核心原则: TCB 的链表管理要尽量简单。我推荐使用单向链表做就绪队列,双向链表做阻塞队列。单向链表插入快,双向链表删除快。别在一个链表上既要插入快又要删除快,那是给自己找麻烦。

最后说一句,TCB 的设计没有标准答案。不同的 MCU、不同的应用场景,侧重点都不一样。你在 8 位单片机上跑 4 个任务,TCB 可以精简到 12 个字节;你在 Cortex-M4 上跑 32 个任务,TCB 就得考虑缓存对齐和原子操作了。灵活一点,别死板。