3. 任务状态机与状态迁移:就绪态、运行态、阻塞态、挂起态
任务调度这事儿,说白了就是让 CPU 在不同任务之间来回切换。但任务不是想切就能切的——它得处于正确的状态才行。我刚开始学 RTOS 时,总觉得状态机这东西太理论,后来在项目里踩了坑才明白:不理解状态迁移,写出来的调度器就是定时炸弹。
这一节,咱们把任务状态机彻底讲透。四个核心状态:就绪态、运行态、阻塞态、挂起态。以及它们之间怎么跳转,代码怎么写。
3.1 四态模型:任务的一生
一个任务从创建到销毁,无非就在这四种状态里打转。我习惯用一张图来理解:
核心概念:任务在任何时刻,只能处于且必须处于四种状态之一。这是 RTOS 调度器能够正确工作的前提。
3.2 每个状态的定义与典型场景
3.2.1 就绪态(Ready)
任务已经准备好运行,只等 CPU 分配给它。说白了,任务的所有资源都到位了,就差一个「上场」的机会。就绪态的任务会被放在就绪队列里,调度器从这里挑下一个要运行的任务。
我在项目中遇到过一个问题:某个任务一直处于就绪态但就是跑不起来。后来发现是优先级设置错了,它被更高优先级的任务「饿死」了。嗯,这就是就绪态的一个典型陷阱——优先级反转和任务饿死。
3.2.2 运行态(Running)
CPU 正在执行这个任务的代码。在单核 MCU 上,同一时刻只能有一个任务处于运行态。你想想看,一个核一次只能干一件事,对吧?
运行态的任务会占用 CPU 直到以下情况发生:
- 时间片耗尽(被调度器强制切换)
- 主动让出 CPU(调用延时或等待 API)
- 被更高优先级任务抢占
3.2.3 阻塞态(Blocked)
任务在等待某个事件或资源。比如等一个信号量、等队列里有数据、或者单纯在延时。阻塞态的任务不参与调度——调度器根本不会看它,直到它等待的条件满足。
我的经验:阻塞态是 RTOS 的灵魂。没有阻塞态,所有任务都在轮询,CPU 利用率会低得可怜。我见过有人把延时写成 for(i=0;i<1000000;i++); 这种空循环——这等于把 RTOS 当裸机用,白费了。
3.2.4 挂起态(Suspended)
任务被「冻结」了。它不参与调度,也不等待任何事件。挂起态通常由用户主动调用 API 触发,比如调试时暂停某个任务,或者系统进入低功耗模式前挂起非关键任务。
挂起态和阻塞态的区别?阻塞态是「我在等某件事」,挂起态是「我不想干活了,别叫我」。恢复挂起态的任务后,它回到就绪态,而不是继续原来的等待。
3.3 状态迁移的触发条件
状态不会自己变,得有触发条件。我整理了一张表,把四种状态之间的迁移条件列清楚:
| 源状态 | 目标状态 | 触发条件 | 典型 API |
|---|---|---|---|
| 就绪态 | 运行态 | 调度器选中该任务 | 调度器内部 |
| 运行态 | 就绪态 | 时间片耗尽、被抢占 | 调度器内部 |
| 运行态 | 阻塞态 | 等待资源、延时 | vTaskDelay()、xSemaphoreTake() |
| 阻塞态 | 就绪态 | 等待条件满足 | 中断/超时回调 |
| 运行态 | 挂起态 | 主动挂起 | vTaskSuspend() |
| 就绪态 | 挂起态 | 主动挂起(部分 RTOS 支持) | vTaskSuspend() |
| 挂起态 | 就绪态 | 恢复任务 | vTaskResume() |
注意:阻塞态不能直接进入挂起态。必须先回到就绪态,再挂起。有些 RTOS 允许从阻塞态直接挂起,但这不是标准做法,容易导致资源泄漏——我见过有人这么用,结果信号量计数乱掉了。
3.4 代码实现:状态机与状态切换
理论说完了,上代码。我以一个简化版的 RTOS 内核为例,展示任务状态的定义和切换逻辑。
3.4.1 任务控制块(TCB)中的状态字段
/* 任务状态枚举 */
typedef enum {
TASK_READY = 0x01, /* 就绪态 */
TASK_RUNNING = 0x02, /* 运行态 */
TASK_BLOCKED = 0x04, /* 阻塞态 */
TASK_SUSPENDED = 0x08 /* 挂起态 */
} TaskState_t;
/* 任务控制块 */
typedef struct tcb {
uint32_t *stack_ptr; /* 栈指针 */
TaskState_t state; /* 当前状态 */
uint32_t priority; /* 优先级 */
uint32_t delay_ticks; /* 延时剩余节拍 */
void *wait_obj; /* 等待的对象(信号量/队列等) */
struct tcb *next; /* 链表指针 */
} TCB_t;
这里我用位掩码定义状态,而不是简单的 0-3 枚举。为什么?因为有些 RTOS 允许任务同时处于多个状态(比如阻塞+挂起),位掩码方便组合判断。不过咱们这节课先讲标准四态,不搞复杂。
3.4.2 状态切换函数
状态切换的核心逻辑,其实就是修改 TCB 中的 state 字段,然后把任务从旧队列移到新队列。我写几个关键函数:
/* 将任务从就绪态切换到运行态 */
void task_switch_to_running(TCB_t *task) {
/* 从就绪队列移除 */
remove_from_ready_list(task);
/* 更新状态 */
task->state = TASK_RUNNING;
/* 设置当前运行任务指针 */
current_task = task;
}
/* 将任务从运行态切换到阻塞态 */
void task_block(TCB_t *task, uint32_t ticks) {
/* 保存延时节拍 */
task->delay_ticks = ticks;
/* 更新状态 */
task->state = TASK_BLOCKED;
/* 加入阻塞队列(按超时时间排序) */
insert_to_blocked_list(task);
/* 触发调度,选择下一个就绪任务 */
schedule();
}
/* 将任务从阻塞态切换到就绪态(条件满足时调用) */
void task_unblock(TCB_t *task) {
/* 从阻塞队列移除 */
remove_from_blocked_list(task);
/* 更新状态 */
task->state = TASK_READY;
/* 加入就绪队列 */
insert_to_ready_list(task);
/* 如果新任务优先级更高,触发调度 */
if (task->priority > current_task->priority) {
schedule();
}
}
/* 挂起任务 */
void task_suspend(TCB_t *task) {
/* 根据当前状态做不同处理 */
switch (task->state) {
case TASK_READY:
remove_from_ready_list(task);
break;
case TASK_RUNNING:
/* 运行态挂起,需要触发调度 */
break;
case TASK_BLOCKED:
/* 标准做法:不允许从阻塞态直接挂起 */
/* 这里可以报错或忽略 */
return;
default:
break;
}
task->state = TASK_SUSPENDED;
insert_to_suspended_list(task);
/* 如果是当前任务,重新调度 */
if (task == current_task) {
schedule();
}
}
/* 恢复挂起的任务 */
void task_resume(TCB_t *task) {
if (task->state != TASK_SUSPENDED) {
return; /* 只有挂起态才能恢复 */
}
remove_from_suspended_list(task);
task->state = TASK_READY;
insert_to_ready_list(task);
/* 检查是否需要抢占 */
if (task->priority > current_task->priority) {
schedule();
}
}
3.4.3 调度器中的状态判断
调度器选择下一个任务时,只从就绪队列里挑。代码很简单:
TCB_t *scheduler_select_next(void) {
TCB_t *highest = NULL;
uint32_t highest_prio = 0;
/* 遍历就绪队列,找最高优先级任务 */
TCB_t *curr = ready_list_head;
while (curr != NULL) {
if (curr->priority > highest_prio) {
highest_prio = curr->priority;
highest = curr;
}
curr = curr->next;
}
return highest;
}
看到了吗?调度器根本不关心阻塞态和挂起态的任务。它们不在就绪队列里,调度器「看不见」它们。这就是状态机的意义——把不参与调度的任务隔离出去,减少搜索开销。
3.5 避坑指南:我踩过的几个坑
坑一:从阻塞态直接挂起
我曾经在一个项目里,为了「省事」,允许从阻塞态直接挂起任务。结果任务恢复后,它还在等那个永远不会来的信号量——因为挂起期间信号量已经被别的任务取走了。后来我花了三天才定位到这个 bug。从此以后,我严格遵循「阻塞态必须先回到就绪态才能挂起」的规则。
坑二:状态切换忘记关中断
状态切换操作不是原子的。如果在切换过程中被中断打断,就绪队列可能被破坏。我习惯在状态切换函数开头关中断,操作完再开。临界区保护的具体做法,咱们后面章节会详细讲。
坑三:挂起态任务的资源释放
挂起一个任务前,一定要确认它持有的资源(互斥锁、动态内存等)已经释放。否则挂起后资源被「锁死」,其他任务永远拿不到。我见过一个案例:挂起了一个持有互斥锁的任务,结果所有等待这个锁的任务全部阻塞,系统直接「假死」。
3.6 小结
任务状态机是 RTOS 调度器的基石。四个状态——就绪、运行、阻塞、挂起——定义了任务的生命周期。状态迁移的触发条件必须清晰,代码实现要保证原子性。
我个人习惯在调试时,把每个任务的状态打印出来。看到任务在四个状态间正常流转,心里才踏实。如果某个任务卡在某个状态不动了,那八成是状态迁移条件没满足,或者代码里有 bug。