3. 任务状态机与状态迁移:就绪态、运行态、阻塞态、挂起态

任务调度这事儿,说白了就是让 CPU 在不同任务之间来回切换。但任务不是想切就能切的——它得处于正确的状态才行。我刚开始学 RTOS 时,总觉得状态机这东西太理论,后来在项目里踩了坑才明白:不理解状态迁移,写出来的调度器就是定时炸弹。

这一节,咱们把任务状态机彻底讲透。四个核心状态:就绪态、运行态、阻塞态、挂起态。以及它们之间怎么跳转,代码怎么写。

3.1 四态模型:任务的一生

一个任务从创建到销毁,无非就在这四种状态里打转。我习惯用一张图来理解:

核心概念:任务在任何时刻,只能处于且必须处于四种状态之一。这是 RTOS 调度器能够正确工作的前提。

任务状态机四态模型 运行态 Running 就绪态 Ready 阻塞态 Blocked 挂起态 Suspended 调度器选择 时间片耗尽/抢占 等待资源/延时 条件满足 主动挂起 恢复 (部分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。