2. 任务模型与状态:任务的定义、任务状态与TCB结构

各位同学,咱们今天聊聊任务模型。这东西看着基础,但说真的,我见过太多工程师在这上面栽跟头。我自己刚入行那会儿,也把任务状态搞混过,结果系统跑着跑着就卡死了。嗯,咱们今天就把这事儿彻底理清楚。

2.1 任务到底是什么?

任务,说白了就是一段代码,加上它运行所需要的上下文环境。你想想看,一个函数能叫任务吗?不能。因为函数跑完就完了,没有“状态”这个概念。任务不一样,它有自己的生命周期。

我个人习惯把任务理解成“一个独立的小程序”。它有自己的栈空间、自己的优先级、自己的运行节奏。在实时系统里,任务就是最小的调度单位。

任务的核心三要素:

  • 代码段:任务要执行的指令序列
  • 数据段:任务私有的变量和堆栈
  • 控制块:描述任务属性的数据结构(就是咱们马上要讲的TCB)

我在项目中遇到过一件事:有个同事把一个大循环直接写在main函数里,觉得这样省事。结果系统要响应外部中断时,这个循环死活不肯让出CPU。这就是典型的“没有任务化”的后果。记住,在实时系统里,所有并发执行的东西,都应该封装成任务。

2.2 任务的状态机

任务不是一直“活着”的。它有几种状态,在状态之间跳来跳去。咱们先看一张图,把整体脉络搞清楚。

任务状态转换图 就绪 Ready 运行 Running 阻塞 Blocked 挂起 Suspended 调度器选中 时间片用完/抢占 等待资源/事件 资源就绪/事件到达 主动挂起 恢复 挂起阻塞任务 恢复后仍阻塞

这张图我画了好一会儿。你看,任务有四个核心状态:就绪、运行、阻塞、挂起。每个状态之间的转换,背后都有明确的触发条件。

2.3 四个状态详解

就绪(Ready)

任务已经准备好了,随时可以运行。但CPU现在没空,有其他任务在跑。说白了就是“排队等CPU”。

我举个例子:你在食堂排队打饭,前面的人还没打完,你就在“就绪”状态。你手里端着盘子,随时可以上前一步。

运行(Running)

CPU正在执行这个任务的代码。同一时刻,单核CPU上只能有一个任务在运行。这是铁律。

小提示: 运行态的任务如果主动调用 delay() 或者等待信号量,就会从运行态切换到阻塞态。如果被更高优先级的任务抢占了,就会回到就绪态。

阻塞(Blocked)

任务在等某个东西。等什么?等一个信号量、等一个消息队列、等一个定时器超时。总之,它现在没法继续往下走。

我曾经调试过一个系统,发现有个任务一直不执行。查了半天,原来是它等的一个信号量永远没人释放。这就是典型的“死锁”前兆。嗯,遇到这种情况,先检查阻塞条件是否真的能满足。

挂起(Suspended)

这个状态比较特殊。任务被“冻结”了,调度器根本不会考虑它。只有通过其他任务或者中断服务程序,才能把它“解冻”。

挂起和阻塞的区别是什么?阻塞是“我想跑但跑不了”,挂起是“我不让跑”。你想想看,挂起通常用于调试,或者临时禁用某个功能模块。

注意: 挂起状态的任务,不会响应任何事件。如果你挂起了一个负责看门狗喂狗的任务,系统可能会复位。我曾经...嗯,不提了,都是泪。

2.4 任务控制块(TCB)

每个任务都有一个“身份证”,这就是TCB。操作系统通过TCB来管理任务。TCB里存了什么?咱们直接看代码。

// 一个典型的TCB结构(以FreeRTOS为例,简化版)
typedef struct tskTaskControlBlock {
    volatile StackType_t *pxTopOfStack;    // 栈顶指针
    ListItem_t xStateListItem;             // 状态链表节点
    ListItem_t xEventListItem;             // 事件链表节点
    UBaseType_t uxPriority;                // 任务优先级
    StackType_t *pxStack;                  // 栈起始地址
    char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名字
    // ... 还有其他字段,比如栈大小、临界区嵌套计数等
} tskTCB;

你看,TCB里最重要的就是栈指针优先级。栈指针保存了任务的上下文——寄存器值、局部变量、函数调用链。优先级决定了调度器该先让谁跑。

我个人习惯在TCB里再加一个字段:uxTaskID,用来做任务标识。虽然系统有名字,但名字比较字符串太慢了,用ID做索引快得多。

TCB字段 作用 我的经验
pxTopOfStack 保存任务切换时的寄存器现场 这个指针搞错了,任务切换必崩
uxPriority 决定调度顺序 优先级别设太多,8级以内最好管理
pxStack 任务私有的栈空间 栈大小宁大勿小,我吃过栈溢出的亏
xStateListItem 把任务挂到就绪/阻塞等链表上 链表操作要加临界区保护

核心要点:

  • TCB是任务在操作系统里的“影子”
  • 任务切换的本质就是切换TCB中的栈指针
  • TCB占用的RAM要提前算好,别等到运行时才发现不够

2.5 状态转换的实战场景

咱们看一个实际例子。假设系统里有三个任务:A(高优先级)、B(中优先级)、C(低优先级)。

  1. C正在运行,突然A就绪了。调度器一看,A优先级更高,立刻把C踢回就绪态,让A运行。
  2. A运行到一半,要等一个串口数据,于是调用阻塞函数,进入阻塞态。
  3. 调度器一看,A阻塞了,那就让C继续跑吧。B虽然优先级比C高,但B也在阻塞态(等定时器)。
  4. 串口数据到了,中断里释放信号量,A从阻塞态回到就绪态。调度器发现A优先级最高,又把C踢开,让A跑。

你看,这一套流程下来,任务状态来回切换。如果哪个环节出了问题——比如中断里忘了释放信号量——任务就会一直卡在阻塞态。这就是所谓的“任务饿死”。

调试技巧: 我建议你在每个任务的主循环里加一个状态输出。比如用串口打印当前任务的TCB地址和状态。这样系统跑飞了,你能快速定位是哪个任务出了问题。

2.6 避坑指南

最后,分享几个我踩过的坑:

  • 栈溢出:任务里用了大数组或者深层递归,栈空间不够。后果就是数据被踩,系统行为诡异。我曾经排查了一个星期的bug,最后发现是栈溢出了。从那以后,我每个任务都留30%的栈余量。
  • 优先级反转:低优先级任务占着资源不放,高优先级任务等资源被阻塞,中优先级任务趁机抢跑。解决方案是优先级继承或者使用互斥量。
  • 忘记初始化TCB:创建任务时,TCB里的字段要清零。否则残留数据可能导致调度器误判任务状态。

好了,任务模型这部分就讲到这里。记住一句话:理解任务状态,就理解了实时系统的调度本质。下一节咱们聊调度算法,到时候你会更深刻地体会到状态转换的重要性。