1. 引言
FreeRTOS的任务调度是个大头,也是一个操作系统的核心。
其实个人理解,FreeRTOS调度规则很好理解,原则就是“优先级高抢占”,因为FreeRTOS是一个抢占式实时内核,一定会保证就绪态的高优先级任务可以先运行。
所有的调度都是为了实现这个目的来做的。
一些个人思考可以看4.1节。
2. 原理分析
2.1 任务切换是怎么进行的
任务切换是一个明确的操作,就是通过pendSV中断,把当前任务的现场全部保存在自己任务的栈帧里,然后,把SP和PC等切换到新的任务上去。
上下文切换是在pendSV中做的。
如何进入pendSV中断?
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
或者是调用 portYIELD
可见,系统是通过直接配置portNVIC_INT_CTRL_REG 寄存器或者调用portYIELD接口,进而进而PendSV中断来进行上下文切换的。
那PendSV是如何进行切换的?可以看2.2.2节的源码分析。
2.2 什么情况会任务切换
个人理解,
只有当一个更高优先级的任务进入到就绪列表时,需要进行抢占时,才会进行任务切换。
或者是同优先级的任务交替时间片运行。
FreeRTOS的任务的状态迁移如下图:
那在不考虑同优先级的情况,什么时候会产生高优先级任务进入ready态呢?
- 创建任务
- 任务从其他状态切换过来
- 其他?我暂时没想到
这些情况下,FreeRTOS就会调用portTIELD或者直接给portNVIC_INT_CTRL_REG 置位来做任务切换了。
2. 源码分析
2.1 任务调度器开启
freeRTOS在创建任务后,需要手动开启任务调度器,即 vTaskStartScheduler 函数。
代码如下:
void vTaskStartScheduler( void )
{
BaseType_t xReturn;
#if( configSUPPORT_STATIC_ALLOCATION == 1 )
{
StaticTask_t *pxIdleTaskTCBBuffer = NULL;
StackType_t *pxIdleTaskStackBuffer = NULL;
uint32_t ulIdleTaskStackSize;
vApplicationGetIdleTaskMemory( &pxIdleTaskTCBBuffer, &pxIdleTaskStackBuffer, &ulIdleTaskStackSize );
xIdleTaskHandle = xTaskCreateStatic( prvIdleTask,
configIDLE_TASK_NAME,
ulIdleTaskStackSize,
( void * ) NULL,
portPRIVILEGE_BIT,
pxIdleTaskStackBuffer,
pxIdleTaskTCBBuffer );
if( xIdleTaskHandle != NULL )
{
xReturn = pdPASS;
}
else
{
xReturn = pdFAIL;
}
}
#else
{
xReturn = xTaskCreate( prvIdleTask,
configIDLE_TASK_NAME,
configMINIMAL_STACK_SIZE,
( void * ) NULL,
portPRIVILEGE_BIT,
&xIdleTaskHandle );
}
#endif
#if ( configUSE_TIMERS == 1 )
{
if( xReturn == pdPASS )
{
xReturn = xTimerCreateTimerTask();
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
#endif
if( xReturn == pdPASS )
{
#ifdef FREERTOS_TASKS_C_ADDITIONS_INIT
{
freertos_tasks_c_additions_init();
}
#endif
portDISABLE_INTERRUPTS();
#if ( configUSE_NEWLIB_REENTRANT == 1 )
{
_impure_ptr = &( pxCurrentTCB->xNewLib_reent );
}
#endif
xNextTaskUnblockTime = portMAX_DELAY;
xSchedulerRunning = pdTRUE;
xTickCount = ( TickType_t ) configINITIAL_TICK_COUNT;
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS();
traceTASK_SWITCHED_IN();
if( xPortStartScheduler() != pdFALSE )
{
}
else
{
}
}
else
{
configASSERT( xReturn != errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY );
}
( void ) xIdleTaskHandle;
}
这段代码也很重要,我们来分析一下流程
- 首先进行一些全局变量的初始化,
- 之后对中断进行一些配置,
- 最后进入汇编,配置一些寄存器。
2.2 三个中断
我们在代码会遇到SVC、pendSV、滴答这三个中断,他们的实现都是在port.c中。
2.2.1 SVC
vPortSVCHandler,只在第一次启动任务调度的时候用。
__asm void vPortSVCHandler( void )
{
PRESERVE8
ldr r3, =pxCurrentTCB
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11}
msr psp, r0
isb
mov r0, #0
msr basepri, r0
orr r14, #0xd
bx r14
}
这里要注意一下LDR的使用。LDR有指令和伪指令两种用法,一定要区分开。
区别一
ldr r3, = 变量
ldr r3, = 标号
ldr r3, = 立即数
区别二
ldr r3, = 立即数
ldr r3, 立即数 , 就是把立即数这个地址中的值存放到r0中
步骤:
- pxCurrentTCB 是一个指针变量,指向当前运行任务的TCB。
首先将pxCurrentTCB 的 地址赋给r3,即 r3 = & pxCurrentTCB ; - 然后把pxCurrentTCB 的值赋值给r1,即r1 = pxCurrentTCB 。
- 最后pxCurrentTCB所指的TCB的第一个成员变量(任务堆栈地址)赋给r0,即r1 = [r3] = *pxCurrentTCB= pxCurrentTCB->pxTopOfStack
- 把人为入栈的寄存器r4 - r11手动出栈,剩下的 xPSR、PC、LR、R12、R3 - R0会自动出栈。
- 把出栈完成之后的栈顶地址赋给psp,供任务使用。
这里要注意的就是第一句,是把pxCurrentTCB 的地址赋值给r3,而不是把他的值赋值r3。
要分清 LDR的汇编指令和伪指令的用法。
引用在论坛里看到的一段话:
C代码中的pxCurrentTCB是变量。
而汇编中的pxCurrentTCB是一个label,可认为就是一个地址,它对应C中的pxCurrentTCB
ldr r3,=pxCurrentTCB # 读取<pxCurrentTCB变量的地址>到 r3
ldr r1,[r3] # 读取<pxCurrentTCB变量>到r1,即获取当前任务TCB地址
ldr r0,[r1] # 从<当前任务TCB>读取一个WORD大小到 r0,TCB首元素即任务栈顶
而
ldr rn, =label 是ldr伪指令,可以理解将label作为立即数加载到rn
ldr rn, label 不是伪指令,它通过间接寻址,把label作为地址,从这个地址中加载值到rn
你可能会想,按这个说法
既然汇编中的pxCurrentTCB是C变量地址,为啥不直接
ldr r1,pxCurrentTCB
正好是间接寻址,拿到TCB指针。
这是因为ldr rn, label 这个指令是基于PC寻址,是有范围要求的,
对于C中的变量,由于是在链接时才能确定变量地址的,所以有可能会超了范围。
所以使用ldr rn,[rm] 。这个是基于rm的,不超过rm大小都可以。
2.2.2 pendSV
pendSV 中断为 xPortPendSVHandler。
用于任务切换。
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp
isb
ldr r3, =pxCurrentTCB
ldr r2, [r3]
stmdb r0!, {r4-r11}
str r0, [r2]
stmdb sp!, {r3, r14}
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0
dsb
isb
bl vTaskSwitchContext
mov r0, #0
msr basepri, r0
ldmia sp!, {r3, r14}
ldr r1, [r3]
ldr r0, [r1]
ldmia r0!, {r4-r11}
msr psp, r0
isb
bx r14
nop
}
步骤如下:
- 系统进入pendSV异常,硬件自动压栈了PSR、PC、LR、R12、R3~R0的寄存器(使用PSP指针,压入任务堆栈)
- 然后 mrs r0, psp,把PSP的指针给到r0,一会手动压栈用
- 接着ldr r3, =pxCurrentTCB和 ldr r2, [r3],把变量pxCurrentTCB的地址赋值给r3,把pxCurrentTCB的值赋值给r2,即r2为当前任务TCB的地址。
- 接着从r0地址手动压栈r4 - r11。
- 接着临时入栈r3 和 r14,为了调用vTaskSwitchContext准备
- 通过调用 vTaskSwitchContext ,找到优先级最高的task,把pxCurrentTCB指过去。
- 出栈r3 和 r14
- r3存的是变量pxCurrentTCB的地址,通过ldr r1, [r3] 和 ldr r0, [r1],可以获得r1为指向新的TCB的pxCurrentTCB指针,r0为新的TCB第一个成员(栈顶指针)
- 然后从r0的地址手动出栈r4-r11
- 把更新完的r0的值给到psp,用于一会自动出栈 PSR、PC、LR、R12、R3~R0
- bx r14 /* 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
这样可以看出一个任务task的堆栈分配,抄朱工一张图来理解(朱工链接可以见文末参考链接)
2.2.2 SysTick
xPortSysTickHandler是系统滴答定时器的中断。
void xPortSysTickHandler( void )
{
vPortRaiseBASEPRI();
{
if( xTaskIncrementTick() != pdFALSE )
{
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
vPortClearBASEPRIFromISR();
}
可以看出这个中断函数主要就是做了xTaskIncrementTick,如果xTaskIncrementTick导致有优先级更高的任务进入就绪,就portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT调用pendSV来进行任务切换。
这个主要要分析的是xTaskIncrementTick。
3.3 N个状态链表
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
PRIVILEGED_DATA static List_t xDelayedTaskList1;
PRIVILEGED_DATA static List_t xDelayedTaskList2;
PRIVILEGED_DATA static List_t * volatile pxDelayedTaskList;
PRIVILEGED_DATA static List_t * volatile pxOverflowDelayedTaskList;
PRIVILEGED_DATA static List_t xPendingReadyList;
PRIVILEGED_DATA static List_t xSuspendedTaskList;
可以看出,这里系统一共维护了pxReadyTasksLists[configMAX_PRIORITIES ]、pxDelayedTaskList 、pxOverflowDelayedTaskList 、xPendingReadyList 、xSuspendedTaskList这么几个链表。
-
Ready态对应 pxReadyTasksLists[ configMAX_PRIORITIES ]
每个优先级都有一个pxReadyTasksLists来管理当前优先级的所有就绪态的任务TCB,在pendSV中断中,会调用taskSELECT_HIGHEST_PRIORITY_TASK接口来找最高优先级的就绪态的下一个任务。
-
block态对应 pxDelayedTaskList 或 pxOverflowDelayedTaskList , 这两个指针指的就是 xDelayedTaskList1 和 xDelayedTaskList2。
在滴答定时器中断中,首先会根据定时器是否溢出做一次指针的切换。
然后会把pxDelayedTaskList中的任务的xItemValue值做一次更新。
如果任务到期,把任务从pxDelayedTaskList链表中删除,加入到ready链表,如果这个任务的优先级更高,就返回pdTRUE,函数出去之后去置位pendSV中断寄存器。
-
xPendingReadyList 要注意一下,这个是在调度器被暂停时,新的任务进入了ready状态,因此先保存起来,在xTaskResumeAll时再加入到相应的ready list中。
引申几个问题:
- 为什么不直接加入到ready态,而建立了一个特殊的中间状态?
网上找了一段:
那么为什么要来一个中间步骤而不直接放进就绪任务列表呢?
这是因为任务从挂起到恢复可能出现优先级大于当前运行任务,高优先级任务要抢占低优先级任务,由于之前调度器被挂起,所以无法执行抢占操作。等调度器恢复后,再将xPendingReadyList里的任务一一取出来,判定是否有抢占操作发生或任务延时到期。
可见,在xTaskResumeAll中会把所有的xPendingReadyList加入到就绪列表。
- 什么时候会加入xPendingReadyList?
当任务从事件列表中删除的时候,即xTaskRemoveFromEventList中,如果调度器没挂起,就会加到Ready列表,如果挂起了,就会加到xPendingReadyList中。 - 挂起调度器是什么意思
代码中就是uxSchedulerSuspended的值改变了。执行vTaskSuspendAll的时候会++uxSchedulerSuspended,xTaskResumeAll的时候会 --uxSchedulerSuspended;当uxSchedulerSuspended不为0的时候就便是被挂起了,会挂起会发生什么?
在pendSV中断中,会调用vTaskSwitchContext,如果被挂起,vTaskSwitchContext是不会调用taskSELECT_HIGHEST_PRIORITY_TASK去就绪列表里找下一个任务的,
综上所述,xPendingReadyList的作用就是,保证操作系统的实时性,保证高优先级任务抢占能正常进行。
因为如果高优先级task从其他状态退出要进入就绪态时,应当立刻进行任务切换,同时状态进入就绪列表。但是,因为调度器挂起,无法立刻切换任务,如果直接把任务塞入就绪列表,当调度器恢复时,就无法实现“实时”抢占,要等下一个时间片查找高优先级任务时才能切换。因为只有状态变化的这个事件,会导致进行任务切换,如果当时没有成功切换,而且状态又已经切换成了就绪,就没有一个新的状态变化的事件再来触发一次任务切换了。
所以这里设置一个新的状态列表xPendingReadyList,当调度器恢复时,这些xPendingReadyList里的任务还要进行一次状态切换,利用这次状态变换的事件来触发任务切换。
- suspend态对应 xSuspendedTaskList,没啥说的,就是任务挂起的时候用。
4. 一些以前的错误思考
4.1 任务切换的原则
在认真阅读源码之前,其实之前的心里一直感觉是,任务调度是基于时间片的调度,每个时间周期(eg:1ms),作一次任务切换,切PC、栈之类的。
所以需要一个稳定的滴答定时器来做定时切换。
后来发现不是这样的,其实任务切换是基于状态来做的。
因为是抢占式的,只有更高优先级的任务进入ready状态才需要切状态,
状态的切换才会导致一次任务切换(pendSV)
滴答定时器只是做一个简单的工作,就是给计时数加1,(函数名字写的也很明白了,就是xTaskIncrementTick),如果因为加一,导致有些任务的状态发生变化,比如堵塞到期等,而导致改变了状态链表,产生了一个触发,这样才会进行pendSV的调度器。
可见滴答定时器也没有之前想象的那么重要,他也不过是产生了一个触发。
和其他事件的触发是一样。
比如入队出队操作,导致系统任务的状态发生变化,有优先级更高的任务放了出来,进入了ready,也是触发中断,进pendSV做上下文切换。
所以滴答和pendSV完全是两个东西,做的是两件事情。
所以放在两个中断里。
我见过有朋友问,为什么任务切换是pendSV,按理说,任何一个中断,设置到这个优先级上,都可以来做这个事,为什么是pendSV有这个“殊荣",如此独特。
是因为pendSV设计出来就是用来做切换的,pendSV可以挂起。
这部分可以见结尾参考链接"Cortex-M PendSV 应用"。
4.2 为什么滴答,pendSV的优先级要最低
之前一直以为滴答中断的优先级要高,这样能保证实时性,计时不会乱。
后来发现压根不是这回事。
在 xPortStartScheduler 中,
滴答和pendSV的中断优先级最低是为了保证其他中断都能正常运行, 保证系统会话切换不会阻塞系统其他中断的响应。计时是硬件计时器做的,及时进入中断不及时,也不会乱掉。
即我们不能允许下图这样的情况出现,会导致中断处理的时间不可控。
4.3 为什么正常的函数调用不需要手动压栈r4-r11
网上找的图,原文见参考链接。
中断调用,系统自动帮压栈R0-R3、R12、LR、PSR。中断程序也是优先用这些,如果不够,需要别的寄存器,编译器帮做压栈,所以不用手动写。
5. 参考链接
- FreeRTOS高级篇3—FreeRTOS调度器启动过程分析
- Cortex-M PendSV 应用
- 关于FreeRTOS任务栈的那点事儿
- 嵌入式操作系统学习(3)FreeRTOS的任务调度机制
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)