这次来讲讲任务的调度。任务是嵌入式系统的核心要素,业务由它承载。目前所有的嵌入系统都支持多任务运行,但是从微观(即某一个时刻)上来讲,一个CPU核只能跑一个任务,可实际业务场景中又需要在一个CPU核上同时运行多个任务,这怎么办呢?只能从宏观上来实现,即让人看起来多个任务是同时运行的。要想达到这个目的,就需要任务调度,即不同的时刻调度出不同的任务给CPU执行,让系统看起来是同时运行多个任务。
FreeRTOS上采用的是比较普遍的基于优先级的可抢占式的时间片轮转调度算法(注:FreeRTOS也支持配置成非抢占式,目前只讨论可抢占的场景),即在系统的生命周期里,任务消耗完一个时间片之后,系统就从就绪任务队列中调度出另一个任务,并执行。当然这属于任务被动放弃CPU的场景,CPU资源相当宝贵,那些处于等待事件状态的任务没必要继续占用CPU,所以FreeRTOS也支持任务自己主动释放CPU。
从任务调度的流程上看,可以分为两大步骤:1,确定是否需要触发任务调度;2,执行任务调度。
确定是否需要触发任务调度
怎么确定是否需要触发任务调度呢?由谁来确定呢?当然不能依赖当前正在运行的任务,自己跑得正酣,怎么舍得放弃CPU,而且总不可能每个任务都统计自己跑了多久然后去切换下一个任务是吧。所以必然有一个东西在默默的执行着,这个东西就是定时器中断,定时器中断对应的异常向量维护着一个软定时器,即全局变量xTickCount,每触发一次定时器中断,该变量加1,这是系统的时基,很多时间都是以这个作为参考,如时间片是否跑完,阻塞任务是否到期等。
一般将定时器中断的时间间隔定为10ms,当然这个间隔可以根据实际的业务场景进行调整,间隔越短则触发调度的频率高,将降低CPU利用率,反之间隔越长则会影响实时性。总之,这也是平衡的艺术。
定时器中断里除了维护软定时器,还负责检查阻塞任务队列里是否有到期的任务,如果有的话就将所有到期的任务添加到就绪队列中,并触发任务调度。如果当前任务优先级的就绪任务队列中还有其他任务,也会触发任务调度。任务调度触发后,即开始正式选取下一个任务并执行,其选取原则为从就绪任务队列中选取优先级最高的任务。
上面两个属于被动触发任务调度,系统还支持主动触发任务调度,即正在运行的任务主动放弃CPU,触发系统调度运行其他的任务。
主动触发和被动触发的触发方式是一样的,详情见下一小节。
执行任务调度
那么如何选取目标任务并切换执行呢?
首先了解一下预备知识,这里需要用到异常向量表及里面的SysTick和PendSV异常向量。以M4的异常向量表为例,如下:
![fbe8c52bab1575b627e553d9728e7b6b.png](https://i-blog.csdnimg.cn/blog_migrate/6e81debf4058b801758af7fb817e5d00.jpeg)
这里需要关注表中的SysTick(向量15)和PendSV(向量14)。
前面提到在FreeRTOS里,有两种情况会触发任务切换,一种是当前任务主动放弃CPU,另外一种是时间片轮转到期,如下图:
![36f866046c30342929c6368d6710e8f2.png](https://i-blog.csdnimg.cn/blog_migrate/77f5fbf7daa86a4b4a10cb2dc2484e63.jpeg)
两种方法进行任务切换的硬件实现是一样的,简单来讲是通过触发异常,在异常向量里处理任务上下文切换务,对于M4核的CPU来讲,该异常为PendSV,该异常的描述:
![3add24dc62d273fae9ae419b91553bc6.png](https://i-blog.csdnimg.cn/blog_migrate/4b53072e82d888091226862020206fe2.jpeg)
那么软件如何去触发该异常呢?配置ICSR寄存器的比特PENDSVSET即可,如下图:
![4af264de2fe83a6a2ba2f7f6f2fa070c.png](https://i-blog.csdnimg.cn/blog_migrate/b0ba02d4ce3ea61aab30c3c0f966b979.jpeg)
![7026d43138cc339635c7e431d026690a.png](https://i-blog.csdnimg.cn/blog_migrate/f4104ae0d08da9318ec7ef1d4f0bfd81.jpeg)
该比特置1后即可触发PendSV异常。异常触发后,CPU便跳转到固定地址执行我们指定的异常向量,比如PendSV_Handler:
.section.isr_vector,"a",%progbits.typeg_pfnVectors, %object.sizeg_pfnVectors, .-g_pfnVectorsg_pfnVectors:.word_estack.wordReset_Handler.wordNMI_Handler.wordHardFault_Handler.wordMemManage_Handler.wordBusFault_Handler.wordUsageFault_Handler.word0.word0.word0.word0.wordSVC_Handler.wordDebugMon_Handler.word0.wordPendSV_Handler //这里.wordSysTick_Handler.wordWWDG_IRQHandler.wordPVD_PVM_IRQHandler.wordTAMP_STAMP_IRQHandler.wordRTC_WKUP_IRQHandler.wordFLASH_IRQHandler.wordRCC_IRQHandler.wordEXTI0_IRQHandler.wordEXTI1_IRQHandler
那么现在聚焦在该异常向量是如何完成任务切换的了。任务切换接口采用内嵌汇编实现(有裁剪,为便于理解不考虑有FPU的场景):
__asm volatile ( "mrs r0, psp " "isb " " " /* Get the location of the current TCB. */ "ldrr3, pxCurrentTCBConst " "ldrr2, [r3] " " " /* Save the core registers. */ "stmdb r0!, {r4-r11, r14} " /* Save the new top of stack into the first member of the TCB. */ "str r0, [r2] " " " "stmdb sp!, {r0, r3} " "mov r0, %0 " "msr basepri, r0 " "dsb " "isb " "bl vTaskSwitchContext " "mov r0, #0 " "msr basepri, r0 " "ldmia sp!, {r0, r3} " " " /* The first item in pxCurrentTCB is the task top of stack. */ "ldr r1, [r3] " "ldr r0, [r1] " " " /* Pop the core registers. */ "ldmia r0!, {r4-r11, r14} " " " "msr psp, r0 " "isb " " " "bx r14 " " " ".align 4 " "pxCurrentTCBConst: .word pxCurrentTCB " ::"i"(configMAX_SYSCALL_INTERRUPT_PRIORITY));
该流程分析如下:
![4c848304531ba7590fff8f99d9ba39c6.png](https://i-blog.csdnimg.cn/blog_migrate/ad916fcdd6ab8ada8b35f2df7aefc3b2.jpeg)
这里除了任务上下文的切换,要特别注意的是流程中pxCurrentTCB在选出新任务的时候即发生改变,并且pxCurrentTCB的第一个成员即为栈底地址,指向的是有效栈数据的最底部,这是固定不变的,它在上下文切换中起到关键作用:
typedef struct tskTaskControlBlock { /*< Points to the location of the last item placed on the tasks stack. *THIS MUST BE THE FIRST MEMBER OF THE TCB STRUCT. */ volatile StackType_t*pxTopOfStack; ...}tskTCB;
这样新调度出来的任务就开始执行了,系统如此反复,不停的在任务间切换。
综上概括来讲,调度程序会遍历就绪任务队列,选择优先级最高的任务,然后通过上下文切换的方式切换到目标任务执行。