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 与系统其他模块的关系:
3.4 TCB 初始化与链表管理
TCB 不是定义出来就能用的,得初始化。我一般写一个 TCB_Init() 函数,干三件事:
- 清空整个 TCB 结构体,防止遗留数据污染
- 设置任务 ID、优先级、任务名
- 初始化栈指针,让 SP 指向栈顶
栈指针的初始化有个小技巧:先把栈顶地址减去中断现场的大小。因为任务第一次被调度时,会触发一个伪中断来加载上下文,如果 SP 没预留好位置,第一次压栈就把栈给压爆了。
核心原则: TCB 的链表管理要尽量简单。我推荐使用单向链表做就绪队列,双向链表做阻塞队列。单向链表插入快,双向链表删除快。别在一个链表上既要插入快又要删除快,那是给自己找麻烦。
最后说一句,TCB 的设计没有标准答案。不同的 MCU、不同的应用场景,侧重点都不一样。你在 8 位单片机上跑 4 个任务,TCB 可以精简到 12 个字节;你在 Cortex-M4 上跑 32 个任务,TCB 就得考虑缓存对齐和原子操作了。灵活一点,别死板。