✅作者简介:嵌入式入坑者,与大家一起加油,希望文章能够帮助各位!!!!
📃个人主页:@rivencode的个人主页
🔥系列专栏:玩转FreeRTOS
💬保持学习、保持热爱、认真分享、一起进步!!

前言

本文将详细阐述FreeRTOS多优先级的实现,时间片的轮转,以及深入理解任务在就绪态与阻塞态如何相互转换,这一切的本质都基于链表,然后在加上一些简单的算法,只要掌握了链表一切都不是什么大问题。

《FreeRTOS-链表的源码解析》

《FreeRTOS-实现任务调度器》

一.实现多优先级与时间片轮转

1.怎样实现多优先级

我们知道中断会有优先级之分,优先级高的可以抢占优先级低的,任务同样如此,不过再怎么说任务说白了只是我们写的一个函数,最高优先级的任务还是会被最低优先级的中断打断(这里顺便提一下),但任务是如何实现多优先级的呢,其实FreeRTOS的做法很简单创建完任务之后,会根据任务的优先级去将任务挂入不同优先级的链表,而这些链表其实就是一个链表数组,数组的每个元素是一个链表,而数组元素的下标对应的就是任务的优先级。
在这里插入图片描述
在这里插入图片描述
任务在创建的时候,会根据任务的优先级将任务插入到就绪列表不同的位置(数组下标对应任务的优先级)。相同优先级的任务插入到就绪列表里面的同一条链表中。

2.寻找最高优先级任务

既然实现了任务的多优先级,自然是优先级高的任务先执行,也就是说在切换任务时我们需要去就绪链表(就绪链表数组里面),寻找最高优先级的任务去执行,任务切换过程请参考《FreeRTOS-实现任务调度器》
任务切换过程调用vTaskSwitchContext函数将这个任务控制块(TCB)的指针pxCurrenTCB去指向下一个任务然后切换到下一个任务,我们只需要将pxCurrenTCB去指向最高优先级的任务即可,即每次在切换任务的时候在链表数组中寻找最高优先级的任务赋给pxCurrenTCB,然后确保运行的任务是优先级最高的。

如何寻找到最高优先级的就绪任务的 TCB。FreeRTOS 提供了两套方法,一套是通用的,一套是根据特定的处理器优化过的,接下来我们重点讲解下这两个方法。
在这里插入图片描述
这两种方法实现了两个"宏函数",它们的宏名一样但是实现的内容不一样,configUSE_PORT_OPTIMISED_TASK_SELECTION 这个宏控制,定义为 0 选择通用方法(编译通用方法实现的"宏函数"),定义为 1 选择根据处理器优化的方法(编译优化的方法实现的"宏函数"),该宏默认portmacro.h 中定义为 1使用优化的方法来寻找最高优先级的任务。

接下来就是详细讲解这两种方式是如何实现,以及FreeRTOS是如何兼容两套方式,以及两个方法的优缺点

1)通用方法

在这里插入图片描述
接下来一个一个宏函数来分析

  • 1.taskRECORD_READY_PRIORITY()

在这里插入图片描述
uxTopReadyPriority 是一个在 task.c 中定义的静态变量,用于表示创建的任务的最高优先级,默认初始化为 0,即空闲任务的优先级,空闲任务顾名思义就是CPU空闲时运行的任务它负责一些内存的清理工作,确保系统一直有任务在运行,空闲任务与其他任务的创建和初始化是一样的只不过任务优先级为最低为0,后面会出一篇文章专门阐述空闲任务的作用。
在这里插入图片描述

taskRECORD_READY_PRIORITY()这个"宏函数"用于创建任务,在添加任务至就绪链表之前去更新uxTopReadyPriority的值,确保任务创建完毕之后,uxTopReadyPriority的值表示是这些创建的任务里面最高优先级
在这里插入图片描述

  • 2.taskSELECT_HIGHEST_PRIORITY_TASK()

在这里插入图片描述
taskSELECT_HIGHEST_PRIORITY_TASK()的作用:从最高优先级对应的就绪列表数组下标开始寻找当前链表下是否有任务存在,如果没有,则 uxTopPriority 减一操作,继续寻找下一个优先级对应的链表中是否有任务存在,如果有则跳出 while 循环,表示找到了最高优先级对应的链表(链表中可能会有很多相同优先级的任务),然后链表中取出一个任务执行,这样就引申出一个问题该链表中的任务优先级都是一样的,该取那个任务运行,怎么取?
在这里插入图片描述
看完上面这个取最高优先级的任务的流程,你可能大概理解了,但是里面有很多更深层的细节,接下来就引申出几个问题来彻底理解这个"宏函数"。

1.创建完任务的时候uxTopReadyPriority不是已经更新为这些任务中最高优先级了嘛,为啥还要去寻找最高优先级的链表然后取一个任务运行。

原因就是最高优先级的任务可能会阻塞,而任务一旦阻塞任务就会从就绪链表中删除,插入延时链表(这个后面会讲到),因为最高优先级的任务一旦阻塞可能最高优先级的链表就会为空(当然链表只有一个任务的情况下),所以此时最高优先级的任务就要换成其它任务。

2.下面这个"宏函数"就是支持时间片的关键。
在这里插入图片描述

支持时间片轮转

所谓时间片就是同一个优先级链表中可以有多个任务(每个任务的优先级相同),每个任务轮流地享有相同的 CPU 时间,享有 CPU 的时间我们叫时间片。

在 RTOS 中,最小的时间单位为一个 tick,即 SysTick 的中断周期,
RT-Thread 和 μC/OS 可以指定时间片的大小为多个 tick,但是 FreeRTOS 不一样,时间片只能是一个 tick。

也就是说FreeRTOS所以任务最多只能执行一个tick的时间,不过可以指定一个tick的大小(改变SysTick 的中断周期就行了),但是其它的RT-Thread 和 μC/OS 操作系统,可以支持不同任务运行不同的tick数,而且一个tick的大小也能指定,所以与其说 FreeRTOS 支持时间片,倒不如说它的时间片就是正常的任务调度。

在FreeRTOS 时间片的本质就是:相同优先级的任务轮流执行一个tick的时间

在这里插入图片描述
当链表为使用时pxIndex指向链表结构体中的头结点
在这里插入图片描述

下面就是三个同优先级任务轮流的运行的示例:

在这里插入图片描述
总结过程:
假设有三个相同任务的优先级的任务1,2,3,而且他们的优先级是链表数组中最高的,不然也执行不了,首先就是寻找到,他们寻找到他们所在的链表(也就是前面寻找最高优先级(就是链表数组的下标)),然后在链表中取出一个任务执行,第一次任务切换取出的是任务1,第二次取出的是任务2,第二次取出的是任务3,第三次取出的是任务1…,三个任务是轮流执行一个tick的时间。

3.为什么最后要更新一下uxTopReadyPriority?
在这里插入图片描述
下面这段也非常重要:
在这里插入图片描述
taskRECORD_READY_PRIORITY()又起了关键作用

最后两个宏在通用方法中并没有用到,实现为空
在这里插入图片描述

说了这么多,但是在代码中如何体现呢,如何这些"宏函数"又是在哪里被调用呢,简单梳理一下咯。

首先肯定要在任务控制块TCB中添加任务优先级这一成员变量
在这里插入图片描述
xTaskCreateStatic()函数
在这里插入图片描述
prvInitialiseNewTask()函数
在这里插入图片描述
prvAddNewTaskToReadyList()函数是关键
在这里插入图片描述

其他的看注释就好,主要看下面这段代码:
在这里插入图片描述
这段代码的作用是:创建完任务插入就绪链表之前,确保pxCurrentTCB指向的是最高优先级的任务这样一来,启动第一个任务时能确保是启动最高优先级的任务,如果是同优先级的任务:假设按照顺序任务1,2,3优先级相同的任务插入,则pxCurrentTCB指向任务3,因为新任务<=当前任务的优先级就将新任务赋给pxCurrentTCB,则执行的顺序是任务3 任务1 任务2 任务3 …
在这里插入图片描述
最后将任务插入就绪链表:

在这里插入图片描述
关于几个链表操作的函数一定要心中有数:

《FreeRTOS-链表的源码解析》

vTaskSwitchContext()函数
在这里插入图片描述
在任务切换过程中会调用是直接调用vTaskSwitchContext()函数,vTaskSwitchContext()函数里面再调用taskSELECT_HIGHEST_PRIORITY_TASK()寻找到优先级最高的就绪任务的 TCB,然后更新到 pxCurrentTCB,然后切换任务(切换到pxCurrentTCB指向的任务)

所以vTaskSwitchContext()函数的核心就是寻找就绪列表中优先级最高的任务,然后赋给pxCurrentTCB

2)优化方法

寻找最高优先级的任务,首先需要找到最高优先级的链表(就绪链表的下标),所以说寻找最高优先级的任务的本质是去寻找最高优先级(链表的下标)

优化的方法,这得益于于 Cortex-M 内核有一个计算前导零的指令CLZ,所谓前导零就是计算一个变量(Cortex-M 内核单片机的变量为 32 位)从高位开始第一次出现 1 的位的前面的零的个数。

比如:一个 32 位的变量 uxTopReadyPriority,其位 0、位 24 和 位 25 均 置 1 ,其余位为 0 , 具体见。 那么使用前导零指令 __CLZ (uxTopReadyPriority)可以很快的计算出 uxTopReadyPriority 的前导零的个数为 6
在这里插入图片描述
现在uxTopReadyPriority 是一个32位的变量,uxTopReadyPriority变量的每个位对应的是就绪链表的下标(也就是任务的优先级),0~ 31位对应0~31优先级,任务就绪时,则将对应的位置 1(代表该优先级的链表不为空),如果有任务阻塞时需要判断该优先级的链表是否为空,如果为空则将对应的位清零。
下图表示优先级 0、优先级 24 和优先级 25 三个优先级链表不为空,其中优先级为 25 的任务优先级最高。利用前导零计算指令可以很快计算出就绪任务中的最高优先级就绪链表下标为:
( 31UL - ( uint32_t ) __clz( ( uxReadyPriorities ) ) ) = ( 31UL - ( uint32_t ) 6 ) = 25。

在这里插入图片描述
这样就能利用 Cortex-M 内核有一个计算前导零的指令CLZ快速算出最高优先级(最高优先级的链表下标).

在这里插入图片描述

  • 1.taskRECORD_READY_PRIORITY()
    在这里插入图片描述
    将变量 uxTopReadyPriority 的某个位置 1表示该位对应的优先级链表不为空。uxTopReadyPriority 是一个在 task.c 中定义的静态变量,默认初始化为 0。
  • 2.taskRESET_READY_PRIORITY()
支持时间片轮转

在这里插入图片描述
第二个支持时间片的关键已经在通用方法中讲解了,是一模一样的
在这里插入图片描述
3.taskSELECT_HIGHEST_PRIORITY_TASK()

taskSELECT_HIGHEST_PRIORITY_TASK()与通用方式一样用于从链表数组寻找优先级最高的任务就绪链表下标(链表不为空),只不过是利用这样就能利用 Cortex-M 内核有一个计算前导零的指令CLZ快速算出最高优先级(最高优先级的就绪链表下标),然后去更新pxCurrentTCB(指向最高优先级的任务) 的值。
在这里插入图片描述

说了这么多,但是在代码中如何体现呢,如何这些"宏函数"又是在哪里被调用呢,基本上与通用方法是一样的,只不过有用到taskRECORD_READY_PRIORITY()
taskRESET_READY_PRIORITY()
taskSELECT_HIGHEST_PRIORITY_TASK()
都换成优化方法实现的方式

任务插入就绪链表时:

在这里插入图片描述
任务阻塞时:
在这里插入图片描述
最后就是vTaskSwitchContext里面换成优化方法试实现的taskSELECT_HIGHEST_PRIORITY_TASK()

3.通用方式与优化方法对比

  • 通用方式

没有优化—配置宏定义configUSE_PORT_OPTIMISED_TASK_SELECTION 为 0:
优点:
1.在所有平台中都可以使用通用方式,因为支持C语言就可以了。
2.可用的优先级数量不限制,最大为255个优先级
缺点:
1.由纯C语言编写,比优化方式效率低。

  • 优化方式

进行了优化—配置宏定义 configUSE_PORT_OPTIMISED_TASK_SELECTION 为为 1:
优点:
1.有些平台架构有专用的汇编指令,比如 CLZ(Count Leading Zeros)指令,通过这些指令可以加速算法执行速度。比通用方式高效。
缺点
1.部分平台支持,没有专用的汇编指令,比如 CLZ(Count Leading Zeros)的平台就不兼容咯
2.有最大优先级数限制,通常限制为 32 个(优先级0 ~ 31)因为一般是32位的平台,变量最大为32位。

总结:通用方法的优先级数量比优化方式多,平台兼容性好,但是比优化方式的效率要差。

二.实现延时链表与任务阻塞

1.任务阻塞与延时链表的作用

  • 什么是任务阻塞?

RreeRTOS的对任务的阻塞延时是vTaskDelay ()函数实现的,当任务调用该函数时,任务会被剥离 CPU 使用权,也就是停止运行,该任务会从就绪链表移除并挂入一个延时链表中,并发起一次任务调度(任务切换),再任务阻塞期间,CPU 可以去执行其它的任务,直到延时结束,任务从延时链表移除重新挂入一个就绪链表,然后再获取 CPU 使用权才可以继续运行

上面只是一个大概的阐述先理解延时的概念,其中还有很多细节值得我们深究。

  • 任务延时链表的作用?

任务延时链表,看名字肯定与任务阻塞有千丝万缕的联系,其实优先级与任务任务状态的切换就是本质就是几条链表挂来挂去。

在 FreeRTOS 中,有两条任务延时链表(一条是正常,一条是用于保存已超出当前tick计数的任务的延迟任务链表(计数器溢出),不过它们的实现方式与作用是一样的,这两条链表轮流使用以及计数器溢出是什么东西都值得我们深入研究一下),当任务需要延时的时候,则先将任务挂起,即先将任务从就绪链表删除,然后插入到任务延时链表,同时更新任务的解锁时刻变量:xNextTaskUnblockTime 的值。

在任务延时插入到延时链表时,任务会按照延时时间的大小从小到大升序排序插入延时链表中,也就是延时链表中第一个任务是延时时间最小的,当每次时基中断(SysTick 中断)来临时,就拿系统时基计数器的值 xTickCount 与下一个任务的解锁时刻变量xNextTaskUnblockTime的值相比较,(xNextTaskUnblockTime 的值等于系统时基计数器的值 xTickCount 加上任务需要延时的值 xTicksToDelay) 如果相等,则表示有任务延时到期,需要将该任务就绪,否则只是单纯地更新系统时基计数器xTickCount 的值(加1),然后进行任务切换。

在这里插入图片描述
为什么只需要与xNextTaskUnblockTime比较就能代表有任务延时时间到了呢?
因为任务挂入延时链表是按延时时间的升序排序,也就是说延时链表中第一个任务是延时时间最短的,xNextTaskUnblockTime就是第一个任务要就绪的时间,如果第一个任务都没有到时间,延时链表后面的任务更没有到时间(当然也有延时时间相同的任务)。

2.定义延时链表及其初始化

1.定义延时链表与延时链表指针

在这里插入图片描述
2.任务延时链表初始化prvInitialiseTaskLists()函数
在prvAddNewTaskToReadyList函数中顺便初始化一下链表(就绪链表,延时链表,本来还有悬起链表(这里就不讲悬起态了,其实与阻塞态一样都是靠链表来实现的))
在这里插入图片描述

3.定义xNextTaskUnblockTime变量

在这里插入图片描述

4.vTaskDelay()函数

在这里插入图片描述
当然vTaskDelay函数不止这些代码,还有关调度器(进入临界状态),开调度器,这里为了方便讲解,我们主要是理解思路,不过vTaskDelay函数最核心的函数就是prvAddCurrentTaskToDelayedList()函数

prvAddCurrentTaskToDelayedList()函数

在这里插入图片描述
接下来一段一段代码来分析:

1.获取系统时基计数器 xTickCount 的值
在这里插入图片描述

2.将任务结点从就绪链表中移除(并判断是否要清除位图)
在这里插入图片描述
前面已经提到过了

3.计算任务延时到期时间,并将延时时间作为任务结点的辅助排序值,等会排序插入延时链表时候,延时到期时间小的排在延时链表的最前面

在这里插入图片描述

在这里插入图片描述

4.按任务延时到期时间升序插入延时链表中
在这里插入图片描述
什么叫唤醒时间溢出?
所谓任务唤醒时间就是任务延时到期的时间就是xTimeToWake=xTickCount(计数器)+xTicksToWait(任务要延时的时间),即任务在xTimeToWake时刻要被唤醒。

所谓任务唤醒时间溢出:我们知道xTickCount变量就是一个32位的无符号整数,每次Systick中断xTickCount都加1,但是总会有最大值,xTimeToWake也是32位无符号整数,任务唤醒时间溢出就是xTimeToWake溢出。

举个例子:
已知:xTimeToWake=xTickCount(计数器)+xTicksToWait(任务要延时的时间)

如果当前 xTickCount 的值等于 0xfffffffdUL,xTicksToWait 等于
0x03,那么 xTimeToWake = 0xfffffffdUL + 0x03 = 0x01(至于为什么计算会等于1,其实就是无符号整数的计算了请参考
《C语言深度解剖之数据到底在内存中如何存储》)

很显然计算出来的xTimeToWake(任务被唤醒时间)比任务需要延时的时间xTicksToWait =0x03 还小,说明任务唤醒时间溢出(本质就是一个32位的变量超出了能表示的数字了),这时需要将任务插入到溢出链表中,注意:插入溢出链表的任务仍然是按照xTicksToWait 这个值来升序排序。

至于为什么需要两条延时链表

其实这两条延时链表是轮流使用的,当xTickCount(计数器) 溢出时,也就是xTickCount=0xFFFF FFFF,此时发送一次Systick中断让xTickCount+1=0xFFFF FFFF=0(代表xTickCount刚好溢出),此时就让两条链表交换一下,其实是交换下图两个延时链表的指针
在这里插入图片描述
等我讲完Systick中断服务函数的实现你就明白了为什么需要两个延时链表(一个放任务唤醒时间溢出的,一个放任务唤醒时间未溢出的))

5.xPortSysTickHandler()函数

SysTick中断服务函数主要调用了xTaskIncrementTick()函数,当xTaskIncrementTick()函数返回为真(pdTRUE)时才调用 taskYIELD()执行任务切换。

在这里插入图片描述

xTaskIncrementTick()函数

在这里插入图片描述
接下来就是逐句分析代码,顺便讲解为啥需要两条延时链表,而这两条链表又是怎样轮流使用的呢?

1)定义变量

在这里插入图片描述
2)为啥需要两个延时链表如何使用?
在这里插入图片描述
下面的解释有点绕了,一定要结合前面唤醒时间溢出的概念理解,要不然容易懵,我虽然理解了但是不好讲,需要你们慢慢悟
在这里插入图片描述

3)如果有延时任务唤醒时间已到
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述在这里插入图片描述

  • 支持时间片轮转

在这里插入图片描述

最后返回判断是否需要进行任务切换

在这里插入图片描述

总结

FreeRTOS内核的讲解就告一段落了,之后就是FreeRTOS的应用了,FreeRTOS内核合核心已经基本彻彻底底是剖析了一遍,但是还有好多边边角角好多细节的东西,不过都是万变不离其宗,本质把链表队列学好,后面在FreeRTOS的API应用中再不断补充FreeRTOS内核知识的讲解,最后能彻底拿捏FreeRTOS,学习FreeRTOS内核首先需要了解整个框架是怎样的,总分总的形式去学习,先要站在上帝视角看看那些内核函数的调用关系,每个函数的作用大概是什么,然后再去研究函数体深挖细节,最后进行一个全局的总结,这样才会对FreeRTOS内核有一个清晰的认识。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐