调度器核心设计:任务控制块(TCB)、任务状态与优先级

调度器说白了就是操作系统的“大脑”。它决定哪个任务该跑,哪个任务该等。而支撑这个决策的,就是三个核心概念:任务控制块(TCB)、任务状态和优先级。

我个人习惯把TCB比作任务的“身份证”。没有它,调度器根本不知道你是谁。咱们一步步拆开看。

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

每个任务在系统里都有一个专属的数据结构,这就是TCB。它记录了任务的所有关键信息。我早期做项目时,TCB设计得太简陋,结果调试时连任务栈溢出了都查不出来。嗯,后来我学乖了。

一个典型的TCB长这样:

typedef struct {
    uint32_t    *sp;            // 栈指针(核心)
    uint8_t     priority;       // 任务优先级
    uint8_t     state;          // 任务状态
    uint32_t    stack_size;     // 栈大小
    uint32_t    *stack_base;    // 栈基地址
    void        (*task_func)(void *arg); // 任务函数指针
    void        *arg;           // 任务参数
    uint32_t    delay_ticks;    // 延时计数
    uint32_t    slice_ticks;    // 时间片计数
    char        name[16];       // 任务名(调试用)
} TCB_t;

这里我重点说几个字段:

  • sp(栈指针):这是调度器的命根子。任务切换时,CPU就是靠保存和恢复这个指针来切换上下文的。
  • priority:决定了任务在就绪队列里的排队顺序。数值越小优先级越高,这是业界惯例。
  • state:任务当前处于什么状态,后面会细说。
  • delay_ticks:任务主动延时或等待事件时用的计数器。每次系统时钟节拍中断,调度器会遍历所有任务,把非0的delay_ticks减1。
我的经验:TCB里一定要留一个name字段。别省那十几个字节。我在项目里遇到过,任务跑飞了,靠打印任务名才定位到是哪个任务出了问题。没有名字,你只能对着地址发愣。

任务状态机——从生到死

任务不是一直都能跑的。它会在几个状态之间跳来跳去。我画了一张图,你看一眼就明白了。

任务状态转换图 就绪态 运行态 阻塞态 挂起态 调度器选择 时间片用完/被抢占 等待事件/延时 事件到达/延时结束 主动挂起 恢复任务 超时挂起(可选) 说明: • 就绪态:任务具备运行条件,等待调度器分配CPU • 运行态:任务正在占用CPU执行 • 阻塞态:任务因等待资源/事件/延时而暂停,不参与调度

四个状态,你记一下:

状态 含义 典型触发条件
就绪态 任务一切就绪,只等CPU 创建任务、延时结束、事件到达
运行态 正在占用CPU执行 调度器选中该任务
阻塞态 等待某件事发生 调用延时、等待信号量、等待队列消息
挂起态 被强制暂停,不参与调度 被其他任务挂起、调试暂停
我曾经踩过的坑:任务在阻塞态时,千万别去操作它的栈空间。有一次我在中断里直接修改了一个阻塞任务的TCB栈指针,结果任务恢复后栈完全乱套了。记住,阻塞态的任务,它的栈是“冻结”的,碰都不要碰。

优先级——谁先谁后

优先级是调度器做决策的核心依据。我见过两种主流方案:

  • 静态优先级:任务创建时定死,运行期间不变。简单可靠,适合确定性强的系统。
  • 动态优先级:运行时可以调整。比如优先级继承、优先级反转避免等场景会用到。

你想想看,如果两个任务优先级相同怎么办?这时候就需要时间片轮转了。每个任务跑一个固定时间片,然后切换到下一个。

// 优先级相同的任务,采用时间片轮转调度
void scheduler_tick_handler(void) {
    TCB_t *current = get_current_task();
    
    // 如果当前任务优先级与下一个就绪任务相同
    if (current->priority == next_ready_task->priority) {
        current->slice_ticks--;
        if (current->slice_ticks == 0) {
            // 时间片用完,强制切换
            current->slice_ticks = DEFAULT_SLICE_TICKS;
            schedule();  // 触发调度
        }
    }
}

这里有个细节:优先级反转。低优先级任务占着高优先级任务需要的资源,导致高优先级任务被阻塞。我曾在电机控制项目里遇到过,一个高优先级的急停任务被一个低优先级的通信任务堵住了,差点出事故。解决方案是优先级继承——临时把低优先级任务的优先级提到和高优先级一样,等资源释放后再降回去。

核心原则:优先级设计要遵循“关键任务高优先级,非关键任务低优先级”。中断服务函数里尽量只做标记,实际处理交给高优先级任务。别在中断里做复杂运算,那是新手常犯的错。

调度器如何用TCB工作

调度器的工作流程,说白了就是三步:

  1. 遍历就绪队列:从所有就绪态任务中找出优先级最高的那个。
  2. 上下文切换:保存当前任务的寄存器到它的TCB.sp,然后从新任务的TCB.sp恢复寄存器。
  3. 更新状态:旧任务变就绪态,新任务变运行态。

我习惯用位图法来管理就绪队列。每个优先级对应一个位,有就绪任务就置1。查找最高优先级时,直接用CPU的指令(比如CLZ)找第一个1的位置。时间复杂度O(1),非常快。

// 位图法就绪队列示例
#define MAX_PRIORITY 32

uint32_t ready_bitmap;  // 每一位代表一个优先级是否有就绪任务
TCB_t *ready_tasks[MAX_PRIORITY]; // 每个优先级指向一个任务链表

uint8_t get_highest_ready_priority(void) {
    // 使用CPU指令查找最高优先级(假设优先级数值越小优先级越高)
    return __CLZ(ready_bitmap);  // 返回前导零个数
}

嗯,这里要注意:位图法虽然快,但每个优先级只能有一个任务。如果同优先级有多个任务,你需要用链表把它们串起来。

好了,TCB、任务状态、优先级这三个东西,是调度器的基石。你把这些搞透了,后面讲上下文切换和时钟节拍协同的时候,就会轻松很多。