我们已经学会了 FreeRTOS 的任务创建和删除,挂起和恢复等基本操作,并且也学习了分析FreeRTOS 源码所必须掌握的知识:列表和列表项。但是任务究竟如何被创建、删除、挂起和恢复的?系统是怎么启动的等等这些我们还不了解,一个操作系统最核心的内容就是多任务管理,所以我们非常有必要去学习一下 FreeRTOS 的任务创建、删除、挂起、恢复和系统启动等,这样才能对 FreeRTOS 有一个更深入的了解。
本章和下一章要讲解的内容和 Cortex-M 处理器的内核架构联系非常紧密!阅读本章必须先对 Cortex-M 处理器的架构有一定的了解,在学习本章的时候一定要配合《权威指南》来学习,
推荐大家仔细阅读《权威指南》中的如下章节:
1、第 3 章 技术综述,通过阅读本章可以对 Cortex-M 处理器的架构有一个大体的了解。
2、第 4 章 架构,强烈建议仔细阅读本章内容,尤其是要理解其中讲解到的各个寄存器。
3、第 5 章 指令集,本章和下一章的内容会涉及到一些有关 ARM 的汇编指令,在阅读的
时遇到不懂的指令可以查阅《权威指南》的第 5 章中相关指令的讲解。
4、第 7 章 异常和中断,大概了解一下 。
5、第 8 章 深入了解异常处理,强烈建议仔细阅读!
6、第 10 章 OS 支持特性, 强烈建议仔细阅读!
《权威指南》中的其他章节大家依据个人爱好来阅读,由于《权威指南》讲解的内容非常的“底层”,所以看起来可能会感觉晦涩难懂,如果看不懂的话不要着急,看不懂的地方就跳过,先对 Cortex-M 的处理器有一个大概的了解就行了。
任务调度器开启
前面的所有例程中我们都是在 main()函数中先创建一个开始任务 start_task,后面紧接着调用函数 vTaskStartScheduler()。这个函数的功能就是开启任务调度器的,这个函数在文件tasks.c中有定义,具体可自行查阅。
内部实现流程大致如下:
(1)、创建空闲任务,如果使用静态内存的话使用函数 xTaskCreateStatic()来创建空闲任务,优先级为 tskIDLE_PRIORITY,宏 tskIDLE_PRIORITY 为 0,也就是说空闲任务的优先级为最低。
(2)、如果使用软件定时器的话还需要通过函数 xTimerCreateTimerTask()来创建定时器服务任务。定时器服务任务的具体创建过程是在函数 xTimerCreateTimerTask()中完成的,这个函数很简单,大家就自行查阅一下。
(3)、关闭中断,在 SVC 中断服务函数 vPortSVCHandler()中会打开中断。
(4)、变量 xSchedulerRunning 设置为 pdTRUE,表示调度器开始运行。
(5)、当宏 configGENERATE_RUN_TIME_STATS 为 1 的时候说明使能时间统计功能,此时需要用户实现宏 portCONFIGURE_TIMER_FOR_RUN_TIME_STATS,此宏用来配置一个定时器/计数器。
(6)、调用函数 xPortStartScheduler()来初始化跟调度器启动有关的硬件,比如滴答定时器、 FPU 单元和 PendSV 中断等等。
内核相关硬件初始化函数分析
关于上面的最后一点,内核相关硬件初始化函数 xPortStartScheduler()分析如下:
FreeRTOS 系统时钟是由滴答定时器来提供的,而且任务切换也会用到 PendSV 中断,这些硬件的初始化由函数 xPortStartScheduler()来完成,缩减后的函数代码如下:
(1)、设置 PendSV 的中断优先级,为最低优先级。
(2)、设置滴答定时器的中断优先级,为最低优先级。
(3)、调用函数 vPortSetupTimerInterrupt()来设置滴答定时器的定时周期,并且使能滴答定时
器的中断,函数比较简单,大家自行查阅分析。
(4)、初始化临界区嵌套计数器。
(5)、调用函数 prvStartFirstTask()开启第一个任务。
有一个问题,那就是,滴答定时器的定时周期以及中断开启不需要我们在移植初始化的时候配置吗?待解决。
启动第一个任务
经过上面的操作以后我们就可以启动第一个任务了,函数 prvStartFirstTask()用于启动第一个任务,这是一个汇编函数,函数源码如下:
(1)、将 0XE000ED08 保存在寄存器 R0 中。一般来说向量表应该是从起始地(0X00000000)开始存储的,不过,有些应用可能需要在运行时修改或重定义向量表,Cortex-M 处理器为此提供了一个叫做向量表重定位的特性。向量表重定位特性提供了一个名为向量表偏移寄存器(VTOR)的可编程寄存器。VTOR 寄存器的地址就是 0XE000ED08,通过这个寄存器可以重新定义向量表,比如在 STM32F103 的 ST 官方库中会通过函数 SystemInit()来设置VTOR 寄存器,代码如下:
SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; //VTOR=0x08000000+0X00
通过上面一行代码就将向量表开始地址重新定义到了 0X08000000,向量表的起始地址存储的就是 MSP 初始值。关于向量表和向量表重定位的详细内容请参阅《权威指南》的“第 7 章 异常和中断”的 7.5 小节。
(2)、读取 R0 中存储的地址处的数据并将其保存在 R0 寄存器,也就是读取寄存器 VTOR 中的值,并将其保存在 R0 寄存器中。这一行代码执行完就以后 R0 的值应该为0X08000000。
(3)、读取 R0 中存储的地址处的数据并将其保存在 R0 寄存器,也就是读取地址0X08000000处存储的数据,并将其保存在 R0 寄存器中。我们知道向量表的起始地址保存的就是主栈指针MSP 的初始值,这一行代码执行完以后寄存器 R0 就存储 MSP 的初始值。现在来看(1)、(2)、(3)这三步起始就是为了获取 MSP 的初始值而已!
(4)、复位 MSP,R0 中保存了 MSP 的初始值,将其赋值给 MSP 就相当于复位 MSP。
(5)和(6)、使能中断,关于这两个指令的详细内容请参考《权威指南》的“第 4 章 架构”的第 4.2.3 小节。
(7)和(8)、数据同步和指令同步屏障,这两个指令的详细内容请参考《权威指南》的“第 5 章 指令集”的 5.6.13 小节。
(9),调用 SVC 指令触发 SVC 中断,SVC 也叫做请求管理调用,SVC 和 PendSV 异常对于OS 的设计来说非常重要。SVC 异常由 SVC 指令触发。关于 SVC 的详细内容请参考《权威指南》的“第 10 章 OS 支持特性”的 10.3 小节。在 FreeRTOS中仅仅使用 SVC 异常来启动第一个任务,后面的程序中就再也用不到 SVC 了。
SVC中断服务函数
在函数 prvStartFirstTask()中通过调用 SVC 指令触发了 SVC 中断,而第一个任务的启动就是在 SVC 中断服务函数中完成的,SVC 中断服务函数应该为 SVC_Handler(),但是FreeRTOSConfig.h 中通过#define 的方式重新定义为了 xPortPendSVHandler(),如下:
#define vPortSVCHandler SVC_Handler
函数 vPortSVCHandler()在文件 port.c 中定义,这个函数也是用汇编写的,函数源码如下:
详细过程参考视频,此处不赘述。
RTOS 系统的核心是任务管理,而任务管理的核心是任务切换,任务切换决定了任务的执行顺序,任务切换效率的高低也决定了一款系统的性能,尤其是对于实时操作系统。
任务切换场合
有两种场合会进行任务切换
● 可以执行一个系统调用
● 系统滴答定时器(SysTick)中断。
执行系统调用
执行系统调用就是执行 FreeRTOS系统提供的相关API函数,比如任务切换函数 taskYIELD(), FreeRTOS 有些 API 函数也会调用函数 taskYIELD(),这些 API 函数都会导致任务切换,这些 API 函数和任务切换函数 taskYIELD()都统称为系统调用。函数 taskYIELD()其实就是个宏,在文件 task.h中有如下定义:
#define taskYIELD() portYIELD()
函数 portYIELD()也是个宏,在文件 portmacro.h 中有如下定义:
通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。 这样就可以在 PendSV 中断服务函数中进行任务切换了。
中断级的任务切换函数为 portYIELD_FROM_ISR(),定义如下:
可以看出 portYIELD_FROM_ISR()最终也是通过调用函数 portYIELD()来完成任务切换的。
系统滴答定时器(SysTick)中断
FreeRTOS 中滴答定时器(SysTick)中断服务函数中也会进行任务切换,滴答定时器中断服务函数如下:
在滴答定时器中断服务函数中调用了 FreeRTOS 的 API 函数 xPortSysTickHandler(),此函数源码如下:
(1)、关闭中断
(2)、通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
(3)、打开中断。
PendSV异常
PendSV(可挂起的系统调用)异常对 OS 操作非常重要,其优先级可以通过编程设置。可以通过将中断控制和状态寄存器 ICSR 的 bit28,也就是 PendSV 的挂起位置 1 来触发PendSV 中断。与 SVC 异常不同,它是不精确的,因此它的挂起状态可在更高优先级异常处理内设置,且会在高优先级处理完成后执行。利用该特性,若将 PendSV 设置为最低的异常优先级,可以让 PendSV 异常处理在所有其他中断处理完成后执行,这对于上下文切换非常有用,也是各种 OS 设计中的关键。
中断的优先级永远高于任务的优先级,用中断最低优先级的中断来切换任务,既不会影响高优先级的中断的执行,又能实现任务的切换。
在具有嵌入式 OS 的典型系统中,处理时间被划分为了多个时间片。若系统中只有两个任 务,这两个任务会交替执行,如下图所示:
在 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,同时系统再次进入线程模式。
讲解 PendSV 异常的原因就是让大家知道,FreeRTOS 系统的任务切换最终都是在 PendSV 中断服务函数中完成的,UCOS 也是在 PendSV 中断中完成任务切换的。
PendSV中断服务函数
前面说了 FreeRTOS 任务切换的具体过程是在 PendSV 中断服务函数中完成的,接着我们就来学习PendSV 的中断服务函数,看看任务切换过程究竟是怎么进行的。PendSV 中断服务函数本应该为 PendSV_Handler(),但是 FreeRTOS 使用#define 重定义了,如下:
#define xPortPendSVHandler PendSV_Handler
该函数源码如下:
(1)、读取进程栈指针,保存在寄存器 R0 里面。
(2)和(3),获取当前任务的任务控制块,并将任务控制块的地址保存在寄存器 R2 里面。
(4)、保存 r4~r11 和 R14 这几个寄存器的值。
(5)、将寄存器 R0 的值写入到寄存器 R2 所保存的地址中去,也就是将新的栈顶保存在任务控制块的第一个字段中。此时的寄存器 R0 保存着最新的堆栈栈顶指针值,所以要将这个最新的栈顶指针写入到当前任务的任务控制块第一个字段,而经过(2)和(3)已经获取到了任务控制块,并将任务控制块的首地址写如到了寄存器 R2 中。
(6)、将寄存器 R3 和 R14 的值临时压栈,寄存器 R3 中保存了当前任务的任务控制块,而接下来要调用函数 vTaskSwitchContext(),为了防止 R3 和 R14 的值被改写,所以这里临时将 R3和 R14 的值先压栈。
(7)和(8)、关闭中断,进入临界区
(9)、调用函数 vTaskSwitchContext(),此函数用来获取下一个要运行的任务,并将 pxCurrentTCB 更新为这个要运行的任务。
(10)和(11)、打开中断,退出临界区。
(12)、刚刚保存的寄存器 R3 和 R14 的值出栈,恢复寄存器 R3 和 R14 的值。注意,经过(12)步,此时 pxCurrentTCB 的值已经改变了,所以读取 R3 所保存的地址处的数据就会发现其值改变了,成为了下一个要运行的任务的任务控制块。
(13)和(14)、获取新的要运行的任务的任务堆栈栈顶,并将栈顶保存在寄存器 R0 中。
(15)、R4~R11,R14 出栈,也就是即将运行的任务的现场。
(16)、更新进程栈指针 PSP 的值。
(17)、执行此行代码以后硬件自动恢复寄存器 R0~R3、R12、LR、PC 和 xPSR 的值,确定
异常返回以后应该进入处理器模式还是进程模式,使用主栈指针(MSP)还是进程栈指针(PSP)。
很明显这里会进入进程模式,并且使用进程栈指针(PSP),寄存器 PC 值会被恢复为即将运行的任务的任务函数,新的任务开始运行!至此,任务切换成功。
总的来说,其实就是保存之前的任务现场,然后恢复下一个任务的现场。
查找下一个要运行的任务
在 PendSV 中断服务程序中有调用函数 vTaskSwitchContext()来获取下一个要运行的任务, 也就是查找已经就绪了的优先级最高的任务。
该函数内部实现过程如下:
(1)、如果调度器挂起那就不能进行任务切换。
(2)、调用函数 taskSELECT_HIGHEST_PRIORITY_TASK()获取下一个要运行的任务。
taskSELECT_HIGHEST_PRIORITY_TASK()本质上是一个宏,在 tasks.c 中有定义。
FreeRTOS 中查找下一个要运行的任务有两种方法:一个是通用的方法,另外一个就是使用硬件的方法,这个在我们讲解 FreeRTOSCofnig.h 文件的时候就提到过了,至于选择哪种方法通过宏configUSE_PORT_OPTIMISED_TASK_SELECTION 来决定的。当这个宏为 1 的时候就使用硬件的方法,否则的话就是使用通用的方法,我们来看一下这两个方法的区别。
通用方法
顾名思义,就是所有的处理器都可以用的方法,通用方法是完全通过 C 语言来实现的,肯定适用于不同的芯片和平台,而且对于任务数量没有限制,但是效率肯定相对于使用硬件方法的要低很多。
硬件方法
硬件方法就是使用处理器自带的硬件指令来实现的,比如 Cortex-M 处理器就带有的计算前 导 0 个数指令:CLZ。
如果使用硬件方法的话最多只能有 32 个优先级。
可以看出硬件方法借助一个指令就可以快速的获取处于就绪态的最高优先级,但是会限制任务的优先级数,比如 STM32 只能有 32 个优先级,不过 32 个优先级已经完全够用了。要知道FreeRTOS 是支持时间片的,每个优先级可以支持无限多个任务。
FreeRTOS 时间片调度
前面多次提到 FreeRTOS 支持多个任务同时拥有一个优先级,这些任务的调度是一个值得考虑的问题,不过这不是我们要考虑的。在 FreeRTOS 中允许一个任务运行一个时间片(一个时钟节拍的长度)后让出 CPU 的使用权,让拥有同优先级的下一个任务运行,至于下一个要运行哪个任务?在上小节里面已经分析过了,FreeRTOS 中的这种调度方法就是时间片调度。下图展示了运行在同一优先级下的执行时间图,在优先级 N 下有 3 个就绪的任务。
(1)任务3正在运行。
(2)这时一个时钟节拍中断(滴答定时器中断)发生,任务3的时间片用完,但是任务3还
没有执行完。
(3)FreeRTOS 将任务切换到任务1,任务1是优先级 N 下的下一个就绪任务。
(4)任务1连续运行至时间片用完。
(5)任务3再次获取到 CPU 使用权,接着运行。
(6)任务3运行完成,调用任务切换函数 portYIELD()强行进行任务切换放弃剩余的时间片, 从而使优先级N下的下一个就绪的任务运行。
(7)FreeRTOS 切换到任务1。
(8)任务1执行完其时间片。
要使用时间片调度的话宏 configUSE_PREEMPTION 和宏 configUSE_TIME_SLICING 必须为 1。时间片的长度由宏 configTICK_RATE_HZ 来确定,一个时间片的长度就是滴答定时器的中断周期,比如本教程中 configTICK_RATE_HZ 为 1000,那么一个时间片的长度就是 1ms。时间片调度发生在滴答定时器的中断服务函数中,前面讲解滴答定时器中断服务函数的时候说了在中断服务函数 SysTick_Handler()中会调用 FreeRTOS 的 API 函数xPortSysTickHandler(),而函数 xPortSysTickHandler() 会 引 发 任 务 调 度 , 但 是 这个 任 务 调 度 是 有 条 件 的 ,函 数xPortSysTickHandler()如下:
上述代码中红色部分表明只有函数 xTaskIncrementTick()的返回值不为pdFALSE的时候就会进行任务调度!查看函数 xTaskIncrementTick()会发现有如下条件编译语句:
(1)、当宏 configUSE_PREEMPTION 和宏 configUSE_PREEMPTION 都为 1 的时候下面的代码才会编译。所以要想使用时间片调度的话这这两个宏都必须为 1,缺一不可!
(2)、判断当前任务所对应的优先级下是否还有其他的任务。
(3)、如果当前任务所对应的任务优先级下还有其他的任务那么就返回 pdTRUE。
从上面的代码可以看出,如果当前任务所对应的优先级下有其他的任务存在,那么函数xTaskIncrementTick() 就会返回pdTURE ,由于函数返回值为 pdTURE,因此函数xPortSysTickHandler()就会进行一次任务切换。
也就是说,时间片调度只有在同一优先级下还有其他任务时才会进行任务切换。
遗留问题:抢占式调度是怎么实现的?