【RTOS学习】优先级 | Tick | 任务状态 | 空闲任务 | 任务调度

🐱作者:一只大喵咪1201

🐱专栏:《RTOS学习》

🔥格言:你只管努力,剩下的交给时间!

优先级 | Tick | 任务状态 | 空闲任务 | 任务调度

🏀优先级

如上图,在使用xTaskCreate创建任务的时候,需要传入的参数中有一个uxPriority是用来指定优先级的。

相同优先级:

如上图,定义三个任务函数,每隔函数中有一个while(1)死循环,还有三个任务执行标志,执行哪个任务,就将对应的标志置一,其他置0。

如上图,动态创建三个任务让去执行上面对应的三个函数,这三个任务的优先级都是1,看看开始调度以后会发生什么。

如上图,可以三个任务在交替执行,每一个任务执行一段时间就换下一个任务,同一时刻CPU只能执行一个任务。

  • 相同优先级的任务,采用时间片轮转的方式执行固定时间。

不同优先级:

如上图,给任务1和任务2的优先级设置为2,任务3的优先级设置为1,从优先级上任务1和任务2的优先级就比任务3的优先级高。

如上图,此时任务1和任务2在轮替执行,任务3始终就没有得到执行的机会。

再做一个实验:

如上图,当任务1在执行一段时间后,在任务1中创建任务2,切任务2的优先级为3,比任务1的优先级还高:

如上图,由于任务1的优先级是2,任务3的优先级是3,所以开始执行后,任务1在执行,执行了一段时间后创建了任务2,优先级是3,之后就一直在执行任务3,任务2以及任务1都得不到执行。

  • 高优先级的任务会始终抢占低优先级的任务,让低优先级的任务得不到执行。

⚽任务管理

不同优先级的任务是如何被管理的,FreeRTOS又是如何找判断让哪个优先级的任务先执行的呢?

如上图所示,对于就绪任务,FreeRTOS维护着一个数组,每个元素是一个链表的链表头,每一个链表头指向的链表中存放的都是任务的TCB节点,并且优先级是相同的。

数组的下标代表着不同的优先级,从而维护着不同的链表,进而控制着不同优先级的任务被CPU调度。

如上图,在FreeRTOSConfig.h头文件中定义了最大优先级,该值是5,上面的数组就有5个元素,维护着5个链表,决定了任务的最大优先级只能是4。


调度器启动以后:

  • 会先从数组下标最大位置的链表头开始遍历里面的TCB节点,如果链表为空,则数组下标减一,继续遍历对应链表中的TCB节点。

  • 当链表不为空时,会让CPU挨个执行链表中每一个TCB节点指向的任务,每个任务执行的时间是固定的,时间一到就切换下一个任务,并且将执行过的任务插到链表的尾部。

  • 所以就会始终都在执行这个链表中的任务,优先级低链表中的任务就得不到执行。

🏀Tick

对于同优先级的任务,它们"轮流"执行,你执行一会,我执行一会,那么这个"一会儿"是如何定义的呢?

人有心跳,心跳间隔基本恒定,FreeRTOS 中也有心跳,它使用定时器产生固定间隔的中断,这叫 Tick(滴答),比如每 1ms 发生一次时钟中断。

如上图,假设 t1、t2、t3 时刻发生时钟中断,两次中断之间的时间被称为时间片(time slice、tick period) 。

时间片的长度由 configTICK_RATE_HZ 决定,configTICK_RATE_HZ 为1000,那么时间片长度就是 1ms。


相同优先级的任务怎么切换呢?

如上图相同优先级任务切换过程,任务2从t1 执行到t2,在t2时刻发生tick中断,进入tick中断处理函数,在中断函数中选择下一个要运行的任务,执行完中断处理函数后,切换到新的任务------任务 1。

任务1从t2执行到t3,再次发生tick中断,继续切换任务,如此往复,从图中可以看出,任务运行的时间并不是严格从 t1,t2,t3 哪里开始,这是因为中断函数中选择任务也是需要耗费一定时间的。

⚽延时

有了Tick的概念后,我们就可以使用Tick来衡量时间了:

如上图所示vTaskDelay函数,可以用来延时,如vTaskDelay(2),表示延时两个Tick,如果configTICK_RATE_HZ == 1000,那么延时的时间就是2ms。

还可以使用宏pdMS_TO_TICKS把ms转换成tick,如vTaskDelay(pdMS_TO_TICKS(100)),此时就会延时100ms。

但是,基于Tick实现的延时并不精确,比如vTaskDelay(2)的本意是延迟 2 个Tick周期,有可能经过1 个Tick多一点就返回了。

  • 使用vTaskDelay函数时,建议以ms为单位,使用pdMS_TO_TICKS把时间转换为Tick
  • 这样的代码就与configTICK_RATE_HZ无关,即使配置项configTICK_RATE_HZ改变了,我们也不用去修改代码。

有两个 Delay 函数:

  • vTaskDelay:至少等待指定个数的 Tick Interrupt 才能变为就绪状态

  • vTaskDelayUntil:等待到指定的绝对时刻,才能变为就绪态。

vTaskDelay

该函数前面就讲解了它的使用,这里我们来看演示效果:

如上图代码,创建两个任务,任务1的优先级是2,任务2的优先级是1,调度开始后,任务1会一直执行,除非主动放弃CPU资源,否则任务2不会得到执行。

  • 主动延时就是放弃CPU资源的一种方式。

在任务1执行的函数中,在while(1)循环中还有一个for循环,该循环执行完一遍所用的次数不同,也就是所用时间不同,这里本喵就是构造了一个执行时间在不断变化的场景。

当执行完for循环后,任务1就调用vTaskDelay主动延时5个tick,按照本喵这里的配置也就是5ms,此时任务2就得到了执行机会,待5ms过后,任务1又重新抢占了CPU资源。

如上图所示,红色线条每次处于高电平的时间不同,说明每次执行for循环的时间不一样,但是for循环执行完毕后的延时时间都是5ms,如上图两个黑色线条之间的距离是一样的(忽略误差)。

也就是说,任务1从主动让出CPU资源到下一次执行的时间间隔是一样的,都是5ms。如果将执行for循环和延时的5ms都算作任务1的一次执行周期,如上图蓝色线条之间的t1和t2,那么这里两个执行周期就不相同。

如果要让这样一个任务的执行时间和延时时间加起来每次都是一个固定值的话,vTaskDelay显然是做不到的。

vTaskDelayUntil

如上图所示xTaskDelayUntil,该函数也是一个延时函数,它需要两个参数,一个是调用该函数时刻的tick1值,另一个是参数是在该时刻基础上延时多少个Tick

如上图,在任务1开始被执行的时候,使用xTaskGetTickCount函数获取当前的Tick值,在执行完for循环以后,使用vTaskDelayUntil在前面的Tick基础上延时20个tick

如上图所示,此时任务1两次被执行的事件间隔就完全一样了,都是20ms,虽然每次执行的时间不一样,但是延时的时间也不一样,但任务1的执行周期是固定的。

🏀任务状态

以前我们很简单地把任务的状态分为2种,运行(Runing)、非运行(Not Running),就比如相同优先级的两个任务,被CPU执行的那个处于运行状态,而另一个则处于非运行状态。

调用了vTaskDelay的任务也处于非运行状态,这两种非运行状态有什么区别呢?任务一共存在多少种状态呢?

  • 就绪状态
  • 阻塞状态:
  • 暂停状态:也被叫做挂起状态。

就绪状态(Ready):

这个任务完全准备好了,随时可以运行,只是还轮不到它。这时,它就处于就绪态,它的TCB节点处于FreeRTOS维护的那个数组中的链表中。

阻塞状态(Blocked):

继续使用上篇文章认识RTOS中母亲喂饭的例子,母亲在电脑前跟同事沟通时,如果同事一直没回复,那么母亲的工作就被卡住了、被堵住了、处于阻塞状态(Blocked)。

  • 重点在于:母亲在等待。

在前面优先级的实验中,如果一个任务的优先级比其他任务的优先级高,那么其他任务根本就没有执行的机会,其他任务就是被"饿死"。

在实际产品中,我们不会让一个任务一直运行,而是使用"事件驱动"的方法让它运行:

  • 任务要等待某个事件,事件发生后它才能运行,比如延时到了。
  • 在等待事件过程中,它不消耗 CPU 资源。

在等待事件的过程中,这个任务就处于阻塞状态(Blocked),在阻塞状态的任务,可以等待两种类型的事件:

  • 时间相关的事件:

  • 可以等待一段时间,如等 2 分钟。

  • 也可以一直等待,直到某个绝对时间,如等到下午 3 点。

  • 同步事件:这事件由别的任务,或者是中断程序产生。

  • 任务 A 等待任务 B 给它发送数据

  • 任务 A 等待用户按下按键

在等待一个同步事件时,可以加上超时时间。比如等待队里数据到来,超时时间设为10ms:

  • 10ms 之内有数据到来:成功返回
  • 10ms 到了,还是没有数据:超时返回

FreeRTOS又是如何管理处于阻塞状态的任务呢?

如上图,一个任务调用了xTaskDelay以后就会处于阻塞状态,而在延时函数内部,会使用prvAddCurrenTaskToDelayedList函数将该任务添加到一个阻塞链表中。

  • 阻塞链表中存放的都是处于阻塞状态的TCB节点。

FreeRTOS也维护着上图示意这样一个阻塞链表,处于阻塞状态的任务TCB节点都放在这个链表中,FreeRTOS会不断检测这个链表中任务的状态,一旦某个任务等待的事件发生了,就将其设置为就绪状态,并且根据优先级放入到对应的就绪链表中。

暂停(挂起)状态:

FreeRTOS 中的任务也可以进入暂停状态,唯一的方法是通过vTaskSuspend函数:

如上图,参数 xTaskToSuspend 表示要暂停的任务,如果为 NULL,表示暂停自己。也可以指定让其他任务暂停。

要退出暂停状态只能由别人(其他任务)来操作:

  • 别的任务调用vTaskResume来唤醒某个任务,还可以调用vTaskResumeAll来唤醒所有暂停的任务。
  • 中断程序调用xTaskResumeFromISR唤醒某个任务

如上图代码所示,任务1的优先级是2,任务2的优先级是1,任务1在执行了5个Tcik以后将自己挂起,此时任务2可以执行了,执行5个Tick后再将任务1唤醒。

如上图所示,程序的运行结果和我们分析的一致,但是实际开发中,暂停状态用得不多。

  • 阻塞状态和挂起状态的区别在于,阻塞状态是在等某个事件就绪,而挂起状态则是单纯的要休息,没有其他目的。

上图代码中,在调用vTaskSuspend时,在函数内部会调用vListInsertEnd将被挂起任务的TCB节点插入到挂起状态的链表中。

如上图所示,FreeRTOS还维护着一个暂停链表,处于暂停状态任务的TCB节点就尾插到这个链表中,当某个任务被唤醒后就将其状态改成就绪状态并插入到对应优先级的就绪链表中。

⚽状态转换

如上图所示是一个完整的任务状态转换示意图,任务的默认状态是Ready状态,位于就绪链表中,当该链表中的任务被CPU执行时,就处于Running状态,执行完毕后又变成了Ready状态,这两个状态是不停转换的。

无论是处于Ready状态还是Running状态的任务,在自己或者他人调用vTaskSuspend函数后,都会变成Suspend状态,处于暂停链表中,只有其他任务调用vTaskResume函数才能将其唤醒并重新添加到就绪链表中。

处于Running状态的任务在处理延时或者其他等待场景时会处于Blicked状态,处于阻塞队链表,直到等待的事件到来才会被重新添加到就绪链表中处于Ready状态。

  • 任务处于不同状态的本质:位于不同类型的链表中

🏀空闲任务

如上图所示,创建了两个任务,优先级都是1,调度开始后,哪个任务先开始执行呢?

如上图所示,可以看到,任务2的执行标志先被置一,说明任务2先被执行:

如上图代码所示,在我们创建新任务时xTaskCreate内部会调用prvAddNewTaskToReadyList函数将新任务添加到就绪链表中。

如上图红色框中,pxNewTCB是新任务的TCB,pxCurrentTCB是当前正在执行的任务的TCB,它会判断新任务和当前任务的优先级,如果新任务的优先级大于等于当前任务,那么就将新任务作为当前任务去执行。

  • 相同优先级的任务,后创建的先执行。

如上图,此时将两个任务的优先级都改成0:

可以看到,此时任务1的执行标志位先被置一,说明任务1先执行了,明明任务2后创建的啊,怎么不是任务2先执行呢?关键就在于将两个任务的优先级该成了0。

  • 说明还有一个任务比任务2更晚创建,这个任务就是空闲任务。

如上图,调度器开始运行以后,无论是是否支持静态创建任务,都会先创建一个空闲任务,所以空闲任务应该是最晚创建的。

  • 为了让空闲任务不影响用户任务,空闲任务的优先级是0,是最低优先级。
  • 空闲任务要么处于就绪状态,要么处于运行状态,拥有不会阻塞。

空闲任务会插入到优先级为0的就绪链表末尾,让CPU执行空闲任务pxCurrentTCB,所以当我们创建的两个任务优先级是0时,调度器创建的空闲任务就会和我们的任务竞争CPU资源,会让它先执行。

⚽钩子函数

我们可以添加一个空闲任务的钩子函数(Idle Task Hook Functions),空闲任务的循环每执行一次,就会调用一次钩子函数。钩子函数的作用有这些:

  • 执行一些低优先级的、后台的、需要连续执行的函数。
  • 测量系统的空闲时间:空闲任务能被执行就意味着所有的高优先级任务都停止了,所以测量空闲任务占据的时间,就可以算出处理器占用率。
  • 让系统进入省电模式:空闲任务能被执行就意味着没有重要的事情要做,当然可以进入省电模式了。
  • 钩子函数的限制:不能导致空闲任务进入阻塞状态、暂停状态。

如上图,在portTASK_FUNCTION中,如果定义了宏configUSE_IDLE_HOOK,则会调用钩子函数vApplicationIdleHook,前提是钩子函数已经被定义了,否则就会出错。

当调度器启动,空闲任务被创建后,如果打开了钩子函数的宏开关并且定义了钩子函数,那么空闲任务每执行一次就会调用一次钩子函数。

如上图,首先将钩子函数的宏开关打开,然后再定义钩子函数:

如上图,定义钩子函数vAppliactionIdleHook,再增加一个空闲任务执行标志,到执行钩子函数的时候将该标志位置一,创建的任务1和任务2优先级都是0,和空闲函数一个等级。

如上图,此时任务1和任务2以及空闲任务轮替执行。


⚽空闲任务作用

那么空闲任务有什么作用呢?

如上图代码所示,先创建一个任务1,优先级是1,在任务1中创建任务2,指定栈大小是1024word,判断是否创建成功,失败则打印失败信息,成功则删除任务2。

如上图可以看到,此时任务1在不断执行,任务2由于一被创建就进入阻塞状态,紧接着就被删除了,所以没有执行的机会。

我们知道,一个任务在创建时,不仅会创建TCB结构体对象,还会开辟独立栈,这里任务1的独立栈一次开辟1024word,可以说很大了。

当任务2被删除以后,如果仅仅是从就绪链表中移除了TCB节点,那么它的TCB结构体对象和独立栈仍然会存在,此时无休止的创建任务2,再删除就会积累下越来越多的TCB结构体和独立栈,内存空间就会不够,任务创建就会失败。

  • 一个任务删除另一个任务时,会顺带回收被删除任务的资源,包括TCB结构体对象和独立栈。

所以上面的程序可以一直在运行而没有发生创建失败的错误。


如上图所示,对之前的程序稍作修改,任务1创建任务2以后不删除任务2,而是由任务2自己删除自己,也就是自杀。

如上图所示,任务2被创建成功以后就立刻被删除了,它是自杀的,所以它自己的资源,包括TCB结构体对象和独立栈没有人给它回收,所以就逐渐导致内存不足了,任务1创建任务2就失败了。

这个过程中,由于任务1和创建的任务2优先级都是1,所以空闲任务无法执行。


如上图,现在仅将任务1的优先级改为0,让空闲任务有机会运行。

如上图,为了感受到空闲任务在执行,将空闲任务标志位也添加到逻辑分析仪中,可以看到,三个任务在轮替执行,并没有因为任务2自杀没有回收自己的TCB结构体对象和独立栈资源而导致内存不足无法创建任务2。

  • 对于自杀的任务,它的TCB结构体对象以及独立栈资源由空闲任务回收。

所以说,空闲任务最大的作用就是回收自杀任务所遗留下的资源

🏀任务调度

这些知识在前面都提到过了,这里总结一下。

正在运行的任务,被称为"正在使用处理器",它处于运行状态。在单处理系统中,任何时间里只能有一个任务处于运行状态。

非运行状态的任务,它处于这 3 中状态之一:阻塞(Blocked)、暂停(Suspended)、就绪(Ready)。就绪态的任务,可以被调度器挑选出来切换为运行状态,调度器永远都是挑选最高优先级的就绪态任务并让它进入运行状态。

阻塞状态的任务,它在等待"事件",当事件发生时任务就会进入就绪状态。事件分为两类:时间相关的事件、同步事件。所谓时间相关的事件,就是设置超时时间:

  • 在指定时间内阻塞,时间到了就进入就绪状态。使用时间相关的事件,可以实现周期性的功能、可以实现超时功能。
  • 同步事件就是:某个任务在等待某些信息,别的任务或者中断服务程序会给它发送信息。

怎么"发送信息"?方法很多,有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)等。这些方法用来发送同步信息,比如表示某个外设得到了数据。

⚽配置调度算法

所谓调度算法,就是已经就绪的任务通过什么方式切换为运行状态,通过配置文件FreeRTOSConfig.h的两个配置项来配置调度算法:

  • configUSE_PREEMPTION:高优先级任务能否抢占低优先级任务。
  • configUSE_TIME_SLICING:是否支持时间片轮转

调度算法的行为主要体现在两方面:

  • 高优先级的任务是否先运行。
  • 同优先级的就绪态任务如何被选中。

可否抢占:

如上图,将configUSE_PREEMPTION宏定义为0,表示高优先级任务不可抢占低优先级任务,默认情况下是1,表示可抢占。

如上图代码,在main函数中创建任务1,优先级是1,任务1开始执行以后,在20个Tick后创建任务2,优先级是2,并且打印两次任务2创建成功的消息。

按照之前的情况,任务2的优先级比任务1的优先级高,任务2一经创建就会抢占任务1。

如上图,此时仍然是任务1在执行,并且成功打印了任务创建的信息,虽然任务2的优先级比任务1高,但是它无法抢占任务1优先执行。

  • 这种不能抢占的方式被叫做合作调度模式

当前任务执行时,更高优先级的任务就绪了也不能马上运行,只能等待当前任务主动让出 CPU 资源,其他同优先级的任务也只能等待,更高优先级的任务都不能抢占,平级的更应该老实点,所以在执行的任务也应该自觉点,执行一段时间后主动让出CPU资源。

是否轮流执行:

如上图,在task.c里的xTaskIncrementTick函数中,只有定义了可抢占的宏以后,是否时间片轮转才有意义,所以先恢复可抢占模式。

如上图,恢复到可抢占模式,定义configUSE_TIME_SLICING宏为0,表示不允许时间片轮转。

如上图,创建两个任务,优先级都是0,再加上调度器创建的空闲任务,三个任务本应该是轮替执行的。

如上图所示,实际上只有任务1在一直执行,并没有发生时间片轮转,任务2和空闲任务没有被执行。

是否礼让:

所谓礼让是指空闲任务,如果礼让则空闲任务很快就让出CPU资源,不礼让则是空闲任务也要执行一个时间片才让出CPU资源。

考虑是否礼让的前提:

  • 支持抢占
  • 支持时间片轮转

如上图,设置为支持抢占,支持时间片轮转,再设置configIDLE_SHOULD_YIELD宏,默认情况下就是1,也就是支持礼让模式。

如上图,此时空闲任务在执行一次以后,会调用taskYIELD主动发起一次调度,主动让出CPU资源。

如上图,创建两个任务,优先级为0,同时也定义了钩子函数。

如上图,此时可以看到,空闲任务也在执行,但是相比于任务1和任务2,空闲任务执行的时间非常短,这是因为空闲任务每执行一次以后就会发起一次调度,将CPU资源让出来,好调度其他用户任务来执行。


如上图,更改配置,让空闲任务不礼让。

如上图,代码仍然是上面代码,此时空闲任务执行的时间和用户任务1以及任务2一样,也是一个时间片,这是因为此时空闲任务不再主动发起调度了,而是由Tick中断进行调度。

  • 此时空闲任务和用户任务的地位是相等的。

将调度策略总结如下表:

配置项 A B C D E
configUSE_PREEMPTION 1 1 1 1 0
configUSE_TIME_SLICING 1 1 0 0 x
configIDLE_SHOULD_YIELD 1 0 1 0 x
说明 常用 很少用 很少用 很少用 几乎不用

注:

  • A:可抢占+时间片轮转+空闲任务让步
  • B:可抢占+时间片轮转+空闲任务不让步
  • C:可抢占+非时间片轮转+空闲任务让步
  • D:可抢占+非时间片轮转+空闲任务不让步
  • E:合作调度

⚽保护现场

如上图代码,有一个加法函数add_val,假设此时有多个任务会调用这个加法函数,先拿其中一个调用的汇编代码来看:

如上图,在执行该函数过程中会操作很多寄存器,假设任务1在执行到蓝色线条位置时被切换走了,CPU开始执行任务2了。

而任务2也会用到这些寄存器,那此时任务1辛辛苦苦写入到寄存器中的值就会被覆盖了,当任务1再被切换回来的时候,它发现整个世界都变了。

  • 所以在任务被切换走的一瞬间,CPU中所有寄存器中的值都会被保存下来,这被称为保护现场
  • 当任务被切换回来的时候,会将保存下来的值再恢复到寄存器中,继续从被切换走的位置接着执行,这被称为恢复现场

程序计数器(pc)实际上就是寄存器r15,所以被切走时,该寄存器会记录此时执行的位置,恢复执行后可以接着执行。


保护现场时,寄存器中的值存放在哪里呢?

如上图,我们知道,每一个任务都有一个句柄,通过该句柄可以找到该任务在内存中的TCB结构体对象,又可以通过结构体中的栈指针找到任务所对应的独立栈结构。

  • 任务的本质就是内存中的TCB结构体对象和独立栈结构。

当任务被切换时,在切换的那一瞬间,CPU中的所有寄存器值都会被保存到该任务的独立栈结构中,当该任务再次被切换回来的时候,回去它的独立栈中将这些值恢复到寄存器中,接着被打断的位置继续执行。

🏀总结

优先级以及任务状态是任何一款操作系统中的重点,FreeRTOS也不例外,只有掌握了这两点,才能更好的使用操作系统。同时要意识到操作系统维护着多种类型的链表,不同类型的链表中放着不同类型的任务节点。

相关推荐
日晨难再2 分钟前
嵌入式:STM32的启动(Startup)文件解析
stm32·单片机·嵌入式硬件
CV学术叫叫兽11 分钟前
一站式学习:害虫识别与分类图像分割
学习·分类·数据挖掘
我们的五年22 分钟前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
一棵开花的树,枝芽无限靠近你1 小时前
【PPTist】添加PPT模版
前端·学习·编辑器·html
VertexGeek1 小时前
Rust学习(八):异常处理和宏编程:
学习·算法·rust
二进制_博客2 小时前
Flink学习连载文章4-flink中的各种转换操作
大数据·学习·flink
codebolt2 小时前
ADS学习记录
学习
qystca2 小时前
洛谷 B3637 最长上升子序列 C语言 记忆化搜索->‘正序‘dp
c语言·开发语言·算法
Komorebi.py3 小时前
【Linux】-学习笔记05
linux·笔记·学习
网易独家音乐人Mike Zhou8 小时前
【卡尔曼滤波】数据预测Prediction观测器的理论推导及应用 C语言、Python实现(Kalman Filter)
c语言·python·单片机·物联网·算法·嵌入式·iot