手把手教你实现一个RTOS调度器:从启动到任务切换

在嵌入式实时操作系统(RTOS)中,调度器是核心中的核心。它负责从就绪列表中找到优先级最高的任务,并切换到该任务执行。今天,我们就从代码层面深入剖析调度器的实现机制,带你一步步理解任务是如何被“调度”起来的。

本文基于一个精简的RTOS内核实现,重点分析调度器的启动流程和任务切换过程,所有代码均来自task.cport.c,适合对RTOS底层感兴趣的开发者。


一、调度器长什么样?

调度器本质上是一组全局变量和几个函数,它们协同工作,完成任务的切换。在task.c中,我们看到最核心的全局指针:

// 指向当前正在运行(或即将运行)的任务控制块
TCB_t *pxCurrentTCB;

调度器的工作就是不断更新这个指针,让它指向就绪列表中最高优先级的任务,然后通过上下文切换真正跳转到该任务执行。


二、启动调度器:从 vTaskStartScheduler() 开始

系统启动后,我们需要调用vTaskStartScheduler()来让调度器跑起来。它的实现非常简洁:

void vTaskStartScheduler(void)
{
    /* 手动指定第一个运行的任务(暂不支持优先级) */
    pxCurrentTCB = &Task1TCB;

    /* 启动调度器 */
    if (xPortStartScheduler() != pdFALSE)
    {
        /* 调度器启动成功,则不会返回 */
    }
}

在初期实现中,我们手动指定了第一个任务Task1TCB。随后调用xPortStartScheduler(),这个函数与硬件相关,负责配置系统中断并启动第一个任务。


三、硬件相关初始化:xPortStartScheduler()

xPortStartScheduler()通常放在port.c中,主要做两件事:

  1. 设置PendSV和SysTick的中断优先级为最低
    这样,系统中的其他硬件中断可以抢占内核调度,保证实时性。
#define portNVIC_SYSPRI2_REG   (*((volatile uint32_t *) 0xe000ed20))
#define portNVIC_PENDSV_PRI     (((uint32_t) configKERNEL_INTERRUPT_PRIORITY) << 16UL)
#define portNVIC_SYSTICK_PRI    (((uint32_t) configKERNEL_INTERRUPT_PRIORITY) << 24UL)

BaseType_t xPortStartScheduler(void)
{
    /* 配置 PendSV 和 SysTick 为最低优先级 */
    portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
    portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;

    /* 启动第一个任务,不再返回 */
    prvStartFirstTask();

    return 0;
}
  1. 调用prvStartFirstTask()启动第一个任务
    该函数用汇编编写,负责设置主栈指针(MSP)并触发SVC异常,从而进入第一个任务的执行。

四、汇编级的第一个任务启动:prvStartFirstTask()

prvStartFirstTask()的代码是关键中的关键,我们来逐行解析:

__asm void prvStartFirstTask(void)
{
    PRESERVE8                     ; 8字节对齐

    /* 获取向量表的起始地址(即MSP的初始值) */
    ldr r0, =0xE000ED08           ; SCB_VTOR 寄存器地址
    ldr r0, [r0]                  ; 读取向量表起始地址(通常为0x00000000)
    ldr r0, [r0]                  ; 读取MSP的初始值(栈顶)

    /* 设置主栈指针 MSP */
    msr msp, r0

    /* 全局使能中断 */
    cpsie i
    cpsie f
    dsb
    isb

    /* 触发 SVC 异常,进入第一个任务 */
    svc 0
    nop
    nop
}

要点解析:

  • 向量表通常放在Flash起始地址(0x00000000),第一个字就是MSP的初始值。我们通过读取SCB_VTOR得到向量表基址,再取出MSP。
  • 使能中断后,通过svc 0指令产生SVC调用,跳转到SVC_Handler(在FreeRTOS中我们通过宏将其重命名为vPortSVCHandler)。

五、SVC中断服务:vPortSVCHandler()

vPortSVCHandler()的任务是启动第一个任务的执行,不再返回。它通过操作pxCurrentTCB,从任务控制块中取出栈顶指针,并切换到用户任务。

__asm void vPortSVCHandler(void)
{
    extern pxCurrentTCB;

    PRESERVE8

    ldr r3, =pxCurrentTCB
    ldr r1, [r3]                  ; r1 = pxCurrentTCB
    ldr r0, [r1]                  ; r0 = 任务栈顶指针(pxTopOfStack)

    /* 将栈中保存的 r4~r11 弹出到 CPU 寄存器 */
    ldmia r0!, {r4-r11}

    /* 更新 PSP 为新的栈顶,任务将使用 PSP */
    msr psp, r0
    isb

    /* 允许所有中断 */
    mov r0, #0
    msr basepri, r0

    /* 修改 EXC_RETURN 的值,使得异常返回后使用 PSP 并进入任务模式 */
    orr r14, #0xd

    bx r14
}

关键点:

  • 任务的栈在创建时已经初始化好,模拟了异常入栈后的布局(xPSR、PC、LR、R12、R3、R2、R1、R0 以及 R4~R11)。
  • 我们手动加载 R4~R11,然后更新 PSP,最后通过修改 EXC_RETURN(即LR)告诉CPU:退出异常时使用PSP,并且进入线程模式。
  • bx r14 执行后,CPU自动将剩下的寄存器(R0~R3、R12、LR、PC、xPSR)从任务栈中弹出,任务开始执行。

六、任务切换:taskYIELD() 与 PendSV

调度器的另一个核心功能是任务切换。通常我们通过taskYIELD()宏来触发一次调度:

#define taskYIELD()   portYIELD()

#define portYIELD()                                 \
{                                                   \
    /* 触发 PendSV 异常 */                           \
    portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
    __dsb(portSY_FULL_READ_WRITE);                  \
    __isb(portSY_FULL_READ_WRITE);                  \
}

这里直接向中断控制寄存器(0xE000ED04)的PendSV悬起位写1,PendSV中断就会被挂起。当没有更高优先级中断时,PendSV中断服务函数xPortPendSVHandler()就会执行,并在其中完成任务的切换。


七、PendSV中断服务:真正的上下文切换

xPortPendSVHandler()是调度器的心脏,它保存当前任务的上下文,选择下一个任务,然后恢复新任务的上下文。

__asm void xPortPendSVHandler(void)
{
    extern pxCurrentTCB;
    extern vTaskSwitchContext;

    PRESERVE8

    /* 获取当前任务的 PSP */
    mrs r0, psp
    isb

    /* 获取 pxCurrentTCB 的地址 */
    ldr r3, =pxCurrentTCB
    ldr r2, [r3]                  ; r2 = pxCurrentTCB

    /* 保存当前任务的 R4~R11 到它的栈中 */
    stmdb r0!, {r4-r11}
    str r0, [r2]                  ; 更新任务的栈顶指针

    /* 保存 r3 和 r14 到主栈(因为接下来要调用函数) */
    stmdb sp!, {r3, r14}

    /* 关中断(保护临界区) */
    mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
    msr basepri, r0
    dsb
    isb

    /* 调用选择下一个任务的函数 */
    bl vTaskSwitchContext

    /* 开中断 */
    mov r0, #0
    msr basepri, r0

    /* 恢复 r3 和 r14 */
    ldmia sp!, {r3, r14}

    /* 获取新的 pxCurrentTCB 和它的栈顶指针 */
    ldr r1, [r3]                  ; r1 = 新的 pxCurrentTCB
    ldr r0, [r1]                  ; r0 = 新的任务栈顶

    /* 恢复新任务的 R4~R11 */
    ldmia r0!, {r4-r11}
    msr psp, r0                   ; 更新 PSP
    isb

    bx r14                        ; 异常返回,切换到新任务
}

切换流程总结:

  1. 保存上文:当前任务的R4~R11被压入它的私有栈,栈顶指针更新到TCB中。
  2. 选择新任务:调用vTaskSwitchContext(),更新pxCurrentTCB指向下一个任务。
  3. 恢复下文:从新任务的栈中弹出R4~R11,更新PSP,最后通过bx r14返回,CPU自动弹出剩余寄存器,任务切换完成。

八、选择下一个任务:vTaskSwitchContext()

在简单实现中,我们只支持两个任务轮流切换:

void vTaskSwitchContext(void)
{
    if (pxCurrentTCB == &Task1TCB)
    {
        pxCurrentTCB = &Task2TCB;
    }
    else
    {
        pxCurrentTCB = &Task1TCB;
    }
}

在实际的RTOS中,这里会遍历就绪列表,选择优先级最高的任务。这个函数执行时,中断是关闭的(通过BASEPRI),以保证pxCurrentTCB的更新是原子的。


九、总结

通过以上代码分析,我们看到了一个RTOS调度器是如何从零搭建起来的:

  • 启动:设置中断优先级 → 获取MSP → 触发SVC → 进入第一个任务。
  • 切换:通过PendSV异常,在中断服务中保存当前任务状态、选择新任务、恢复新任务状态。
  • 关键寄存器:PSP(进程栈指针)、MSP(主栈指针)、BASEPRI(中断屏蔽)、SCB_VTOR(向量表偏移)。

理解这些底层细节,不仅有助于我们更好地使用RTOS,也能在遇到问题时快速定位。希望这篇博客能帮助你把调度器的原理看得更通透。

如果你对RTOS的其他组件(如信号量、消息队列)也感兴趣,欢迎留言交流!


参考资料:

  • 《STM32F10xxx Cortex-M3 programming manual》(PM0056)
  • FreeRTOS 源码(v10.x)
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐