文章目录
- 前言
- 一、列表和列表项
- 1.1 xList 和 xLIST_ITEM
- 1.2 相关API函数
- 1.3 任务就绪列表
- 二、任务调度器的启动过程
- 2.1 PendSV 和 SysTick 寄存器
- 2.2 prvStartFirstTask( )
- 2.3 xPortStartScheduler( )
- 2.4 vTaskStartScheduler( ) 的整体流程
- 三、任务切换
- 3.1基于 SysTick 中断的自动任务切换
- 3.1.1 延时函数和上下文切换
- 3.1.2 PendSV 的作用
- 3.1.3 整体流程总结(自动)
- 3.2 基于外部中断的任务切换
- 3.2.1 外部中断触发任务切换的步骤
- 3.2.2 portYIELD_FROM_ISR( )
- 四、时间片调度
- 4.1 时间片的基本特点
- 4.2 时间片调度的基本原理
- 4.3 时间片调度与临界区保护的关系
前言
本节内容主要是对基础篇的补充,大部分内容是对vTaskStartScheduler()的各种函数底层的探究,如果不深入了解的朋友可以跳过了,这些寄存器或者底层函数通常情况freertos会自动帮我们调整或者调度。
一、列表和列表项
在 FreeRTOS 中,列表(List) 和 列表项(List Item) 是用于链表管理的两个核心概念,它们负责调度任务、管理延迟任务等。列表是容器,用来存储多个列表项,而列表项表示任务或其他内核对象,通过链表的结构被链接到列表中。当调度器需要调度任务时,它会遍历就绪任务列表,通过链表项来找到下一个要执行的任务。实际上,FreeRTOS 中的列表是由链表构成的“双向链表”形式,而列表项也就是链表的节点。
1.1 xList 和 xLIST_ITEM
列表(List)是 FreeRTOS 中的链表结构,用来管理任务或其他内核对象的集合。它包含多个xLIST_ITEM,即列表项。通过链表的结构,FreeRTOS 可以高效地管理和调度任务。xList 结构体定义如下所示:
struct xLIST
{
volatile UBaseType_t uxNumberOfItems; // 列表中当前的项目数
ListItem_t *pxIndex; // 当前列表项的指针,用于遍历列表
ListItem_t xListEnd; // 作为链表的末端标志,不存储实际任务
};
typedef struct xLIST List_t;
- uxNumberOfItems:存储当前列表中的项目数量。
- pxIndex:用于指向链表中的当前元素,调度器在遍历链表时使用它。
- xListEnd:这是链表的末端标志节点,它并不存储实际任务,而是作为链表结束的标记。它的值通常被设置为极大值,以确保任何插入的节点都在它之前。
xList_Item 是用于链表管理的一个节点(列表项)结构体,它是任务和其他内核对象(如队列、信号量等)在链表中存储时使用的基础数据结构。通过链表机制,xList_Item将各个任务或内核对象关联到相应的任务状态链表中(如就绪链表、延迟链表等)。在 FreeRTOS 的 list.h 文件中,xList_Item 结构体定义如下:
struct xLIST_ITEM
{
TickType_t xItemValue; // 用于排序的值,通常是时间戳或优先级
struct xLIST_ITEM *pxNext; // 指向链表中下一个节点的指针
struct xLIST_ITEM *pxPrevious; // 指向链表中前一个节点的指针
void *pvOwner; // 指向该项所属对象(如任务、队列、信号量等)
void *pvContainer; // 指向包含该项的链表
};
typedef struct xLIST_ITEM ListItem_t;
- xItemValue:这是该节点的值,通常用于排序。例如,当任务处于延迟状态时,xItemValue 可能表示任务可以被唤醒的时间戳;在就绪列表中,xItemValue 则可能与任务的优先级相关。
- pxNext:指向下一个链表节点的指针。链表的下一项是基于该指针来确定的。
- pxPrevious:指向前一个链表节点的指针。通过它可以从当前节点回到前一个节点。
- pvOwner:该指针指向这个链表项的拥有者,通常是指向任务控制块(TCB)的指针。通过它可以找到对应的任务或其他内核对象。
- pvContainer:指向包含该节点的链表,这个指针表明该节点属于哪个链表结构。
minixLIST_ITEM 是一种简化版的列表项结构体。与 xListItem 类似,minixLIST_ITEM 也是用于链表中的节点结构,只不过它的功能更简单,主要用于某些不需要完整列表管理功能的地方,通常用于内核级的优化。与 xListItem相比minixLIST_ITEM 没有 pvOwner 和 pvContainer 成员,因为它不需要知道链表项所属的任务控制块(TCB)或容器链表。这使得 minixLIST_ITEM 更加轻量,适用于不需要关联任务或特定对象的场景。xListItem 则是功能更全的链表节点,适合于任务调度、状态管理等场景。在 list.h 文件中,minixLIST_ITEM 结构体被定义为:
// 常用于列表末尾
struct xMINI_LIST_ITEM
{
TickType_t xItemValue; // 用于排序或比较的值
struct xLIST_ITEM *pxNext; // 指向下一个列表项的指针
struct xLIST_ITEM *pxPrevious; // 指向前一个列表项的指针
};
typedef struct xMINI_LIST_ITEM MiniListItem_t;
1.2 相关API函数
函数 | 描述 |
---|---|
vListInitialise(List_t *pxList) | 初始化一个链表,并设置链表的末端标记,并将链表的初始长度设为 0 |
vListInitialiseItem(ListItem_t *pxItem) | 初始化一个链表项并清空链表项中的 pvContainer 指针,表示该项当前不属于任何链表 |
uxListRemove(ListItem_t *pxItem) | 从链表中删除指定的列表项。如果成功移除,返回链表中的剩余项数 |
vListInsert(List_t *pxList, ListItem_t *pxNewListItem) | 将一个新项插入到 xItemValue指定的链表中 |
vListInsertEnd(List_t *pxList, ListItem_t *pxNewListItem) | 将一个新项插入到链表的末端,不进行排序,常用于无序链表的操作 |
listGET_OWNER_OF_NEXT_ENTRY(ListItem_t *pxItem, List_t *pxList) | 返回链表中下一个列表项的拥有者,即该项所属的任务或内核对象。它也会更新 pxIndex,用于遍历整个链表 |
listGET_OWNER_OF_HEAD_ENTRY(List_t *pxList) | 获取链表中第一个项的拥有者,即该项所属的任务或内核对象 |
listCURRENT_LIST_LENGTH(List_t *pxList) | 返回指定链表中的列表项数量 |
listLIST_IS_EMPTY(List_t *pxList) | 检查指定链表是否为空 |
1.3 任务就绪列表
在 FreeRTOS 中,任务就绪列表(Ready List)是用于管理所有处于“就绪”状态的任务,即那些已准备好运行且可以被调度器选择的任务。根据优先级的不同,每个任务都被存放在与其优先级相对应的就绪列表中。当任务处于“就绪”状态时,FreeRTOS 调度器会根据优先级在这些列表中选择最合适的任务进行执行。每个就绪列表本质上是一个链表,其中每个节点是 ListItem_t 结构体。每个节点与任务的 TCB(任务控制块)相关联,记录了任务的优先级、状态等信息。调度器通过这些链表项来管理任务的状态。这里需要知道几个数据结构:
- xReadyTasksLists[ ]:存储多个就绪列表的数组,数组的每一项对应一个优先级的就绪任务列表。
- xTasksWaitingTermination:管理处于终止状态的任务列表。
- pxDelayedTaskList 和 pxOverflowDelayedTaskList:延迟任务列表,用于管理那些暂时不能运行的任务。
二、任务调度器的启动过程
在基础篇,我们提到使用vTaskStartScheduler( )进行任务调度器的启动,这是因为vTaskStartScheduler() 是用于启动调度器的标准接口,它已经封装了调度器启动过程中所有需要的操作,包括调用 prvStartFirstTask() 启动第一个任务。
2.1 PendSV 和 SysTick 寄存器
在前章中断管理的内容中,我们介绍过了这两个寄存器,他们主要是用来在任务切换和系统计时。在实际启动 FreeRTOS 任务调度器时,FreeRTOS 帮我们配置了 PendSV 和 SysTick 寄存器,这里我们可以简单了解一下:
- PendSV 寄存器
- 作用:PendSV 是 ARM Cortex-M 系列处理器中的一个系统中断,用于触发上下文切换。FreeRTOS 通过 PendSV 实现任务切换。当需要切换任务时,FreeRTOS 会将 PendSV 设置为挂起状态,然后在下一个合适的时间(例如,任务进入阻塞状态或时间片结束时),触发 PendSV 中断来执行任务切换。
- 配置:FreeRTOS 的移植代码会自动配置 PendSV 中断优先级,你不需要手动修改该部分内容。调度器调用 portYIELD() 或者在 ISR 中通过 portYIELD_FROM_ISR() 来触发 PendSV 中断。
- SysTick 寄存器
- 作用:SysTick 是一个周期性定时器,用于生成 FreeRTOS 的系统节拍(tick)。每次 SysTick 中断发生时,FreeRTOS 会更新系统时钟,并检查是否需要执行任务调度。SysTick 的频率决定了任务切换和延迟任务的时间粒度,通常设置为 1 ms 或其他合适的周期。
- 配置:在 xPortStartScheduler() 函数中,FreeRTOS 会配置 SysTick 定时器。你需要确保系统的时钟频率已正确设置,以便产生合适的 tick 频率。通常,移植层会根据你的系统时钟自动配置 SysTick 定时器的值。
2.2 prvStartFirstTask( )
prvStartFirstTask( )是 FreeRTOS 内部的一个函数,它负责初始化启动前第一个任务的环境,主要是重新设置MSP指针。那么什么是MSP指针呢?程序在运行的过程中需要一定的栈空间来保存局部的一些信息,内核提供了两个栈空间:主堆栈指针(MSP,由os内核、异常服务例程及所需要特权访问的应用程序来使用)、进程堆栈指针(PSP,用于常规应用代码)。在FreeRtos中,中断内使用MSP,中断以外使用PSP。
prvStartFirstTask( )在这个过程中主要确保第一个任务的上下文被正确加载到 CPU 并开始调度。实际上,在启动第一个任务之前,系统一般处于 “任务未调度” 状态,prvStartFirstTask( )开启调度后,系统开始按照调度算法执行任务。这是一个非常底层的函数,它通常由 xPortStartScheduler( )调用,而xPortStartScheduler( )是启动调度器的特定于硬件的实现部分。
2.3 xPortStartScheduler( )
xPortStartScheduler( )是 FreeRTOS 中一个重要的函数,用于启动任务调度器。它是 FreeRTOS 内核的核心部分之一,负责开始任务的调度和管理。在启动任务调度器之前,xPortStartScheduler() 通常会进行一些硬件初始化,例如配置定时器中断,以便 FreeRTOS 可以进行时间切片和任务调度。之后它会启用调度器,使得 FreeRTOS 的任务管理功能开始运行,并根据任务的优先级和状态,决定哪个任务应该运行。一旦调度器启动,系统将进入任务调度模式,开始按照任务的优先级执行各个任务。值得一提的是,vTaskStartScheduler( )是 xPortStartScheduler( )的实际实现,通常情况下我们不直接使用xPortStartScheduler( )函数,而统一使用vTaskStartScheduler( ) 。
除此之外,xPortStartScheduler( )工作流程大致为:
- 初始化堆栈和任务:在调用 xPortStartScheduler() 之前,系统中的任务和堆栈已经被初始化。此函数的调用标志着任务调度的开始。
- 配置系统定时器:FreeRTOS 使用系统定时器进行时间片轮转和任务调度。xPortStartScheduler() 将配置并启用该定时器。
- 开始调度:调度器启动后,它会根据任务的优先级和状态,决定当前哪个任务应该运行。
- 调度循环:调度器将不断地在任务之间进行切换,直到系统重置或调度器被停止。
2.4 vTaskStartScheduler( ) 的整体流程
可以说vTaskStartScheduler( ) 包含了任务启动的所有工作,我们可以通过直接调用 vTaskStartScheduler() 启动调度器,之后不需要额外考虑底层的就绪列表、中断处理、任务切换等细节。具体来说一旦你调用了 vTaskStartScheduler(),FreeRTOS 的调度器就会开始运行,它会自动从优先级最高的就绪任务列表中选择任务执行,这意味着你不需要显式地去管理任务的切换或就绪列表。如果两个任务的优先级不同,FreeRTOS 将始终选择优先级高的任务运行。当高优先级任务进入阻塞状态(比如等待事件、延迟等),才会调度低优先级任务。如果任务具有相同的优先级,调度器会通过时间片轮转的方式切换任务。除此之外,vTaskStartScheduler( )会自动配置和启动系统时钟(SysTick)中断以及 PendSV 中断,这些负责触发任务切换。当任务切换发生时,FreeRTOS 会保存当前任务的上下文,并切换到下一个任务,着意味着你不需要手动处理中断。最后,当任务从创建状态变为就绪状态,或者从阻塞状态重新进入就绪状态时,FreeRTOS 内核会自动更新任务的状态并自动将其插入或移除就绪列表。
- 初始化内核数据结构:就绪列表、延迟列表、空闲任务钩子等;
- 创建空闲任务:prvIdleTask( );
- (可选)如启用了软件定时器,创建定时器服务任务xTimerCreateTimerTask( );
- 检查系统是否至少有一个任务可以运行;
- 配置系统节拍定时器:vPortSetupTimerInterrupt( )配置 SysTick 定时器;
- (可选)若需要进行浮点运算,使能浮点运算单元(FPU),并配置FPCCR(浮点运算上下文控制寄存器);
- 启动调度器:xPortStartScheduler( )自动调用prvStartFirstTask( )开启第一个任务;
三、任务切换
3.1基于 SysTick 中断的自动任务切换
3.1.1 延时函数和上下文切换
在基础篇,我们提到可以利用主动进入延时状态进入任务切换,那么这是为什么呢?实际上,延时函数会让当前任务进入阻塞状态,意味着该任务将不会再参与 CPU 的调度,直到设定的延时时间结束。通过调用 vTaskDelay(),任务会主动放弃 CPU 的使用权,允许调度器选择其他任务执行。FreeRTOS 调度器会在系统下一个时钟节拍(tick)触发时运行,查看是否有其他任务就绪。如果有,调度器会切换到下一个任务,从而实现上下文切换。而切换工作的实现流程其实与前文提到的PendSV有关。
3.1.2 PendSV 的作用
之前提到过,PendSV是 ARM Cortex-M 系列处理器中专门用于任务上下文切换的中断,由于其可以被调度器异步触发,因此它可以等待处理器处理完高优先级的中断后再执行。故而我们通常将它的优先级设置为最低,以免打断其他中断的执行。当 FreeRTOS 的调度器判断需要切换任务时(如当前任务进入阻塞状态、延时时间到、或有高优先级任务就绪等情况),会决定要触发任务上下文切换。在任务切换时,调度器只会触发 PendSV 中断,实际的上下文切换操作(保存和恢复任务寄存器、切换栈指针等)则是在 PendSV 中断处理程序中完成的。
其实简单来说,PendSV的触发流程有以下两种可能:
- (自动)滴答定时器定期中断 -> 进行调度器检查 -> 需要进行上下文切换 -> 触发PendSV 中断
- (手动)利用类似portYIELD()的API函数直接修改 ICSR 寄存器中的 PendSVSet 位
SCB->ICSR = SCB_ICSR_PENDSVSET_Msk
3.1.3 整体流程总结(自动)
- 延时函数的调用(如 vTaskDelay()):延时任务主动放弃 CPU 控制权,并进入阻塞状态。
- 滴答定时器 SysTick:SysTick 产生系统节拍中断,定期更新任务的状态。
- 调度器检查:在 SysTick 中断中,调度器检查是否有任务需要切换,是否有延时到期的任务。
- 触发 PendSV:如果需要切换任务,调度器触发 PendSV 中断。
- 上下文切换:PendSV 中断处理程序执行实际的任务上下文切换,保存当前任务状态,恢复下一个任务状态。
3.2 基于外部中断的任务切换
利用中断进行任务切换是一种通过硬件中断机制和RTOS调度器来强制任务切换的方式,这种方式通常用于响应外部事件,比如按键中断、通信中断等。
3.2.1 外部中断触发任务切换的步骤
- 外部中断发生:当某个外部事件(如按钮按下或数据到达)触发外部中断时,中断服务例程会被调用。
- 在中断中唤醒阻塞任务:如果某个任务因为等待某个外部事件(如数据输入)而进入阻塞状态,当外部中断发生时,任务可以从阻塞状态恢复到就绪状态。FreeRTOS 提供了一些API函数(例如,xTaskNotifyFromISR() 或 xQueueSendFromISR() ),可以在中断中调用这些函数来唤醒任务。
- 触发任务切换:在中断服务例程中,如果唤醒了更高优先级的任务,可以调用任务切换请求函数,比如 portYIELD_FROM_ISR() 来触发上下文切换,这会请求调度器在中断结束后执行任务切换。
3.2.2 portYIELD_FROM_ISR( )
portYIELD_FROM_ISR() 的原理和 portYIELD( ) 类似,它用于在中断结束时请求任务切换,都是通过触发 PendSV 中断来进行任务上下文切换的。
#define portYIELD_FROM_ISR(x) if (x) SCB->ICSR = SCB_ICSR_PENDSVSET_Msk
四、时间片调度
时间片调度(Round Robin Scheduling)是一种常见的任务调度算法,特别适合用于多任务操作系统(如 FreeRTOS)中,它的目标是为每个任务分配平等的 CPU 时间,让多个任务能够“轮流”执行。这里需要知道时间片的基本概念:每个任务被分配固定时长的 CPU 运行时间(可设置)。任务只能在时间片内运行,超过时间片后,系统强制切换到下一个任务。
4.1 时间片的基本特点
时间片调度算法非常简单,易于实现,尤其适合实时操作系统中。它的核心特性是公平,它确保每个任务都有机会获得 CPU 资源,不会因为某个任务长时间运行而饿死其他任务。除此之外,还有以下的基本特性:
- 抢占式:时间片调度通常是抢占式的,这意味着即使任务没有运行完,系统也会强制中断任务,切换到下一个任务。
- 任务切换:任务切换的实现通过计时器(如 SysTick 定时器)触发中断,在中断中保存当前任务的上下文,并恢复下一个任
- 效率问题:如果任务切换频繁,会增加上下文切换的开销,尤其是当任务数量较多时,频繁的任务切换会导致性能下降。
- 不适合长时间任务:对于执行时间较长的任务,时间片调度可能无法有效处理,可能导致整体系统效率降低。
4.2 时间片调度的基本原理
在 FreeRTOS 等实时操作系统中,时间片调度是基本的调度机制之一,特别是当多个同优先级任务同时就绪时,调度器会以时间片的方式公平地分配 CPU 资源。FreeRTOS 的调度器可以根据时间片进行切换,借助系统滴答定时器(SysTick),实现定时中断。每当时间片到期时,系统会触发 PendSV 中断进行上下文切换,从而完成任务之间的切换。时间片的长度(即任务能够运行的时间)是由系统时钟频率和滴答频率(tick rate)共同决定的,由 configTICK_RATE_HZ 来控制。configTICK_RATE_HZ 是 FreeRTOS 的一个配置宏,定义了每秒钟产生的滴答中断次数,也就是任务调度器的频率。例如,如果 configTICK_RATE_HZ 被设置为 1000,那么每 1ms 系统会产生一次滴答中断,表示每个时间片的默认长度为 1ms。
#define configTICK_RATE_HZ (1000) // 每秒 1000 次 tick,表示每个时间片为 1ms
我们以图示为例,讲述一下时间片是怎么作用于任务切换的:
- 初始化:所有任务准备就绪,调度器会按照一定顺序排列任务队列。
- 任务执行:从队列中的第一个任务开始运行,任务在它的时间片内完成其操作。
- 任务切换:当一个任务的时间片用完后,调度器中断该任务的执行,并将 CPU 控制权切换到下一个任务。
- 临界保护:在到达Task3时,(不到一个时间片)进入阻塞,任务完成之后才结束阻塞并切换下一个任务
- 循环调度:当所有任务轮流执行后,调度器会重新回到第一个任务继续调度。
4.3 时间片调度与临界区保护的关系
相信看完上面的例子,你可能会产生疑惑:如果一个任务进入临界区保护,在它退出临界区之前,确实不会发生任务切换,那么这不是和时间片调度机制存在矛盾吗?实际上,RTOS 的时间片轮转(time slicing)和优先级调度是一种公平调度机制,在多任务系统中,可以保证不同任务在一段时间内得到执行。然而,临界区保护只是在某些特定代码段中避免任务切换,这段代码之外仍然会遵循时间片轮转机制。所以,临界区只保护短暂的关键代码段,而并不是让任务完全占据 CPU。与此同时,临界区通常只持续很短的时间,用于保护对共享资源的访问。临界区的时间应尽量短,以减少对实时性的影响。当任务不处于临界区时,RTOS 的调度机制仍然可以正常工作,包括时间片轮转、任务优先级调度等。正确使用临界区可以避免竞态条件,而不会对整个系统的实时性产生太大影响。
免责声明:本文参考了网上公开资料,仅用于学习交流,若有错误或侵权请联系笔者。