FreeRTOS源码解读系列文章之四
内核调度机制深度解析
RTOS(实时操作系统)是一个多任务系统,其设计哲学是将复杂的业务逻辑拆解为多个独立、解耦的任务模块。通过 RTOS 提供的同步与通信机制实现数据交换,不仅极大方便了后期维护,更核心的优势在于其实时性:当高优先级任务就绪时,它能立即打断低优先级任务夺取 CPU 执行权。
理解实时性: 在裸机环境中,main 函数中的代码必须顺序执行。即使发生了紧急事件,除非通过中断,否则程序必须按既定顺序跑完前面的流程。而 RTOS 允许系统在任务之间“横向跳转”,实现真正的按需分配。
1. 硬件基石:MSP 与 PSP 指针
在 ARM Cortex-M 架构中,存在两个独立的栈指针寄存器:MSP (Main Stack Pointer) 和 PSP (Process Stack Pointer)。
-
设计初衷: 将“操作系统内核/中断服务”使用的栈与“普通任务”使用的栈进行物理隔离。
-
分配规则: * MSP: 用于裸机环境、异常处理(ISRs)和内核逻辑。
-
PSP: 专门用于用户级任务线程。
-
优势: 使用双栈指针可以防止某个任务的栈溢出直接破坏内核栈(MSP),提高了系统的健壮性和安全性。
2. 核心“三剑客”:SVC, PendSV 与 SysTick
RTOS 的运转依赖于三个关键的中断。FreeRTOS 为了跨平台兼容,在底层对其进行了重定义:
/* FreeRTOS 对 Cortex-M 中断处理函数的统一映射 */
#define vPortSVCHandler SVC_Handler // 系统调用中断:主要用于启动第一个任务
#define xPortPendSVHandler PendSV_Handler // 悬起系统调用中断:核心上下文切换地
#define xPortSysTickHandler SysTick_Handler // 系统滴答中断:提供时间片动力
3. 启动调度器:从裸机到多任务
启动调度器是通过 vTaskStartScheduler() 完成的。它会创建空闲任务、初始化定时器任务。这里启动过程其实可以单独拿出来说的,不过和调度详细不大,暂时不管。
这里空闲任务和定时计数器任务是两个内核任务,我们知道FreeRTOS是可以根据每个任务的需求分配CPU时间的,如果当前所有的任务都在阻塞或者在delay的状态中,那么CPU就不需要工作,这个时候就会进入空闲任务中,但是空闲任务中不是什么都不做,也会做一些收尾工作,比如我们在上一章节提到的回收动态分配的任务内存,这部分就不多说,因为它和调度关系不是很大,留给读者自己深究一下它到底做了什么。
定时计数器任务,因为FreeRTOS其实会维护他自己的内核时间,所以我们可以用这个机制来实现一些周期定时的任务,和定时计数器一样,但是精度肯定是没有定时计数器高的,这个后面我们深入介绍一下,因为软件定时器机制就是通过这个实现的,基本原理就是,每个定时计数器函数都是一个回调函数,这个任务根据当前时间是否到了,执行这个回调函数,实现软件定时器的效果。
3.1 准备环境:prvStartFirstTask
在正式调度前,系统还是裸机状态。我们需要清空 MSP 并触发一次 SVC 中断。
__asm void prvStartFirstTask( void )
{
PRESERVE8
/* 1. 重置 MSP:将其指向向量表定义的初始位置 */
ldr r0, =0xE000ED08
ldr r0, [ r0 ]
ldr r0, [ r0 ]
msr msp, r0
/* 2. 环境清理:确保处于特权级并开启中断 */
mov r0, #0
msr control, r0
cpsie i
cpsie f
dsb
isb
/* 3. 触发 SVC 中断:正式切入 RTOS 模式 */
svc 0
nop
nop
}
3.2 启动第一个任务:vPortSVCHandler
SVC 处理函数只执行一次。它的任务是:“伪装”一次异常返回,从而把预先在栈里准备好的任务环境“弹”进 CPU 寄存器。上一章中不是说了每个任务创建的时候会伪造一个现场,其实这里就已经开始起作用了。
__asm void vPortSVCHandler( void )
{
PRESERVE8
/* 获取 pxCurrentTCB,即当前要运行的任务 */
ldr r3, =pxCurrentTCB
ldr r1, [ r3 ]
ldr r0, [ r1 ] /* r0 现在是任务的栈顶指针 (pxTopOfStack) */
/* 1. 手动出栈 R4-R11 和 R14 (EXC_RETURN) */
/* 这些是创建任务时手工压入的“假现场” */
ldmia r0!, {r4-r11,r14}
/* 2. 更新 PSP:剩下的寄存器 (R0-R3, PC等) 将由硬件自动处理 */
msr psp, r0
isb
mov r0, #0
msr basepri, r0
/* 3. 利用 bx r14 触发硬件的“异常返回机制” */
/* 处理器会自动从 PSP 弹出剩余的 PC, R0 等,跳转到任务入口 */
bx r14
}
4. 上下文切换:PendSV 机制
xPortPendSVHandler 是 RTOS 中最繁忙的函数,它负责在两个任务之间进行“现场保存”和“现场恢复”。
4.1 任务切换汇编分析
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
/* --- 第一步:保存当前旧任务的现场 --- */
mrs r0, psp /* 获取当前任务的 PSP */
isb
ldr r3, =pxCurrentTCB
ldr r2, [ r3 ] /* r2 = 当前 TCB 指针 */
/* 如果使用了浮点单元,保存 FPU 寄存器 */
tst r14, #0x10
it eq
vstmdbeq r0!, {s16-s31}
/* 手动将 R4-R11 和 R14 压入任务栈 */
stmdb r0!, {r4-r11, r14}
/* 将最新的栈顶地址保存回 TCB,以便下次找回 */
str r0, [ r2 ]
/* --- 第二步:寻找下一个就绪任务 --- */
stmdb sp!, {r0, r3} /* 保护寄存器 */
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0 /* 关中断,进入调度临界区 */
dsb
isb
bl vTaskSwitchContext /* 【关键 C 函数】:更新 pxCurrentTCB 指向新任务 */
mov r0, #0
msr basepri, r0 /* 开中断 */
ldmia sp!, {r0, r3}
/* --- 第三步:恢复新任务的现场 --- */
ldr r1, [ r3 ] /* 获取新任务的 TCB */
ldr r0, [ r1 ] /* 获取新任务的栈顶指针 pxTopOfStack */
/* 从栈中弹出 R4-R11 和 R14 */
ldmia r0!, {r4-r11, r14}
/* 恢复 FPU 寄存器 */
tst r14, #0x10
it eq
vldmiaeq r0!, {s16-s31}
/* 将新任务的栈地址写到 PSP,硬件将从这里自动弹栈恢复 R0-R3, PC 等 */
msr psp, r0
isb
bx r14 /* 返回新任务 */
}
5. 核心调度算法逻辑
当 vTaskSwitchContext 被调用时,内核需要决定“谁是下一个”。
void vTaskSwitchContext( void )
{
if( uxSchedulerSuspended != ( UBaseType_t ) 0U )
{
// 调度器都还没有启动
xYieldPendings[ 0 ] = pdTRUE;
}
else
{
xYieldPendings[ 0 ] = pdFALSE;
#if ( configUSE_POSIX_ERRNO == 1 )
{
pxCurrentTCB->iTaskErrno = FreeRTOS_errno;
}
#endif
// 找优先级最高的第一个就绪任务
taskSELECT_HIGHEST_PRIORITY_TASK();
traceTASK_SWITCHED_IN();
// 钩子函数机制
portTASK_SWITCH_HOOK( pxCurrentTCB );
#if ( configUSE_POSIX_ERRNO == 1 )
{
FreeRTOS_errno = pxCurrentTCB->iTaskErrno;
}
#endif
}
}
5.1 优先级选择:taskSELECT_HIGHEST_PRIORITY_TASK
内核通过一个循环查找,找到当前优先级最高且非空的就绪任务链表。
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{ \
UBaseType_t uxTopPriority = uxTopReadyPriority; \
\
/* 从最高优先级向下查找,直到找到第一个有任务的就绪链表 */ \
while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) \
{ \
--uxTopPriority; \
} \
\
/* 从该链表中获取一个 TCB (如果是多个同级任务,则通过时间片轮转选择) */ \
listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) ); \
uxTopReadyPriority = uxTopPriority; \
}
uxTopReadyPriority这是一个全局变量,记录了当前最高优先级的就绪任务链表,他是一个会随着任务的恢复和创建变化的变量,所以这个宏定义中的查找其实是很快的。
5.2 时间片轮转:listGET_OWNER_OF_NEXT_ENTRY
对于同等优先级的任务,RTOS 通过移动链表的 pxIndex 指针,确保每个任务执行一个 Tick 时间,实现“雨露均沾”。
#define listGET_OWNER_OF_NEXT_ENTRY( pxTCB, pxList ) \
{ \
List_t * const pxConstList = ( pxList ); \
/* 移动链表索引到下一个节点 */ \
( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext;1. 核心结论:位置是由“插入算法”决定的
在 FreeRTOS 中,任务在就绪列表中并不是每次切换都要“出链表再入链表”。
如果是高优先级抢占: 低优先级任务原地不动。它依然留在它那个优先级的链表里,位置没变。只是 pxCurrentTCB 指向了高优先级任务。
如果是同优先级时间片轮转: 任务也没有重新插入,而是通过移动链表的 pxIndex 指针,指向当前链表中的下一个任务。
真正的“加入到优先级链表”的函数是 prvAddTaskToReadyList()(这是一个宏,最终调用 vListInsertEnd())。
\
/* 如果移到了末尾的尾节点,则跳过并指向头部的第一个任务 */ \
if( ( void * ) ( pxConstList )->pxIndex == ( void * ) &( ( pxConstList )->xListEnd ) ) \
{ \
( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext; \
} \
/* 获取该节点对应的任务控制块 (TCB) */ \
( pxTCB ) = ( pxConstList )->pxIndex->pvOwner; \
}
我们这里再做深入说明,就是如果一个任务是自己释放了CPU使用权或者因为什么原因阻塞,他会加入到延时/阻塞链表中,但是如果是高优先级任务打断,这个任务还在就绪链表中,不过因为( pxConstList )->pxIndex = ( pxConstList )->pxIndex->pxNext;这句话,任务一旦被选中就会变成同等优先级下的最后一个调度的任务。如果是时间片也是一样的,如果一个任务执行完了,因为我的pxIndex向后移动了一个,我需要从后往前才可以找到它,也就是最后一个了。以上时间片调度和优先级调度的机制也都非常清晰了。
总结
FreeRTOS 的调度本质是“利用异常机制接管硬件”:
- SysTick 周期性地检查是否有更高优先级任务就绪或时间片是否耗尽。
- PendSV 负责苦力活,即保存旧现场、加载新现场。
- 优先级链表 则是调度器的“账本”,记录了所有任务的状态。
通过对这些汇编与底层 C 代码的分析,我们可以清晰地看到:RTOS 并不是什么魔法,而是通过精妙的硬件栈管理,实现了一场持续不断的“偷梁换柱”。
声明: 本篇文章内容由作者原创撰写,并使用了 AI 进行辅助润色与排版优化,文章核心技术观点及逻辑内容已由作者本人严格审核。
更多推荐
所有评论(0)