4、任务调度器移植:任务控制块(TCB)结构体适配、上下文切换代码实现、临界区保护策略、调度器启动与停止。

好,咱们接着往下聊。上一章我们把时钟和中断搞定了,现在该碰调度器最核心的部分了。说实话,调度器移植是整个工作中最容易出鬼的地方。我见过太多人,系统跑不起来,最后发现是TCB结构体里一个字段没对齐,或者上下文切换时寄存器压栈顺序搞反了。

这一节,我带你把这四个硬骨头啃下来:TCB结构体适配、上下文切换、临界区保护、调度器启停。每个点我都会结合项目里踩过的坑来讲。

4.1 任务控制块(TCB)结构体适配

TCB是什么?说白了就是每个任务的“身份证”。操作系统靠它来管理任务的一切。移植时,TCB结构体必须和你的CPU架构、编译器对齐。

我个人习惯,先把原生RTOS的TCB结构体拿出来,然后对照目标CPU的寄存器列表,一个一个核对。举个例子,ARM Cortex-M3/M4有16个通用寄存器(R0-R15),其中R13是栈指针SP,R14是链接寄存器LR,R15是程序计数器PC。上下文切换时,这些寄存器都得保存。

下面是我在某个项目里用过的TCB结构体,适配了Cortex-M4内核:

typedef struct tcb {
    uint32_t    *stack_ptr;      // 当前栈指针,指向任务栈顶
    uint32_t    stack_size;      // 栈大小(字节)
    uint8_t     priority;        // 任务优先级
    uint8_t     state;           // 任务状态:就绪、阻塞、挂起等
    char        name[16];        // 任务名字,调试用
    void        (*entry)(void*); // 任务入口函数
    void        *arg;            // 入口函数参数
    struct tcb  *next;           // 链表指针,用于就绪队列
    uint32_t    delay_ticks;     // 延时节拍数
    // 以下是硬件相关字段
    uint32_t    initial_sp;      // 初始栈指针,用于栈溢出检测
} TCB_t;

这里有个坑stack_ptr必须是结构体的第一个成员。为什么?因为调度器切换任务时,直接通过TCB首地址拿到栈指针,然后恢复寄存器。如果顺序不对,CPU会从错误的地方取数据,系统直接崩掉。我曾经在一个项目里,因为加了一个调试字段在前面,结果调度器一跑就进HardFault,查了两天才发现。

另外,结构体对齐也要注意。ARM架构下,建议用__attribute__((aligned(8)))强制8字节对齐。有些RTOS的TCB里会有浮点寄存器保存区,如果你的CPU有FPU,别忘了加上。

警告:TCB结构体的大小必须是4的倍数(32位CPU)。否则,当多个TCB放在数组里时,地址不对齐会导致总线错误。我见过有人把char类型字段放在最后,结果sizeof(TCB_t)变成了奇数,调度器一跑就挂。

4.2 上下文切换代码实现

上下文切换,就是保存当前任务的状态,恢复下一个任务的状态。说白了,就是“换人”。

这部分必须用汇编写,因为你要直接操作CPU寄存器。C语言做不到。嗯,这里要注意:不同CPU的压栈顺序不一样,一定要看芯片手册。

以Cortex-M3为例,硬件在进入中断时会自动压栈一部分寄存器(R0-R3、R12、LR、PC、xPSR),我们只需要手动压栈剩下的(R4-R11)。所以上下文切换的汇编代码大概长这样:

; 任务切换函数:PendSV_Handler
PendSV_Handler:
    ; 保存当前任务上下文
    MRS     R0, PSP             ; 获取当前任务的栈指针
    STMFD   R0!, {R4-R11}      ; 手动压栈 R4-R11
    ; 更新当前TCB中的栈指针
    LDR     R1, =current_tcb    ; 获取当前TCB指针地址
    LDR     R2, [R1]            ; 读取当前TCB指针
    STR     R0, [R2]            ; 保存栈指针到TCB的第一个字段

    ; 切换到下一个任务
    LDR     R1, =next_tcb       ; 获取下一个TCB指针地址
    LDR     R2, [R1]            ; 读取下一个TCB指针
    LDR     R0, [R2]            ; 从TCB中取出栈指针

    ; 恢复下一个任务上下文
    LDMFD   R0!, {R4-R11}       ; 弹出 R4-R11
    MSR     PSP, R0             ; 更新栈指针
    ; 异常返回,硬件自动弹出 R0-R3, R12, LR, PC, xPSR
    BX      LR

你想想看,这段代码里最容易被忽略的是什么?是BX LR。在Cortex-M上,LR在异常处理中存放的是EXC_RETURN值,不是普通的返回地址。如果你直接写BX LR,CPU会把它当成异常返回来处理,自动触发硬件出栈。这个机制很巧妙,但新手很容易搞混。

提示:我建议你在调试阶段,在PendSV_Handler里加一个GPIO翻转。每次上下文切换时,用示波器看GPIO波形,就能直观看到切换频率和是否卡死。这招我用了很多年,比看调试器变量靠谱多了。

4.3 临界区保护策略

临界区,就是那些不能被中断打断的代码段。比如操作就绪队列、修改TCB状态。如果不保护,中断一来,数据可能被破坏。

最常用的策略是“关中断-执行-开中断”。但这里有个度:关中断时间不能太长,否则会影响实时性。我见过有人把整个调度器函数都包在临界区里,结果中断延迟飙到几十微秒,电机控制直接抖成筛子。

下面是我常用的临界区保护宏:

#define ENTER_CRITICAL()    do {                            \
    uint32_t __primask;                                     \
    __primask = __get_PRIMASK();                            \
    __disable_irq();                                        \
    // 这里可以保存中断状态,用于嵌套保护

#define EXIT_CRITICAL()     do {                            \
    if (!__primask) {                                       \
        __enable_irq();                                     \
    }                                                       \
} while(0)

注意看,我用了__get_PRIMASK()保存当前中断状态。为什么?因为临界区可能嵌套。如果外层已经关了中断,内层退出时就不能开中断,否则会破坏外层的保护。这个细节,很多移植教程都没提。

还有一种策略是“调度器锁”,只禁止任务切换,不禁止中断。适合那些需要响应中断,但不能被切换走的场景。比如在中断里给任务发信号量,你不想让调度器在中间插一杠子。

核心原则:临界区保护要“快进快出”。我一般要求临界区代码不超过20条指令。如果超过,就要考虑用其他同步机制,比如互斥量或消息队列。

4.4 调度器启动与停止

调度器启动,说白了就是“把控制权交给操作系统”。停止,就是“把控制权拿回来”。

启动调度器时,要做三件事:

  1. 初始化系统节拍定时器——让SysTick开始跑,产生周期性中断。
  2. 设置PendSV优先级为最低——确保所有其他中断都能抢占PendSV,这是Cortex-M的推荐做法。
  3. 触发一次PendSV异常——手动启动第一次上下文切换,让最高优先级的任务跑起来。

代码实现大概是这样:

void scheduler_start(void) {
    // 1. 初始化系统节拍定时器
    SysTick_Config(SystemCoreClock / TICK_RATE_HZ);

    // 2. 设置PendSV优先级为最低(数值最大)
    NVIC_SetPriority(PendSV_IRQn, 0xFF);

    // 3. 手动触发PendSV,启动第一次切换
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;

    // 4. 这里永远不会返回
    while(1);
}

你可能会问:为什么PendSV要设成最低优先级?因为这样,任何中断都能打断PendSV,保证中断响应速度。PendSV只在没有其他中断要处理时才执行,这就是“尾链”机制。说白了,就是让中断优先,任务切换靠后。

调度器停止呢?这个场景比较少用,但调试时很有用。比如你想单步调试某个任务,就得先把调度器停了。停止的方法很简单:关掉SysTick中断,然后关全局中断。但要注意,停止后所有任务都冻结了,你只能通过调试器操作。

注意:千万不要在中断服务函数里调用调度器停止函数。否则中断一关,CPU可能永远回不来了。我有个同事曾经在UART中断里调了scheduler_stop(),结果系统直接死机,最后只能按复位键。

好了,调度器移植的四个核心点就这些。TCB结构体是地基,上下文切换是骨架,临界区保护是护城河,调度器启停是总开关。这四个点搞定了,你的RTOS就能在目标芯片上跑起来了。下一章,我们聊聊任务创建和删除的移植,那又是另一番天地。