我发现学习 RTOS 是学习 Linux 内核的好方法。大有弯道超车的可能。
- 1. 任务堆栈
- 1.1 任务栈大小确定
- 1.2 栈溢出检测机制
- 2. 任务状态
- 3. 任务优先级
- 3.1任务优先级说明
- 3.2 任务优先级分配方案
- 3.3 任务优先级与终端优先级的区别
- 4. 任务调度
- 4.1 调度器
- 5. 临界区、锁与系统时间
- 5.1 临界区与开关中断
- 5.2 锁
- 5.3 FreeRTOS 系统时钟节拍和时间管理
一、 单任务系统(裸机)
主要是采用超级循环系统(前后台系统),应用程序是一个无限的循环,循环中调用相应的函数完成相应的操作,这部分可以看做后台行为;中断服务程序处理异步事件,这部分可以看做是前台行为。后台也可以叫做任务级,前台也叫作中断级。
前后台系统的编程思路有两种:轮询方式(实时性得不到保障,紧急与非紧急消息不能有效管理)、中断方式(可以保证一定的实时性,紧急消息可以得到响应)。
采用中断和查询结合的方式可以解决大部分裸机应用,但随着工程的复杂,裸机方式的缺点就暴露出来了:
- 必须在中断(ISR)内处理时间关键运算:
- ISR 函数变得非常复杂,并且需要很长执行时间。
- ISR 嵌套可能产生不可预测的执行时间和堆栈需求。
- 超级循环和 ISR 之间的数据交换是通过全局共享变量进行的:
- 应用程序的程序员必须确保数据一致性。
- 超级循环可以与系统计时器轻松同步,但:
- 如果系统需要多种不同的周期时间,则会很难实现。
- 超过超级循环周期的耗时函数需要做拆分。
- 增加软件开销,应用程序难以理解。
- 超级循环使得应用程序变得非常复杂,因此难以扩展:
- 一个简单的更改就可能产生不可预测的副作用,对这种副作用进行分析非常耗时。
- 超级循环概念的这些缺点可以通过使用实时操作系统 (RTOS) 来解决。
二、多任务系统(带OS)
采用多任务系统可以以上的裸机开发遇到的4大缺点。
RTOS的实现重点就在这个OS任务调度器上,调度器的作用就是使用相关的调度算法来决定当前需要执行的任务。FreeRTOS就是一款支持多任务运行的实时操作系统,具有时间片、抢占式和合作式三种调度方式。通过 FreeRTOS 实时操作系统可以将程序函数分成独立的任务,并为其提供合理的调度方式。
1. 任务堆栈
栈大小 0x400 = 1024,单位字节。在RTOS下,上面截图里设置的栈大小有了一个新名字叫做系统栈空间,而任务栈是不使用这里的空间,哪里使用这里的栈空间呢,实际上是中断函数和中断嵌套。
- 由于 Cortex-M3 和 M4 内核具有双堆栈指针,MSP 主堆栈指针和 PSP 进程堆栈指针,或者叫 PSP 任务堆栈指针也是可以的。在 FreeRTOS 操作系统中,主堆栈指针 MSP 是给系统栈空间使用的,进 程堆栈指针 PSP 是给任务栈使用的。也就是说,在 FreeRTOS 任务中,所有栈空间的使用都是通过 PSP 指针进行指向的。一旦进入了中断函数以及可能发生的中断嵌套都是用的 MSP 指针。这个知识 点要记住它,当前可以不知道这是为什么,但是一定要记住。
- 实际应用中系统栈空间分配多大,主要是看可能发生的中断嵌套层数,下面我们就按照最坏执行情况 进行考虑,所有的寄存器都需要入栈,此时分为两种情况:
64 字节:对于 Cortex-M3 内核和未使用 FPU(浮点运算单元)功能的 Cortex-M4 内核在发生中断时需要将 16 个通用寄存器全部入栈,每个寄存器占用 4 个字节,也就是 16*4 = 64 字节的空间。可能发生几次中断嵌套就是要 64 乘以几即可。当然,这种是最坏执行情况,也就是所有的寄存器都入栈。(注:任务执行的过程中发生中断的话,有 8 个寄存器是自动入栈的,这个栈是任务栈,进入中断以后其余寄存器入栈以及发生中断嵌套都是用的系统栈) 200 字节: 对于具有 FPU(浮点运算单元)功能的 Cortex-M4 内核,如果在任务中进行了浮点运算,那么在发生中断的时候除了 16 个通用寄存器需要入栈,还有 34 个浮点寄存器也是要入栈的,也就是(16 34)*4 = 200 字节的空间。当然,这种是最坏执行情况,也就是所有的寄存器都入栈。
1.1 任务栈大小确定
- 函数的栈大小计算起来是比较麻烦的,那么有没有简单的办法来计算呢?有的,一般 IDE 开发环境都有这样的功能,比如 MDK 会生成一个 htm 文件,通过这个文件用户可以知道每个被调用函数的最大栈需求以及各个函数之间的调用关系。但是 MDK 无法确定通过函数指针实现函数调用时的栈需求。另外,发生中断或中断嵌套时的现场保护需要的栈空间也不会统计。
- 一般来说,用户可以事先给任务分配一个大的栈空间,然后通过打印任务栈的使用情况,运行一段时间就会有个大概的范围了。这种方法比较简单且实用些。
1.2 栈溢出检测机制
栈生长方向从高地址向低地址生长(M4 和 M3 是这种方式)
上图标识 3 的位置是局部变量 int i 和 int array[10]占用的栈空间,但申请了栈空间后已经越界了。这个就是所谓的栈溢出了。如果用户在函数 test 中通过数组 array 修改了这部分越界区的数据且这部分越界的栈空间暂时没有用到或者数据不是很重要,情况还不算严重,但是如果存储的是关键数据,会直接导致系统崩溃。 上图标识 4 的位置是局部变量申请了栈空间后,栈指针向下偏移(返回地址 变量 i 10 个数组元素)*4 =48 个字节。 上图标识 5 的位置可能是其它任务的栈空间,也可能是全局变量或者其它用途的存储区,如果 test函数在使用中还有用到栈的地方就会从这里申请,这部分越界的空间暂时没有用到或者数据不是很重要,情况还不算严重,但是如果存储的是关键数据,会直接导致系统崩溃。
FreeRTOS 提供了两种栈溢出检测机制,这两种检测都是在任务切换时才会进行:
- 在任务切换时检测任务栈指针是否过界了,如果过界了,在任务切换的时候会触发栈溢出钩子函数。void vApplicationStackOverflowHook( TaskHandle_t xTask,signed char *pcTaskName ); 用户可以在钩子函数里面做一些处理。这种方法不能保证所有的栈溢出都能检测到。比如任务在执行的过程中出现过栈溢出。任务切换前栈指针又恢复到了正常水平,这种情况在任务切换的时候是检测不到的。又比如任务栈溢出后,把这部分栈区的数据修改了,这部分栈区的数据不重要或者暂时没有用到还好,但如果是重要数据被修改将直接导致系统进入硬件异常,这种情况下,栈溢出检测功能也是检测不到的。
使用方法一需要用户在 FreeRTOSConfig.h 文件中配置如下宏定义:
#define configCHECK_FOR_STACK_OVERFLOW 1
- 任务创建的时候将任务栈所有数据初始化为 0xa5,任务切换时进行任务栈检测的时候会检测末尾的 16 个字节是否都是 0xa5,通过这种方式来检测任务栈是否溢出了。相比方法一,这种方法的速度稍慢些,但是这样就有效地避免了方法一里面的部分情况。不过依然不能保证所有的栈溢出都能检测到,比如任务栈末尾的 16 个字节没有用到,即没有被修改,但是任务栈已经溢出了,这种情况是检测不到的。另外任务栈溢出后,任务栈末尾的 16 个字节没有修改,但是溢出部分的栈区数据被修改了,这部分栈区的数据不重要或者暂时没有用到还好,但如果是重要数据被修改将直接导致系统进入硬件异常,这种情况下,栈溢出检测功能也是检测不到的。
使用方法二需要用户在 FreeRTOSConfig.h 文件中配置如下宏定义:
#define configCHECK_FOR_STACK_OVERFLOW 2
除了 FreeRTOS 提供的这两种栈溢出检测机制,还有其它的栈溢出检测机制,大家可以在 Mircrium 官方发布的如下这个博文中学习:https://www.micrium.com/detecting-stack-overflows-part-2-of-2/
2. 任务状态
FreeRTOS的任务状态(4种):1.运行态(Running) 2.就绪态(Ready) 3.阻塞态(Blocked) 4.挂起态(Suspended)
ucos的任务状态(5种):1.睡眠状态 2.就绪状态 3.等待状态 4.中断服务状态 5.执行状态
- Running—运行态
当任务处于实际运行状态被称之为运行态,即 CPU 的使用权被这个任务占用。
- Ready—就绪态
处于就绪态的任务是指那些能够运行(没有被阻塞和挂起),但是当前没有运行的任务,因为同优先 级或更高优先级的任务正在运行。
- Blocked—阻塞态
由于等待信号量,消息队列,事件标志组等而处于的状态被称之为阻塞态,另外任务调用延迟函数也 会处于阻塞态。
- Suspended—挂起态
类似阻塞态,通过调用函数 vTaskSuspend()对指定任务进行挂起,挂起后这个任务将不被执行,只 有调用函数 xTaskResume()才可以将这个任务从挂起态恢复。
3. 任务优先级
3.1任务优先级说明
- FreeRTOS 中任务的最高优先级是通过 FreeRTOSConfig.h 文件中的 configMAX_PRIORITIES 进行配置的,用户实际可以使用的优先级范围是 0 到 configMAX_PRIORITIES – 1。比如我们配置此宏定义为 5,那么用户可以使用的优先级号是 0,1,2,3,4,不包含 5,对于这一点,初学者要特别的注意。
- 用户配置任务的优先级数值越小,那么此任务的优先级越低,空闲任务的优先级是 0。
- 建议用户配置宏定义 configMAX_PRIORITIES 的最大值不要超过 32,即用户任务可以使用的优先级范围是0到31。
3.2 任务优先级分配方案
- IRQ 任务:IRQ 任务是指通过中断服务程序进行触发的任务,此类任务应该设置为所有任务里面优先级最高的。
- 高优先级后台任务:比如按键检测,触摸检测,USB 消息处理,串口消息处理等,都可以归为这一类任务。
- 低优先级的时间片调度任务:比如 emWin 的界面显示,LED 数码管的显示等不需要实时执行的都可以归为这一类任务。实际应用中用户不必拘泥于将这些任务都设置为优先级 1 的同优先级任务,可以设置多个优先级,只需注意这类任务不需要高实时性。
- 空闲任务:空闲任务是系统任务。
特别注意:IRQ 任务和高优先级任务必须设置为阻塞式(调用消息等待或者延迟等函数即可),只有这样,高优先级任务才会释放 CPU 的使用权,,从而低优先级任务才有机会得到执行。这里的优先级分配方案是我们推荐的一种方式,实际项目也可以不采用这种方法。调试出适合项目需求的才是最好的。
3.3 任务优先级与终端优先级的区别
这两个之间没有任何关系,不管中断的优先级是多少,中断的优先级永远高于任何任务的优先级,即任务在执行的过程中,中断来了就开始执行中断服务程序。
另外对于 STM32F103,F407 和 F429 来说,中断优先级的数值越小,优先级越高。而 FreeRTOS的任务优先级是,任务优先级数值越小,任务优先级越低。
4. 任务调度
FreeRTOS就是一款支持多任务运行的实时操作系统,具有时间片、抢占式和合作式三种调度方式。
- 合作式调度,主要用在资源有限的设备上面,现在已经很少使用了。出于这个原因,后面的 FreeRTOS 版本中不会将合作式调度删除掉,但也不会再进行升级了。
- 抢占式调度,每个任务都有不同的优先级,任务会一直运行直到被高优先级任务抢占或者遇到阻塞式的 API 函数,比如 vTaskDelay。
- 时间片调度,每个任务都有相同的优先级,任务会运行固定的时间片个数或者遇到阻塞式的 API 函数,比如vTaskDelay,才会执行同优先级任务之间的任务切换。
4.1 调度器
调度器就是使用相关的调度算法来决定当前需要执行的任务。所有的调度器有一个共同的 特性:
- 调度器可以区分就绪态任务和挂起任务(由于延迟,信号量等待,邮箱等待,事件组等待等原因而使 得任务被挂起)。
- 调度器可以选择就绪态中的一个任务,然后激活它(通过执行这个任务)。当前正在执行的任务是运 行态的任务。
- 不同调度器之间最大的区别就是如何分配就绪态任务间的完成时间。
嵌入式实时操作系统的核心就是调度器和任务切换,调度器的核心就是调度算法。任务切换的实现在不同的嵌 入式实时操作系统中区别不大,基本相同的硬件内核架构,任务切换也是相似的。调度算法就有些区别了。
4.1.1 抢占式调度器
使用了抢占式调度,最高优先级的任务一旦就绪,总能得到 CPU 的控制权。比如,当一个运行着的任务被其它高优先级的任务抢占,当前任务的 CPU 使用权就被剥夺了,或者说被挂起了,那个高优先级的任务立刻得到了 CPU 的控制权并运行。又比如,如果中断服务程序使一个高优先级的任务进入就绪态,中断完成时,被中断的低优先级任务被挂起,优先级高的那个任务开始运行。使用抢占式调度器,使得最高优先级的任务什么时候可以得到 CPU 的控制权并运行是可知的,同时使得任务级响应时间得以最优化。
总的来说,学习抢占式调度要掌握的最关键一点是:每个任务都被分配了不同的优先级,抢占式调度器会获得就绪列表中优先级最高的任务,并运行这个任务。
在 FreeRTOS 的配置文件 FreeRTOSConfig.h 中禁止使用时间片调度,那么每个任务必须配置不同的优先级。当 FreeRTOS 多任务启动执行后,基本会按照如下的方式去执行:
- 首先执行的最高优先级的任务 Task1,Task1 会一直运行直到遇到系统阻塞式的 API 函数,比如延迟,事件标志等待,信号量等待,Task1 任务会被挂起,也就是释放 CPU 的执行权,让低优先级的任务得到执行。
- FreeRTOS 操作系统继续执行任务就绪列表中下一个最高优先级的任务 Task2,Task2 执行过程中有两种情况:
- Task1由于延迟时间到,接收到信号量消息等方面的原因,使得 Task1从挂起状态恢复到就绪态, 在抢占式调度器的作用下,Task2 的执行会被 Task1 抢占。
- Task2 会一直运行直到遇到系统阻塞式的 API 函数,比如延迟,事件标志等待,信号量等待,Task2任务会被挂起,继而执行就绪列表中下一个最高优先级的任务。
- 如果用户创建了多个任务并且采用抢占式调度器的话,基本都是按照上面两条来执行。根据抢占式调度器,当前的任务要么被高优先级任务抢占,要么通过调用阻塞式 API 来释放 CPU 使用权让低优先级任务执行,没有用户任务执行时就执行空闲任务.
4.1.2 时间片调度器
在小型的嵌入式 RTOS 中,最常用的的时间片调度算法就是 Round-robin 调度算法。这种调度算法可以用于抢占式或者合作式的多任务中。另外,时间片调度适合用于不要求任务实时响应的情况。
实现 Round-robin 调度算法需要给同优先级的任务分配一个专门的列表,用于记录当前就绪的任务,并为每个任务分配一个时间片(也就是需要运行的时间长度,时间片用完了就进行任务切换)。
在 FreeRTOS 操作系统中只有同优先级任务才会使用时间片调度,另外还需要用户在FreeRTOSConfig.h 文件中使能宏定义:
#define configUSE_TIME_SLICING 1
默认情况下,此宏定义已经在 FreeRTOS.h 文件里面使能了,用户可以不用在FreeRTOSConfig.h 文件中再单独使能。示例:
- 创建 4 个同优先级任务 Task1,Task2,Task3 和 Task4。
- 先运行任务 Task1,运行够 5 个系统时钟节拍后,通过时间片调度切换到任务 Task2。
- 任务 Task2 运行够 5 个系统时钟节拍后,通过时间片调度切换到任务 Task3。
- 任务 Task3 在运行期间调用了阻塞式 API 函数,调用函数时,虽然 5 个系统时钟节拍的时间片大小还没有用完,此时依然会通过时间片调度切换到下一个任务 Task4。(注意,没有用完的时间片不会再使用,下次任务 Task3 得到执行还是按照 5 个系统时钟节拍运行)
- 任务 Task4 运行够 5 个系统时钟节拍后,通过时间片调度切换到任务 Task1。
5. 临界区、锁与系统时间
5.1 临界区与开关中断
代码的临界临界区,一旦这部分代码开始执行,则不允许任何中断打断。为确保临界区代码的执行不被中断,在进入临界区之前须关中断,而临界区代码执行完毕后,要立即开中断。
FreeRTOS 的源码中有多处临界段的地方, 临界段虽然保护了关键代码的执行不被打断, 但也会影响系统的实时性。比如此时某个任务正在调用系统 API 函数,而且此时中断正好关闭了,也就是进入到了临界区中,这个时候如果有一个紧急的中断事件被触发,这个中断就不能得到及时执行,必须等到中断开启才可以得到执行, 如果关中断时间超过了紧急中断能够容忍的限度, 危害是可想而知的。
FreeRTOS 源码中就有多处临界段的处理,跟 FreeRTOS 一样,uCOS-II 和 uCOS-III 源码中都是有临界段的,而 RTX 的源码中不存在临界段。另外,除了 FreeRTOS 操作系统源码所带的临界段以外,用户写应用的时候也有临界段的问题,比如以下两种:
- 读取或者修改变量(特别是用于任务间通信的全局变量)的代码,一般来说这是最常见的临界代码。
- 调用公共函数的代码,特别是不可重入的函数,如果多个任务都访问这个函数,结果是可想而知的。总之,对于临界段要做到执行时间越短越好,否则会影响系统的实时性。
重入函数的实现特征一般而言,重入函数具有如下特征:
- 函数内部无整个软件生命周期的变量(静态变量)
- 未引用或者访问整个软件生命周期的变量(全局变量)
- 任务代码临界区处理
FreeRTOS 任务代码中临界段的进入和退出主要是通过操作寄存器 basepri 实现的。进入临界段前操作寄存器 basepri 关闭了所有小于等于宏定义configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY所定义的中断优先级,这样临界段代码就不会被中断干扰到,而且实现任务切换功能的 PendSV 中断和滴答定时器中断是最低优先级中断,所以此任务在执行临界段代码期间是不会被其它高优先级任务打断的。退出临界段时重新操作 basepri 寄存器,即打开被关闭的中断(这里我们不考虑不受 FreeRTOS 管理的更高优先级中断)。
- 中断服务程序的临界区处理
与任务代码里临界段的处理方式类似,中断服务程序里面临界段的处理也有一对开关中断函数。
5.2 锁
- 调度锁
调度锁就是 RTOS 提供的调度器开关函数,如果某个任务调用了调度锁开关函数,处于调度锁开和调度锁关之间的代码在执行期间是不会被高优先级的任务抢占的,即任务调度被禁止。这一点要跟临界段的作用区分开,调度锁只是禁止了任务调度,并没有关闭任何中断,中断还是正常执行的。而临界段进行了开关中断操作。调度锁相关函数;
- 任务锁
简单的说,为了防止当前任务的执行被其它高优先级的任务打断而提供的锁机制就是任务锁。
FreeRTOS 也没有专门的任务锁函数,但是使用 FreeRTOS 现有的功能有两种实现方法:
- (1) 通过给调度器加锁实现。利用 FreeRTOS 的调度锁功能给调度器加锁的话,将关闭任务切换功能,从而高优先级任务也就无法抢占低优先级任务的执行,同时高优先级任务也是无法向低优先级任务切换的。另外特别注意,调度锁只是禁止了调度器工作,并没有关闭任何中断。
- (2) 通过关闭任务切换中断 PendSV 和系统时钟节拍中断 Systick利用 FreeRTOS 的任务代码临界段处理函数就可以关闭 PendSV 中断和 Systick 中断。因为进入临界段前,操作寄存器 basepri 关闭了所有小于等于宏定义configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 所定义的中断优先级(实现任务切换功能的 PendSV 中断和滴答定时器中断是最低优先级中断,所以也是被关闭的),这样低优先级任务在执行临界段代码期间是不会被高优先级任务打断的,从而就实现了任务锁的效果。
- 中断锁
中断锁就是 RTOS 提供的开关中断函数,FreeRTOS 没有专门的中断锁函数,使用中断服务程序临界段处理函数就可以实现同样效果。
5.3 FreeRTOS 系统时钟节拍和时间管理
5.3.1 FreeRTOS 时钟节拍
任何操作系统都需要提供一个时钟节拍,以供系统处理诸如延时、 超时等与时间相关的事件。时钟节拍是特定的周期性中断,这个中断可以看做是系统心跳。中断之间的时间间隔取决于不同的应用,一般是 1ms – 100ms。时钟的节拍中断使得内核可以将任务延迟若干个时钟节拍,以及当任务等待事件发生时,提供等待超时等依据。时钟节拍率越快,系统的额外开销就越大。任何操作系统都需要提供一个时钟节拍,以供系统处理诸如延时、 超时等与时间相关的事件。时钟节拍是特定的周期性中断,这个中断可以看做是系统心跳。中断之间的时间间隔取决于不同的应用,一般是 1ms – 100ms。时钟的节拍中断使得内核可以将任务延迟若干个时钟节拍,以及当任务等待事件发生时,提供等待超时等依据。时钟节拍率越快,系统的额外开销就越大。
对于 Cortex-M3 内核的 STM32F103 和 Cortex-M4 内核的 STM32F407 以及 F429,教程配套的例子都是用滴答定时器来实现系统时钟节拍的。
5.3.2 FreeRTOS 时间管理
时间管理功能是 FreeRTOS 操作系统里面最基本的功能,同时也是必须要掌握好的。
- 时间延时
FreeRTOS 中的时间延迟函数主要有以下两个作用:
- 为周期性执行的任务提供延迟。
- 对于抢占式调度器,让高优先级任务可以通过时间延迟函数释放 CPU 使用权,从而让低优先级任务可以得到执行。
通过如下的框图来说明一下延迟函数对任务运行状态的影响,有一个形象的认识。
对任务 Task1 的运行状态做说明,调度器支持时间片调度和抢占式调度。运行过程描述如下:
- 起初任务 Task1 处于运行态,调用 vTaskDelay 函数后进入到阻塞状态,也就是 blocked 状态。
- vTaskDelay 函数设置的延迟时间到,由于任务 Task1 不是当前就绪的最高优先级任务,所以不能进 入到运行状态,只能进入到就绪状态,也就是 ready 状态。
- 一段时间后,调度器发现任务 Task1 是当前就绪的最高优先级任务,从而任务从就绪态切换到运行态。
- 由于时间片调度,任务 Task1 由运行态切换到就绪态。FreeRTOS 时间相关的函数主要有以下 4 个: