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;
嗯,这里要注意:sp 和 stack_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 是调度器的核心数据结构,它的访问频率极高,所以设计时要兼顾功能完整性和访问效率。
下一节咱们聊聊就绪队列怎么组织。是数组?是链表?还是位图?每种方式都有它的脾气。到时候我把我踩过的坑都告诉你。