调度器核心原理:任务控制块、任务状态机与上下文切换

调度器,说白了就是单片机里的「交通警察」。它决定哪个任务该跑、哪个任务该等、哪个任务该睡。我刚开始做嵌入式系统时,总觉得调度器是个黑盒子,直到有一次产品在现场死机,排查了三天才发现是任务状态机乱了。嗯,从那以后,我把调度器的每个细节都啃透了。

这一章,我们聚焦调度器的三个核心构件:任务控制块(TCB)、任务状态机和上下文切换机制。这三样东西搞明白了,调度器在你眼里就不再是玄学。

1. 任务控制块(TCB)——任务的「身份证」

每个任务在调度器里都有一个对应的数据结构,这就是TCB。它记录了任务的所有关键信息。你可以把TCB想象成任务的档案袋,调度器每次切换任务,都要先翻这个档案袋。

一个典型的TCB长这样:

typedef struct {
    uint32_t    *sp;             // 栈指针(核心!)
    uint32_t    stack_size;      // 栈大小
    uint8_t     priority;        // 优先级
    uint8_t     state;           // 当前状态
    void        (*entry)(void*); // 任务入口函数
    void        *arg;            // 入口参数
    uint32_t    delay_ticks;     // 延时计数(阻塞用)
    uint32_t    *stack_base;     // 栈底地址
    char        name[16];        // 任务名(调试用)
} TCB_t;

关键字段解读:

  • sp(栈指针):这是TCB里最重要的字段。它指向任务当前的栈顶位置。上下文切换时,CPU寄存器的值就保存在这里。
  • state:任务当前处于什么状态,后面会细讲。
  • delay_ticks:任务需要阻塞多少个系统滴答。我习惯用这个字段实现延时阻塞,而不是用死循环等待。
  • name:调试时特别有用。我曾经在排查一个优先级反转问题时,就是靠打印任务名才理清调用链的。

我的习惯:TCB数组通常静态分配,大小在编译时就确定。比如#define MAX_TASKS 16,然后声明TCB_t task_pool[MAX_TASKS]。动态分配在单片机上风险太高,我踩过内存碎片化的坑。

2. 任务状态机——就绪/运行/阻塞/挂起

任务不是一直在跑的。它有四种基本状态,状态之间的转换构成了调度器的核心逻辑。

状态 含义 典型触发条件
就绪(Ready) 任务具备运行条件,等待CPU 创建完成、阻塞结束、被唤醒
运行(Running) 任务正在占用CPU 调度器选中该任务
阻塞(Blocked) 任务等待某个事件或延时 调用延时函数、等待信号量
挂起(Suspended) 任务被强制暂停,不参与调度 调试暂停、低功耗模式

状态转换的规则其实不复杂:

  • 就绪 → 运行:调度器从就绪队列里挑一个优先级最高的任务。
  • 运行 → 就绪:时间片用完,或者被更高优先级任务抢占。
  • 运行 → 阻塞:任务主动调用延时或等待资源。
  • 阻塞 → 就绪:延时到期,或者等待的资源可用。
  • 运行/就绪/阻塞 → 挂起:外部强制暂停。
  • 挂起 → 就绪:恢复执行。

我曾经踩过的坑:任务在阻塞状态下被误唤醒。有一次我写了一个按键扫描任务,它等待一个信号量。结果信号量被多个任务同时释放,导致这个任务在未预期的时机被唤醒,状态机乱了。后来我加了一个「状态校验」——每次唤醒后先检查当前状态是否真的是阻塞,不是的话直接丢弃唤醒信号。

你想想看,如果状态机乱了,调度器会做出什么荒唐事?它可能把一个正在运行的任务再次调度进来,导致栈溢出;也可能把一个阻塞中的任务当成就绪任务,直接切换过去。嗯,这就是为什么状态机必须严谨。

3. 上下文切换机制——任务的「换人」操作

上下文切换,就是保存当前任务的运行现场,恢复下一个任务的运行现场。这个过程必须由汇编代码完成,因为C语言无法直接操作CPU寄存器。

一个典型的上下文切换流程:

  1. 保存现场:把当前CPU的通用寄存器、程序计数器(PC)、状态寄存器(PSR)压入当前任务的栈中。
  2. 更新TCB:把当前的栈指针(SP)写入当前任务的TCB.sp字段。
  3. 选择新任务:调度器从就绪队列中选出下一个要运行的任务。
  4. 恢复现场:从新任务的TCB.sp取出栈指针,从栈中弹出寄存器值,然后跳转到新任务上次被中断的位置继续执行。

下面是一个Cortex-M3内核的PendSV中断服务函数示例,它专门用来做上下文切换:

__asm void PendSV_Handler(void)
{
    // 保存当前任务的寄存器
    MRS     R0, PSP           // 获取当前任务的栈指针
    STMDB   R0!, {R4-R11}     // 压入R4~R11(共8个寄存器)
    
    // 更新当前任务的TCB.sp
    LDR     R1, =current_tcb
    LDR     R1, [R1]
    STR     R0, [R1]          // 保存栈指针到TCB
    
    // 调用调度函数,选择下一个任务
    BL      scheduler_select
    
    // 恢复新任务的寄存器
    LDR     R1, =current_tcb
    LDR     R1, [R1]
    LDR     R0, [R1]          // 取出新任务的栈指针
    LDMIA   R0!, {R4-R11}     // 弹出R4~R11
    MSR     PSP, R0           // 更新栈指针
    
    // 返回时自动弹出R0~R3、R12、LR、PC、PSR
    BX      LR
}

注意:Cortex-M3在进入中断时,硬件会自动压入R0~R3、R12、LR、PC、PSR。所以软件只需要压入R4~R11。这个细节搞错了,任务切换后寄存器值全乱套。我调试第一个调度器时,就是在这里卡了两天。

上下文切换的时机通常有两个:

  • 系统滴答定时器中断:每个时间片到期时触发,用于时间片轮转调度。
  • 任务主动让出CPU:比如调用delay()或等待信号量时,任务主动触发PendSV。

我个人习惯把PendSV的优先级设为最低。为什么?因为这样可以等所有其他中断处理完再切换,避免在中断服务里做上下文切换导致优先级反转。

4. 核心逻辑关系图

下面这张图把TCB、状态机和上下文切换的关系串起来了。你仔细看,调度器的所有操作都围绕这三个要素展开。

调度器核心逻辑关系图 任务控制块 (TCB) sp(栈指针) state(状态) priority(优先级) delay_ticks(延时) entry(入口) 任务状态机 就绪 (Ready) 运行 (Running) 阻塞 (Blocked) 挂起 (Suspended) 上下文切换 保存现场 更新TCB.sp 选择新任务 恢复现场 调度器核心 核心逻辑 TCB 记录任务信息 → 状态机管理任务生命周期 → 上下文切换实现任务交替 三者缺一不可,任何一个环节出错,调度器都会崩溃

调试小技巧:如果你怀疑调度器出了问题,先检查TCB.sp的值是否合理。我常用的方法是:在调试器里打印所有任务的TCB.sp,然后跟每个任务的栈底地址对比。如果某个任务的sp跑出了栈范围,那肯定是栈溢出或者上下文切换时保存了错误的指针。