3. 任务控制块(TCB):TCB数据结构设计、关键成员变量

好,咱们进入第三个核心话题——任务控制块。我常说,TCB 就是 RTOS 里任务的「身份证」。你想想看,一个任务从创建到销毁,内核怎么知道它是谁?它跑在哪儿?它有没有资格抢 CPU?全靠 TCB 里那几十个字节。

我个人习惯把 TCB 设计成「轻量级」的。别往里面塞太多东西,否则每次调度切换的开销会变大。但该有的一个都不能少。今天咱们就拆开看看,一个合格的 TCB 到底长什么样。

3.1 TCB 数据结构设计

先看一个最精简的 TCB 结构体。这是我早期做的一个小内核里的定义,后来在工业项目里也基本沿用这个骨架:

typedef struct tcb {
    uint32_t    *sp;          // 栈指针,指向当前任务栈顶
    uint32_t    priority;     // 任务优先级,数值越小优先级越高
    uint32_t    state;        // 任务状态:就绪、运行、阻塞、挂起等
    void        (*entry)(void *arg); // 任务入口函数
    void        *arg;         // 入口函数参数
    struct tcb  *next;        // 链表指针,用于就绪队列或阻塞队列
    uint32_t    stack_size;   // 栈大小(字节)
    uint32_t    *stack_base;  // 栈底地址,用于栈溢出检测
} TCB_t;

嗯,这里要注意:spstack_base 是两个不同的东西。sp 是 CPU 当前用的栈指针,每次任务切换时保存和恢复的就是它。stack_base 是栈的起始地址,我一般用它来做栈溢出检测——如果 sp 跑到了 stack_base 以下,那说明栈溢出了,得赶紧处理。

核心原则:TCB 的大小直接影响上下文切换的速度。我建议把频繁访问的成员(比如 sp、priority)放在结构体最前面,这样 CPU 访问时 cache 命中率更高。

3.2 关键成员变量详解

3.2.1 栈指针(sp)

这是 TCB 里最重要的成员,没有之一。每次任务切换时,CPU 会把当前寄存器的值压栈,然后把新的 sp 加载到 CPU 的栈指针寄存器里。说白了,sp 就是任务的「现场快照」。

我在项目中遇到过一个问题:有个任务跑着跑着就飞了,查了两天才发现是 TCB 里的 sp 被意外改写了。后来我加了个保护机制——每次切换任务时,都检查 sp 是否在合法范围内。

避坑指南:我曾经在 Cortex-M3 上做移植时,忘了把 sp 初始化为栈顶减 8 字节(因为异常压栈会先压入 8 个寄存器)。结果第一个任务启动就崩了。记住:不同架构的初始栈指针偏移量不一样,一定要看芯片手册。

3.2.2 优先级(priority)

优先级决定了任务能不能抢别人。在抢占式调度里,高优先级任务一旦就绪,低优先级任务就得乖乖让路。

我一般用数值越小优先级越高的方式。为什么?因为很多硬件(比如 NVIC)也是这么设计的,统一标准不容易搞混。

优先级的设计有几个要点:

  • 数值范围:通常 0~255,0 留给空闲任务或系统任务
  • 动态优先级:有些内核支持优先级继承,防止优先级反转。我建议初学者先别碰这个,先把静态优先级玩明白
  • 优先级分组:比如把 0~63 分给实时任务,64~127 分给普通任务,方便管理

警告:不要把所有任务都设成同一个优先级!那样抢占式调度就退化成时间片轮转了。我见过有人把 10 个任务全设成优先级 5,结果调度器直接懵了——该抢谁?

3.2.3 状态(state)

任务状态是调度器的决策依据。一个任务当前能不能运行,就看它的状态。我一般定义这么几种状态:

状态 宏定义 说明
就绪 TASK_READY 任务已准备好,等待调度器分配 CPU
运行 TASK_RUNNING 当前正在使用 CPU
阻塞 TASK_BLOCKED 等待某个事件(信号量、队列、延时等)
挂起 TASK_SUSPENDED 被其他任务或自身主动挂起
终止 TASK_TERMINATED 任务执行完毕,等待回收

你可能会问:为什么要有「挂起」和「阻塞」两种不同的等待状态?我解释一下:阻塞是任务在等某个资源,资源到了它自动变就绪;挂起是被人为暂停了,必须由别人调用 resume() 才能恢复。两者在调度器里的处理逻辑完全不同。

3.3 状态转换与调度逻辑

状态之间怎么跳转?我画个简单的逻辑给你看:

// 伪代码:调度器核心逻辑
void scheduler(void) {
    TCB_t *current = get_current_task();
    TCB_t *next = NULL;

    // 从就绪队列中找最高优先级的任务
    next = get_highest_ready_task();

    if (next == NULL) {
        // 没有就绪任务?跑空闲任务
        next = &idle_tcb;
    }

    if (next != current) {
        // 切换上下文
        current->state = TASK_READY;   // 当前任务变为就绪
        next->state = TASK_RUNNING;    // 新任务变为运行
        context_switch(¤t->sp, &next->sp);
    }
}

这里有个细节:context_switch() 函数会保存当前 CPU 寄存器到 current->sp,然后从 next->sp 恢复新任务的寄存器。这个函数通常用汇编写,因为要直接操作栈指针寄存器。

个人经验:我建议在 TCB 里加一个 state 的位掩码版本。比如用 bit0 表示是否就绪,bit1 表示是否阻塞。这样判断状态时可以用位运算,比 if-else 快很多。在实时性要求高的场景下,这点优化很值得。

3.4 其他实用成员

除了上面三个核心成员,我还会根据需求加一些辅助成员:

  • 任务名称:一个字符串指针,调试时打印任务信息用。别用固定数组,太占空间
  • 时间片计数:用于时间片轮转调度,记录当前任务还剩多少时间片
  • 等待事件 ID:任务在阻塞时,记录它在等哪个信号量或队列,方便快速唤醒
  • 栈使用峰值:每次任务切换时记录栈指针位置,用来估算栈使用量。我调试时经常靠这个发现栈设小了

嗯,说到栈使用峰值,我想起一个案例。有个同事把任务栈设成了 256 字节,结果跑着跑着就死机。我让他加上栈使用峰值监控,一测发现峰值到了 240 字节,离溢出就差 16 字节。后来改成 512 字节,问题解决。所以啊,TCB 里多一个监控成员,关键时刻能救命。

3.5 小结

TCB 的设计没有标准答案,但核心成员就那么几个:栈指针、优先级、状态。你可以在这些基础上自由发挥,但记住一条原则——TCB 是调度器的核心数据结构,它的访问频率极高,所以设计时要兼顾功能完整性和访问效率。

下一节咱们聊聊就绪队列怎么组织。是数组?是链表?还是位图?每种方式都有它的脾气。到时候我把我踩过的坑都告诉你。