手把手教你实现一个RTOS调度器:从启动到任务切换
启动:设置中断优先级 → 获取MSP → 触发SVC → 进入第一个任务。切换:通过PendSV异常,在中断服务中保存当前任务状态、选择新任务、恢复新任务状态。关键寄存器:PSP(进程栈指针)、MSP(主栈指针)、BASEPRI(中断屏蔽)、SCB_VTOR(向量表偏移)。理解这些底层细节,不仅有助于我们更好地使用RTOS,也能在遇到问题时快速定位。希望这篇博客能帮助你把调度器的原理看得更通透。如
手把手教你实现一个RTOS调度器:从启动到任务切换
在嵌入式实时操作系统(RTOS)中,调度器是核心中的核心。它负责从就绪列表中找到优先级最高的任务,并切换到该任务执行。今天,我们就从代码层面深入剖析调度器的实现机制,带你一步步理解任务是如何被“调度”起来的。
本文基于一个精简的RTOS内核实现,重点分析调度器的启动流程和任务切换过程,所有代码均来自
task.c和port.c,适合对RTOS底层感兴趣的开发者。
一、调度器长什么样?
调度器本质上是一组全局变量和几个函数,它们协同工作,完成任务的切换。在task.c中,我们看到最核心的全局指针:
// 指向当前正在运行(或即将运行)的任务控制块
TCB_t *pxCurrentTCB;
调度器的工作就是不断更新这个指针,让它指向就绪列表中最高优先级的任务,然后通过上下文切换真正跳转到该任务执行。
二、启动调度器:从 vTaskStartScheduler() 开始
系统启动后,我们需要调用vTaskStartScheduler()来让调度器跑起来。它的实现非常简洁:
void vTaskStartScheduler(void)
{
/* 手动指定第一个运行的任务(暂不支持优先级) */
pxCurrentTCB = &Task1TCB;
/* 启动调度器 */
if (xPortStartScheduler() != pdFALSE)
{
/* 调度器启动成功,则不会返回 */
}
}
在初期实现中,我们手动指定了第一个任务Task1TCB。随后调用xPortStartScheduler(),这个函数与硬件相关,负责配置系统中断并启动第一个任务。
三、硬件相关初始化:xPortStartScheduler()
xPortStartScheduler()通常放在port.c中,主要做两件事:
- 设置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;
}
- 调用
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 ; 异常返回,切换到新任务
}
切换流程总结:
- 保存上文:当前任务的R4~R11被压入它的私有栈,栈顶指针更新到TCB中。
- 选择新任务:调用
vTaskSwitchContext(),更新pxCurrentTCB指向下一个任务。 - 恢复下文:从新任务的栈中弹出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)
更多推荐



所有评论(0)