3. 任务管理基础:任务控制块(TCB)、任务状态机、任务创建与删除API

任务管理,说白了就是RTOS的“心脏”。你想想看,一个系统里跑着好几个任务,谁先跑、谁后跑、谁在等什么——这些都得有个规矩。而这个规矩的核心,就是任务控制块(TCB)、任务状态机,以及创建/删除的API。

我个人习惯,讲任务管理一定先从TCB入手。为什么?因为TCB就是每个任务的“身份证”。没有它,调度器根本不知道你是谁。

3.1 任务控制块(TCB)—— 任务的“身份证”

每个任务在RTOS里都有一个对应的数据结构,这就是TCB。它记录了任务的所有关键信息。我见过不少初学者,写任务时只关注函数体,却忽略了TCB的初始化——结果系统一跑就崩。

核心观点:TCB是任务存在的唯一凭证。调度器不认函数名,只认TCB指针。

一个典型的TCB结构体长什么样?我们来看一下:

typedef struct tcb {
    uint32_t    *sp;                // 栈指针(保存CPU上下文)
    uint8_t     priority;           // 任务优先级
    uint32_t    *stack_base;        // 栈底地址
    uint32_t    stack_size;         // 栈大小(单位:字)
    uint32_t    state;              // 任务状态
    char        name[16];           // 任务名(调试用)
    struct tcb  *next;              // 链表指针
    uint32_t    delay_ticks;        // 延时计数
    void        *msg_queue;         // 消息队列指针(可选)
} TCB_t;

这里我重点说几个字段:

  • sp(栈指针)—— 这是最重要的字段。任务切换时,CPU寄存器全部压栈,sp就指向栈顶。调度器恢复任务时,第一件事就是恢复sp。
  • priority—— 优先级决定了任务被调度的顺序。我在项目中遇到过一个问题:两个任务优先级设反了,结果高优先级任务一直占着CPU,低优先级任务饿死了。嗯,调了一整天才发现。
  • state—— 任务当前处于什么状态,后面会细讲。
  • stack_base / stack_size—— 栈空间的管理。我曾经犯过一个错:栈大小给少了,任务一跑就栈溢出,踩坏了相邻的TCB数据。从那以后,我每个任务都留出20%的栈余量。

经验之谈:TCB通常放在一个全局数组里静态分配,或者从内存池动态分配。我个人偏好静态分配——嵌入式系统里,动态分配容易产生碎片,你懂的。

3.2 任务状态机 —— 任务的一生

任务不是一直在跑的。它有多种状态,状态之间可以切换。这就是任务状态机。

RTOS里常见的状态有四种:

状态 含义 典型触发条件
就绪(Ready) 任务可以运行,但CPU正被别的任务占用 创建完成、被唤醒、时间片用完
运行(Running) 任务正在占用CPU执行 调度器选中该任务
阻塞(Blocked) 任务在等待某个事件(延时、信号量、队列等) 调用延时函数、等待资源
挂起(Suspended) 任务被强制暂停,不参与调度 调用挂起API

状态切换的流程,我画了一张图:

任务状态机 就绪(Ready) 等待CPU 运行(Running) 占用CPU 阻塞(Blocked) 等待事件 挂起(Suspended) 强制暂停 调度选中 时间片用完 等待资源 事件到达 挂起API 恢复API 注意 一个时刻只能有一个 任务处于Running状态

为什么会这样设计?因为任务不能一直占着CPU。你想想看,如果一个任务在等串口数据,它没必要空转——进入阻塞状态,把CPU让给别人,这才是RTOS的优雅之处。

避坑指南:我曾经在一个项目中,所有任务都只用了就绪和运行两个状态,没有用阻塞。结果CPU空转率高达70%,功耗根本压不下去。后来改成事件驱动+阻塞,CPU利用率直接降到15%。记住:阻塞不是坏事,它是RTOS的灵魂。

3.3 任务创建与删除API

有了TCB和状态机,接下来就是怎么创建和删除任务了。这两个API是RTOS的入口和出口。

3.3.1 任务创建

任务创建的典型API长这样:

// 函数原型
int task_create(
    TCB_t       *tcb,           // 指向TCB的指针
    const char  *name,           // 任务名
    void        (*entry)(void*), // 任务入口函数
    void        *param,          // 入口函数参数
    uint8_t     priority,        // 优先级
    uint32_t    *stack_base,     // 栈底
    uint32_t    stack_size       // 栈大小
);

创建任务时,RTOS内部做了三件事:

  1. 初始化TCB—— 填充优先级、状态设为就绪、清空链表指针。
  2. 初始化栈—— 在栈里预置好CPU上下文(模拟一次“刚被中断”的状态)。这样调度器第一次切换时,就能直接恢复上下文,开始执行任务函数。
  3. 加入就绪队列—— 把TCB挂到就绪链表中,等待调度器选中。

我建议你在创建任务时,注意以下几点:

  • 栈大小要合理—— 太小会溢出,太大浪费内存。一般任务512字节起步,带复杂局部变量的给1KB。
  • 优先级不要重复—— 同优先级任务用时间片轮转,但如果你想要确定性的调度行为,最好每个任务优先级不同。
  • 入口函数不能返回—— 任务函数通常是一个无限循环。如果返回了,RTOS会调用一个空处理函数,任务就“死”了。

个人习惯:我写任务入口函数时,一定在函数末尾加一句 while(1){} 或者调用 task_delete(NULL) 来显式结束。这样即使逻辑出错,也不会让任务“裸奔”。

3.3.2 任务删除

删除任务比创建更危险。为什么?因为你要确保任务当前没有持有任何资源。

// 函数原型
int task_delete(TCB_t *tcb);

删除操作的核心步骤:

  1. 从所有队列中移除—— 就绪队列、阻塞队列、挂起队列,都得清掉。
  2. 释放栈空间—— 如果栈是动态分配的,这里要free。静态分配的,标记为“空闲”。
  3. 清空TCB—— 把TCB里的关键字段清零,防止野指针。

避坑指南:我曾经在一个项目中,直接删除了一个正在持有互斥锁的任务。结果其他任务永远等不到锁,整个系统死锁了。从那以后,我定了一条铁律:删除任务前,必须确保它不持有任何资源。如果非要删,先让任务自己释放资源,再调用删除API。

3.4 实战要点总结

任务管理这块,说难不难,说简单也不简单。我总结几个关键点:

  • TCB是核心—— 所有调度操作都围绕TCB展开。调试时,先看TCB里的state和sp,基本能定位90%的问题。
  • 状态切换要闭环—— 每个状态都要有明确的进入和退出条件。不要出现“任务既在就绪队列又在阻塞队列”这种荒唐事。
  • 创建和删除要配对—— 动态创建的任务一定要有对应的删除逻辑,否则内存泄漏在嵌入式系统里是致命的。

嗯,任务管理的基础就这些。你把这些搞懂了,后面学调度算法、同步机制,就会轻松很多。