详解FreeRTOS开发过程(四)-- 任务切换
一.PendSV异常
PendSV(可挂起的系统调用)异常对 OS 操作非常重要,其优先级可以通过编程设置。可以通过将中断控制和状态寄存器ICSR的bit28,也就是PendSV的挂起位置1来触发PendSV中断。与SVC异常不同,它是不精确的,因此它的挂起状态可在更高优先级异常处理内设置,且会在高优先级处理完成后执行。
利用该特性,若将PendSV设置为最低的异常优先级,可以让PendSV异常处理在所有其他中断处理完成后执行,这对于上下文切换非常有用,也是各种OS设计中的关键。
在具有嵌入式 OS 的典型系统中,处理时间被划分为了多个时间片。若系统中只有两个任 务,这两个任务会交替执行,如下图所示:
上述图片的意思就是:线程任务 A ,在系统滴答定时器中断 SysTick 中进行上下文切换,每次都会切换到另外一个任务;因为上图只有两个任务,所以这两个任务进行循环切换。
上下文切换被触发的场合可以是:执行一个系统调用和系统滴答定时器(SysTick)中断。
在OS中,任务调度器决定是否应该执行上下文切换,如上图所示任务切换都是由SysTick中断中执行,每次它都会决定切换到一个不同的任务中。
若中断请求(IRQ)在SysTick异常前产生,则SysTick异常可能会抢占IRQ的处理,在这种情况下,OS不应该执行上下文切换,否则中断请求IRQ处理就会被延迟,而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。对于Cortex M3和Cortex-M4 处理器,当存在活跃的异常服务时,设计默认不允许返回到线程模式,若存在活跃中断服务,且OS试图返回到线程模式,则将触发用法fault,如下图所示:
在一些OS 设计中,要解决这个问题,可以在运行中断服务时不执行上下文切换,此时可以检查栈帧中的压栈xPSR或NVIC中的中断活跃状态寄存器。不过,系统的性能可能会受到影响,特别时当中断源在SysTick中断前后持续产生请求时,这样上下文切换可能就没有执行的机会了。
为了解决这个问题,PendSV 异常将上下文切换请求延迟到所有其他IRQ处理都已经完成后,此时需要将PendSV设置为最低优先级。若OS需要执行上下文切换,他会设置PendSV的挂起状态,并在PendSV异常内执行上下文切换。如下图所示:
上图中事件的流程如下:
1.任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)
2.OS 接收到请求,做好上下文切换的意思,并且 Pend 一个 PendSV 异常
3.当 CPU 退出 SVC 后,它立即进入 PendSV ,从而执行上下文切换
4.当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式。
5.发生了一个中断,并且中断服务程序开始执行
6.在 ISR 的执行过程中,发生SysTick异常,并且抢占了该 ISR
7.OS 执行必要的操作,然后 Pend 起 PendSV 异常以做好上下文切换的准备
8.当 SysTick 退出后,回到先前被抢占的 ISR 中,ISR 继续执行
9.ISR 执行完毕并退出后,PendSV 服务例程开始执行,并且在里面执行上下文切换
10.当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。
二.FreeRTOS 任务切换场合
1.执行系统调用
执行系统调用就是执行FreeRTOS系统提供的相关API函数,比如任务切换函数taskYIELD(), FreeRTOS 有些 API 函数也会调用函数taskYIELD(),这些API函数都会导致任务切换,这些API函 数和任务切换函数taskYIELD()都统称为系统调用。函数taskYIELD()其实就是个宏,在文件task.h 中有如下定义:
#define taskYIELD() portYIELD() #define portYIELD() \
{ \ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \\ __dsb( portSY_FULL_READ_WRITE ); \ __isb( portSY_FULL_READ_WRITE ); \
}
通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
2.系统滴答定时器(SysTick)中断
FreeRTOS 中滴答定时器(SysTick)中断服务函数中也会进行任务切换,滴答定时器中断服务函数如下:
void SysTick_Handler(void)
{ if(xTaskGetSchedulerState()!=taskSCHEDULER_NOT_STARTED)//系统已经运行 { xPortSysTickHandler(); }
}
在滴答定时器中断服务函数中调用了FreeRTOS的API函数xPortSysTickHandler(),此函数源码如下:
void xPortSysTickHandler( void )
{ vPortRaiseBASEPRI(); //关闭中断{ if( xTaskIncrementTick() != pdFALSE ) //增加时钟计数器 xTickCount 的值 {//通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。//这样就可以在 PendSV 中断服务函数中进行任务切换了。portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; } } vPortClearBASEPRIFromISR(); //打开中断
}
三.PendSV中断服务函数
FreeRTOS任务切换的具体过程是在PendSV中断服务函数中完成的,PendSV中断服务函数本应该为PendSV_Handler(),但是FreeRTOS使用#define重定义了,如下:
#define xPortPendSVHandler PendSV_Handler
函数 xPortPendSVHandler() 源码如下:
__asm void xPortPendSVHandler( void )
{ extern uxCriticalNesting; extern pxCurrentTCB; extern vTaskSwitchContext; PRESERVE8 mrs r0, psp // 读取进程栈指针,保存在寄存器 R0 里面.isb ldr r3, =pxCurrentTCB ldr r2, [r3] // 获取当前任务的任务控制块,并将任务控制块的地址保存在寄存器 R2 里面.tst r14, #0x10 //保存r4~r11 和R14这几个寄存器的值。it eq //判断任务是否使用了 FPU,如果任务使用了 FPU 的话在进行任务切换的时候就需要将 FPU 寄存器 s16~s31 手动保存到任务堆栈中,其中 s0~s15 和 FPSCR 是自动保存的vstmdbeq r0!, {s16-s31} //保存 s16~s31 这16个 FPU 寄存器stmdb r0!, {r4-r11, r14} // 保存 R4~R11 和 R14 这几个寄存器的值str r0, [r2] // 将寄存器 R0 的值写入到寄存器 R2 所保存的地址中去,也就是将新的栈顶保存在任务控制块的第一个字段中。此时的寄存器 R0 保存着最新的堆栈栈顶指针值,所以要将这个最新的栈顶指针写入到当前任务的任务控制块第一个字段,而经过(2)和(3)已经获取到了任务控制块,并将任务控制块的首地址写到了寄存器 R2 中。stmdb sp!, {r3} //将寄存器 R3 的值临时压栈,寄存器 R3 中保存了当前任务的任务控制块,而接下来要调用函数 vTaskSwitchContext(),为了防止 R3 的值被改写,所以这里临时将 R3 的值先压栈mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY // 关闭中断,进入临界区msr basepri, r0 //关闭中断,进入临界区dsb isb bl vTaskSwitchContext //调用函数 vTaskSwitchContext(),此函数用来获取下一个要运行的任务,并将 pxCurrentTCB 更新为这个要运行的任务mov r0, #0 //打开中断,退出临界区msr basepri, r0 //打开中断,退出临界区ldmia sp!, {r3} //刚刚保存的寄存器 R3 的值出栈,恢复寄存器 R3 的值。注意,经过(12)步,此时 pxCurrentTCB 的值已经改变了,所以读取 R3 所保存的地址处的数据就会发现其值改变了,成为了下一个要运行的任务的任务控制块。ldr r1, [r3] ldr r0, [r1] // 获取新的要运行的任务的任务堆栈栈顶,并将栈顶保存在寄存器 R0 中ldmia r0!, {r4-r11, r14} // R4~R11,R14出栈,也就是即将要运行的任务的现场tst r14, #0x10 it eq vldmiaeq r0!, {s16-s31} // 判断即将运行的任务是否有使用到 FPU ,如果有的话还需要手工恢复 FPU 的 s16~s31 寄存器msr psp, r0 // 更新进程栈指针 PSP 的值isb bx r14 // 执行此行代码以后硬件自动恢复寄存器 R0~R3、R12、LR、PC 和 xPSR 的值,确定异常返回以后应该进入处理器模式还是进程模式,使用主栈指针(MSP)还是进程栈指针(PSP)。很明显这里会进入进程模式,并且使用进程栈指针(PSP),寄存器 PC 值会被恢复为即将运行的任务的任务函数,新的任务开始运行!至此,任务切换成功。
}
四.查找下一个要运行的任务
在PendSV 中断服务程序中有调用函数vTaskSwitchContext()来获取下一个要运行的任务,也就是查找已经就绪了的优先级最高的任务,缩减后(去掉条件编译)函数源码如下:
void vTaskSwitchContext( void )
{ //判断调度器是否被挂起,如果调度器被挂起,那就不能进行任务切换if( uxSchedulerSuspended != ( UBaseType_t ) pdFALSE ) { xYieldPending = pdTRUE; } else { xYieldPending = pdFALSE; traceTASK_SWITCHED_OUT(); taskCHECK_FOR_STACK_OVERFLOW(); //获取下一个要运行的任务。(也就是任务优先级最高的任务),taskSELECT_HIGHEST_PRIORITY_TASK(); traceTASK_SWITCHED_IN(); }
}
FreeRTOS 中查找下一个运行的任务有两种方法:一个是通用的方法,另外一个是使用硬件的方法,至于选择哪种方法通过宏 configUSE_PORT_OPTIMISED_TASK_SELECTION 来决定的,当这个宏为 1 的时候使用硬件的方法,否则就使用通用的方法。
1.通用方法
#define taskSELECT_HIGHEST_PRIORITY_TASK()
{ UBaseType_t uxTopPriority = uxTopReadyPriority; while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) (1) { configASSERT( uxTopPriority ); --uxTopPriority;} listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, (2) &( pxReadyTasksLists[ uxTopPriority ] ) ); uxTopReadyPriority = uxTopPriority;
}
(1)pxReadyTasksLists[]为就绪任务列表数组,一个优先级一个 列表,同优先级的就绪任务都挂到相对应的列表中。uxTopReadyPriority代表处于就绪态的最高优先级值,每次创建任务的时候都会判断新任务的优先级是否大于uxTopReadyPriority,如果大于的话就将这个新任务的优先级赋值给变量uxTopReadyPriority。函数prvAddTaskToReadyList()也会修改这个值,也就是说将某个任务添加到就绪列表中的时候都会用 uxTopReadyPriority 来记录就绪列表中的最高优先级。这里就从这个最高优先级开始判断,看看哪个列表不为空就说明哪个优先级有就绪的任务。函数 listLIST_IS_EMPTY()用于判断某个列表是否为空, uxTopPriority 用来记录这个有就绪任务的优先级。
(2)已经找到了有就绪任务的优先级了,接下来就是从对应的列表中找出下一个要运行的任务,查找方法就是使用函数listGET_OWNER_OF_NEXT_ENTRY()来获取列表中的下一个列表项,然后将获取到的列表项所对应的任务控制块赋值给pxCurrentTCB,这样我们就确定了下 一个要运行的任务了。
2.硬件方法
硬件方法就是使用处理器自带的硬件指令来实现的,比如Cortex-M处理器就带有的计算前导0个数指令:CLZ。
五.FreeRTOS 时间片调度
在FreeRTOS中允许一个任务运行一个时间片(一个时钟节拍的长度)后让出CPU 的使用权,让拥有同优先级的下一个任务运行,FreeRTOS 中的这种调度方法就是时间片调度。下图展示了运行在同一优先级下的执行时间图,在优先级N下有3个就绪的任务。
1.任务3正在运行。
2.这时一个时钟节拍中断(滴答定时器中断)发生,任务3的时间片用完,但是任务3还没有执行完。
4.FreeRTOS 将任务切换到任务1,任务1是优先级 N下的下一个就绪任务。
5.任务1连续运行至时间片用完。
6.任务3再次获取到CPU使用权,接着运行。
7.任务3运行完成,调用任务切换函数portYIELD()强行进行任务切换放弃剩余的时间片, 从而使优先级N下的下一个就绪的任务运行。
8.FreeRTOS 切换到任务1。
9.任务1执行完其时间片。
要使用时间片调度的话宏configUSE_PREEMPTION和宏configUSE_TIME_SLICING必须为1。时间片的长度由宏configTICK_RATE_HZ来确定,一个时间片的长度就是滴答定时器的中断周期,比如configTICK_RATE_HZ为1000,那么一个时间片的长度就是1ms。
滴答定时器中断服务函数SysTick_Handler()中会调用函数xPortSysTickHandler(),而函数 xPortSysTickHandler()会引发任务调度,但是这个任务调度是有条件的,函数 xPortSysTickHandler()如下:
void xPortSysTickHandler( void )
{ vPortRaiseBASEPRI(); { if( xTaskIncrementTick() != pdFALSE ){ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; } } vPortClearBASEPRIFromISR();
}
如果当前任务所对应的优先级下有其他的任务存在,那么函数 xTaskIncrementTick()就会返回pdTURE,由于函数返回值为pdTURE因此函数 xPortSysTickHandler()就会进行一次任务切换。