任务与任务状态:任务的概念、任务状态机、任务控制块与任务栈

大家好,我是你们的老朋友。今天咱们来聊聊 RTOS 里最核心的东西——任务。说白了,整个 RTOS 就是围着任务转的。你想想看,没有任务,调度器调度谁去?

我刚开始接触 RTOS 那会儿,总觉得任务就是个“死循环函数”。后来踩了不少坑才明白,任务远不止这么简单。它有自己的生命周期、自己的“身份证”、自己的“小仓库”。这一章,咱们就把这些掰开揉碎了讲清楚。

2.1 任务的概念

在裸机编程里,我们写一个 main() 里的超级循环,把所有功能按顺序排好。但 RTOS 不一样,它把整个系统拆成一个个独立的任务(Task)。每个任务负责一件具体的事,比如:

  • 任务 A:读取温度传感器数据
  • 任务 B:刷新 OLED 屏幕
  • 任务 C:处理按键输入
  • 任务 D:通过 Wi-Fi 上报数据

这些任务看起来是“同时”运行的,实际上 CPU 在它们之间快速切换。这就是所谓的“并发”。

核心要点: 任务 = 独立的执行流 + 独立的栈空间 + 独立的优先级。每个任务都觉得自己独占了 CPU。

我记得有个学员问我:“那任务和函数有什么区别?” 区别大了。函数是被调用的,调用完就返回了。任务是个无限循环,它不返回。一旦返回,任务就结束了,RTOS 会把它清理掉。

2.2 任务状态机

任务不是一直都能运行的。它有好几种状态,在状态之间跳来跳去。这就是任务状态机。我习惯把它画成一张图,这样好理解。

RTOS 任务状态机 就绪态 Ready 运行态 Running 阻塞态 Blocked 挂起态 Suspended 调度器选择 时间片用完 / 被抢占 等待事件/资源 事件到达 / 超时 vTaskSuspend() vTaskResume() 说明: 就绪态等待 CPU;运行态占用 CPU;阻塞态等待外部事件;挂起态被主动暂停。 只有就绪态的任务才能被调度器选中进入运行态。

这张图我建议你多看几遍。四种状态之间的转换,是理解 RTOS 调度的基础。

2.2.1 就绪态(Ready)

任务已经准备好了,随时可以运行。但 CPU 正被别的任务占着,所以它在等。调度器会从就绪列表里挑一个优先级最高的来运行。

2.2.2 运行态(Running)

任务正在使用 CPU。在单核芯片上,同一时刻只有一个任务处于运行态。嗯,这里要注意:如果任务自己调用了 taskYIELD() 或者时间片到了,它就会回到就绪态。

2.2.3 阻塞态(Blocked)

任务在等某件事发生。比如等一个信号量、等队列里有数据、或者调用 vTaskDelay() 延时。这时候任务不消耗 CPU,调度器会去运行别的任务。

我的经验: 很多新手写 RTOS 程序,喜欢用 while(1) 空转等待。千万别!那会浪费 CPU。正确的做法是让任务进入阻塞态,等事件来了再唤醒。我曾经在一个项目中,把几个空转的 while 改成阻塞等待后,CPU 占用率从 85% 降到了 15%。

2.2.4 挂起态(Suspended)

任务被主动暂停了。调用 vTaskSuspend() 可以挂起一个任务,调用 vTaskResume() 可以恢复它。挂起态的任务不参与调度,直到被显式恢复。

2.3 任务控制块(TCB)

每个任务在 RTOS 内核里都有一个“身份证”,这就是任务控制块(Task Control Block,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;

关键字段说明:

字段 作用 我的理解
pxTopOfStack 保存任务当前的栈顶指针 切换任务时,CPU 寄存器就存在这里
xStateListItem 把任务挂到就绪/阻塞/挂起列表里 调度器靠这个找到任务
uxPriority 任务的优先级(0 最低) 数值越大,优先级越高
pxStack 指向任务栈的起始地址 栈溢出检查时要用到
pcTaskName 任务的名字,方便调试 我习惯用有意义的名称,比如 "LED_Task"
避坑指南: 我曾经在一个项目中,因为 TCB 里的栈指针被意外修改,导致任务切换时跑飞了。查了两天才发现是数组越界写坏了 TCB。从那以后,我每次创建任务都会在 TCB 后面加一个“哨兵”值,定期检查它有没有被篡改。

2.4 任务栈

每个任务都有自己的栈空间。这个栈用来保存:

  • 局部变量
  • 函数调用时的返回地址
  • 任务切换时的 CPU 寄存器(压栈保存)
  • 中断嵌套时的上下文

任务栈的大小怎么定?这是个好问题。定大了浪费 RAM,定小了会栈溢出。我一般这样估算:

  1. 先看任务里最大嵌套的函数调用深度
  2. 每个函数调用大概占 20-40 字节(看架构)
  3. 加上局部变量的大小
  4. 再加上中断嵌套的余量(通常 100-200 字节)
  5. 最后乘以 1.5 的安全系数

举个例子:

// 假设一个任务只有简单的 LED 闪烁
void vLEDTask(void *pvParameters) {
    while(1) {
        GPIO_Toggle(LED_PIN);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// 这个任务栈给 128 字节就够了
// 但为了安全,我通常给 256 字节
小技巧: 大多数 RTOS 都提供了栈使用量统计功能。比如 FreeRTOS 的 uxTaskGetStackHighWaterMark(),可以告诉你任务栈还剩多少没用。我习惯在产品开发阶段,让所有任务都跑一遍最极端的场景,然后看这个水位值,再调整栈大小。

任务栈和 TCB 的关系很紧密。TCB 里存着栈的起始地址和当前栈顶指针。任务切换时,CPU 先把当前寄存器的值压入当前任务的栈,然后从 TCB 里取出新任务的栈顶指针,恢复寄存器,最后跳转到新任务继续执行。

嗯,这个过程叫“上下文切换”。它是 RTOS 调度的核心动作。后面我们会专门讲它的实现细节。

最后说一句:任务栈是 RAM 里最容易被忽视的“隐形杀手”。栈溢出不会立刻报错,而是悄悄地破坏相邻的内存。等系统崩溃了,你都不知道问题出在哪。所以,一定要给每个任务留够栈空间,并且开启栈溢出检测。