文章来源:https://zhuanlan.zhihu.com/p/90608412
FreeRTOS简介
FreeRTOS是一个迷你的实时操作系统内核。作为一个轻量级的操作系统,功能包括:任务管理、时间管理、信号量、消息队列、内存管理、记录功能、软件定时器、协程等,可基本满足较小系统的需要。由于RTOS需占用一定的系统资源(尤其是RAM资源),只有μC/OS-II、embOS、salvo、FreeRTOS等少数实时操作系统能在小RAM单片机上运行。相对μC/OS-II、embOS等商业操作系统,FreeRTOS操作系统是完全免费的操作系统,具有源码公开、可移植、可裁减、调度策略灵活的特点,可以方便地移植到各种单片机上运行。
FreeRTOS的特性
- 具有抢占式或者合作式的实时操作系统内核
- 功能可裁剪,最小占用10kB左右rom空间,0.5kB ram空间
- 具有低功耗模式
- 有互斥锁、信号量、消息队列等功能
- 运行过程可追踪
FreeRTOS架构
其中重要的几个文件如下:
- tasks.c: 任务相关的代码
- list.c: 一个双向链表的代码
- queue.c: 队列用于任务间通讯的代码
- croutine.c: 多任务调度的代码
- event_groups.c:事件标志组的代码
- time.c : 内部实现的一个软件定时器的代码
通常来说,在一个FreeRTOS的项目中,tasks.c和list.c与queue.c是必须要的,其它的文件是可选的
FreeRTOS内存管理
堆和栈
虽然“堆栈“这个词大多数时候是连在一起使用的,但堆和栈其实是不同的概念。
栈(stack):由编译器自动分配和释放*,如存放函数的参数值,局部变量的值等
堆(heap):一般由程序员分配和释放*,分配方式类似于数据结构中的链表
栈的空间有限,堆有很大的自由存储区(最大值由SRAM区决定),程序在编译器和函数分配内存都是在栈上进行的,同时程序运行中函数调用时参数的传递也是在栈上进行的。通常我们习惯用malloc和free等API申请分配和释放内存,但频繁使用会造成大量的内存碎片进而降低系统的整体性能。同时这些API不是线程安全的,有机率会导致系统的不稳定。
堆栈的示例
[图片上传失败...(image-6c999e-1652015272931)]堆栈的示例
如上图的示例程序所示,全局变量和常量属于静态区(Static),由编译器事先分配好,生命周期贯穿整个程序;函数的参数值,局部变量的值属于栈(Stack),由编译器自动分配和释放;程序员用malloc函数动态请求分配的内存空间属于堆(Heap)。值得注意的是,如果在动态分配的内存用完之后忘记使用free函数释放内存,则会导致内存泄漏,并且当堆和栈无止境的增长到互相覆盖对方区域时则会出现很多无法预料的问题。程序可能运行运行着就跑飞了。
[图片上传失败...(image-ac943c-1652015272931)]
对堆和栈在硬件中的实现原理感兴趣的童鞋们可以参考如下这本书,这本书以通俗易懂的语言介绍了CPU和操作系统的原理,并通过分析汇编语言介绍了堆和栈。
[图片上传失败...(image-a915ce-1652015272931)]程序是怎样跑起来的京东¥35.67去购买
任务如何分配空间
[图片上传失败...(image-8fee03-1652015272931)]FreeRTOS给任务分配空间
如上图所示,当调用FreeRTOS的创建任务API函数xTaskCreate()时,FreeRTOS会在堆中开辟出一块空间,用于存放任务的控制信息TCB块和栈区Stack用于储存任务相关的变量。图中创建的两个任务Task1和Task2都有各自独立的内存空间,互相独立。如果想在静态区建立任务的话可以调用xTaskCreateStatic()函数。
TCB块的大小
TCB块的大小取决于FreeRTOSConfig.h头文件中的设置
- 在最小的设置下TCB块的大小是96字节
- 如果configUSE_TASK_NOTIFICATIONS是1的话再增加8个字节
- 如果configUSE_TRACE_FACILITY是1的话再增加8个字节
- 如果configUSE_MUTEXES是1的话再增加8个字节
上一章节中的任务TCB块因为这三个选项都为1,所以大小为96+8+8+8=120字节。
总结下来 任务占用字节数 = TCB_size + (4 x Task stack size)
MSP和PSP栈指针
在FreeRTOS中维护着两个栈的指针,分别是MSP主堆栈指针(Main stack pointer)和PSP进程堆栈指针(Process stack pointer)。
两个栈指针的区别是
MSP指针
- 用于操作内核以及处理异常和中断
- 由编译器分配
PSP指针
- 用于每个任务的独立的栈指针
- 在任务调度上下文切换(context switch)中,PSP会初始化为相对应的任务的栈指针,如下图所示
[图片上传失败...(image-bd3f87-1652015272931)]MSP指针
*通常MSP指针用于系统内核和中断服务函数,PSP指针用于用户的任务。*
内存池分配方案
FreeRTOS早期的版本使用内存池分配方案(memory pool allocation)。内存池分配是指在程序编译阶段就分配一定数量的内存块留作备用。当有新的内存需求时,就从内存块分出一部分内存块,若内存块不够了就继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到了提高。然而因为内存池分配对RAM的使用效率底下,在新的版本这个方案已经不被采用。
现在FreeRTOS最新的版本把内存分配当成可以由用户定制的部分。由于不同的嵌入式系统可以有不同的动态内存分配方案和时间要求,所以并不能有一个万能的内存分配方案满足所有需求。当FreeRTOS需要RAM的时候,它会调用pvPortMalloc这个函数而不是Malloc这个系统函数;当它需要释放内存的时候,会调用vPortFree这个函数而不是free这个系统函数。
FreeRTOS提供了五个pvPortMalloc和vPortFree的实现方案,分别是heap_1.c, heap_2.c, heap_3.c, heap_4.c 和 heap_5.c。读者可以根据自己项目的要求选择这几个方案中的一种或者也可以定制实现方法。
图形化设置界面
如下图所示,STMCubeIDE提供一个图形化界面用于设置FreeRTOS的堆的大小和内存分配方案。默认采用的是Heap_4方案
[图片上传失败...(image-fd09e-1652015272932)]IDE中的设置
下面将分别介绍这五个预先准备的方案。
Heap_1
本方案适用于小型的嵌入式系统,并且这个系统只能在调度器启动之前创建任务和其它内核对象。内存只需要在程序启动调度器前采用first fit算法对内存进行动态分配,之后任务的内存分配在程序的运行周期中保持不变并且无法被释放。heap_1.c实现了一个基础版本的pvPortMalloc函数,并没有实现vPortFree这个函数。如果系统运行后不用删除任务或者内核对象就可以采用这个方案。一些不需要动态分配内存的安全相关的系统也可以采用这个方案,因为这个方案是可确定性的(deterministic)所以不会导致内存碎片化。这个方案中堆由一个数组实现,数组的大小由FreeRTOSConfig.h文件中configTOTAL_HEAP_SIZE定义,如下图
[图片上传失败...(image-2aa207-1652015272932)]configTOTAL_HEAP_SIZE定义
Heap_1 示例
内存分配示例如下,A表示没有任何任务创建时的内存;B表示一个任务(每个任务有自己的TCB块和栈区)被创建时的内存分配情况;C表示三个任务被创建时的分配情况。
[图片上传失败...(image-8fb771-1652015272932)]Heap_1内存分配示例
Heap_2
这个方案用于保持FreeRTOS的向下兼容性,并不推荐使用。内存管理也由一个数组实现,大小由FreeRTOSConfig.h文件中configTOTAL_HEAP_SIZE定义。它通过一套优化算法(best fit algorithm)对内存进行分配,并允许释放内存。Heap_4是Heap_2的功能强化版本。
best fit 算法确保pvPortMalloc函数分配大小最接近所需要字节的内存空间。它会对大的内存块进行分割,但无法合并相连的内存块。Heap_2适用于重复添加和删除相同任务的系统,但这种系统应该十分少见。
Heap_2 示例
内存分配示例如下,A表示有三个任务被创建时的内存分配情况;B表示有一个任务被删除时的分配情况,此时有两个小的内存块空闲出来;C表示另一个相同任务被创建时的分配情况,因为这个对TCB块和栈区大小的要求和之前被删除任务的大小一样,best fit 算法便把之前被释放的内存块分配给它。
[图片上传失败...(image-897fc4-1652015272932)]Heap_2内存分配示例
Heap_3
本方案使用标准库里的malloc和free函数,所以堆的大小由链接器配置决定,不受configTOTAL_HEAP_SIZE大小影响。因为这个方案使用的场景不多,所以在这里不作详细介绍。
Heap_3示例
[图片上传失败...(image-119606-1652015272932)]Heap_3内存分配示例
可以看到Heap_3使用标准库里的malloc和free函数对任务进行内存分配和释放
Heap_4
此方案同第一个和第二个方案一样,由一个数组表示堆,并把数组分割成小的内存块。堆的大小由FreeRTOSConfig.h文件中configTOTAL_HEAP_SIZE定义。
Heap_4采用的first fit算法可以把相邻的空闲的内存块合并成更大的内存块,减少了内存碎片化的风险。所以这个方案适用于通用的应用(系统会添加和删除不同大小的任务)
Heap_4 示例
内存分配示例如下,可以看到内存可以自由分配和合并空闲的内存块,并使得整体的性能最优。Heap_4的分配方案是不确定性的,但速度要比malloc和free函数快。
[图片上传失败...(image-588d5f-1652015272932)]Heap_4内存分配示例
在STM32CubeIDE的项目中,内存管理默认采用了heap_4.c 作为实现方案。
[图片上传失败...(image-6730ba-1652015272932)]内存管理默认采用了heap_4.c 作为实现方案
Heap_5
这个方案使用类似于Heap_4的内存分配技术,但不同于Heap_4只用一个连续的数组表示堆,Heap_5可以用不同的数组空间对内存进行分配。在本方案要使用vPortDefineHeapRegions这个函数对不同的数组进行申明。
Heap_5 示例
下面这张图定义了三个不同内存空间用于模拟堆。程序如下,首先定义了每个区域的开始地址START_ADDRESS和空间大小SIZE,然后用一个结构体xHeapRegions指向了这些区域,最后使用vPortDefineHeapRegions函数申明堆的空间。
[图片上传失败...(image-95907d-1652015272932)]Heap_5内存分配示例
[图片上传失败...(image-acafae-1652015272932)]vPortDefineHeapRegions函数申明堆的空间举例
内存管理相关函数
size_t xPortGetFreeHeapSize( void );
这个函数会返回当前堆中的空闲空间,可以用来优化堆空间大小。比如在系统运行起来后调用xPortGetFreeHeapSize如果返回了3000,就可以把堆大小configTOTAL_HEAP_SIZE设置为3000。
size_t xPortGetMinimumEverFreeHeapSize( void );
这个函数会返回在系统运行过程中堆空间的最小空闲空间,如果最小空闲空间很小的话可以考虑提高堆大小configTOTAL_HEAP_SIZE的值。
void vApplicationMallocFailedHook( void );
这是一个回调函数,需要用户自己实现。如果配置文件中configUSE_MALLOC_FAILED_HOOK 设置为1的话,当堆分配内存失败时会调用此函数。用户可以在此函数中进行错误处理。
FreeRTOS任务管理机制
什么是任务
在FreeRTOS中,线程(Thread)和任务(Task)的概念是相同的。每个任务就是一个线程,有着自己的一个程序。函数的模型示例如下所示,通常情况下包含一个永远不会退出的循环体。
void TaskFunction( void *pvParameters )
{
int32_t test = 10;
for(;;)
{
}
vTaskDelete(NULL);
}
这个任务函数不能有返回值(即使用return语句),不然会导致异常。如果不需要这个任务的话,必须要用语句显式地删除这个任务(比如调用vTaskDelete()这个函数)。每一个创建的任务有自己的栈区(用于存储数据)以及优先级(用于任务的调度)。
[图片上传失败...(image-2af93a-1652015272932)]任务的运行状态
同时,任务也有自己的运行状态,如上图所示,包括就绪状态(Ready),运行状态(Running),阻塞状态(Blocked),挂起状态(Suspended)。状态之间的切换可以由调用对应箭头上的CMSIS RTOS函数实现。
总结下来,任务有如下的特征
- 由C语言编写的
- 运行在一个无限的循环里
- 有自己独立的栈区和优先级
- 有自己的运行状态
- 由操作系统的API函数创建和删除
任务的优先级
任务的优先级可以用vTaskPrioritySet()函数设置。FreeRTOSConfig.h头文件中的configMAX_PRIORITIES可以设置最高优先级的值。0代表最低优先级, (configMAX_PRIORITIES – 1)代表最高优先级。
有两种影响设置configMAX_PRIORITIES的方式
- 通用方式
configUSE_PORT_OPTIMISED_TASK_SELECTION这个值设为0时为通用方式。采用通用方式时FreeRTOS不会限制configMAX_PRIORITIES的最大值。通常建议把configMAX_PRIORITIES的值设置得小一点,因为值越高,占用的ram越多,程序的最坏运行时间(worst case execution time)会更长。
- 架构优化方式
configUSE_PORT_OPTIMISED_TASK_SELECTION这个值设为1时为架构优化方式。架构优化方式采用了平台相关的汇编代码,比通用方式更快,configMAX_PRIORITIES的值不会影响程序的最坏运行时间。在这种方式下,configMAX_PRIORITIES的最大值不能超过32。因为是平台相关,不是所有的单片机支持这个方式。
在STM32CubeIDE中,设置任务的优先级可以通过ioc->Middleware->FreeRTOS->Tasks and Queues中的图形化界面设置,具体做法是在任务的Priority的下拉菜单中选择一个优先级。
[图片上传失败...(image-6cd0bb-1652015272932)]IDE中设置任务的优先级
任务的创建
任务由FreeRTOS中xTaskCreate()这个函数创建,这一个非常重要并频繁被调用的函数,需要掌握以下每个参数的含义。
BaseType_t xTaskCreate( TaskFunction_t pvTaskCode,
const char * const pcName,
uint16_t usStackDepth,
void *pvParameters,
UBaseType_t uxPriority,
TaskHandle_t *pxCreatedTask );
*pvTaskCode* 这是一个函数指针,指向执行任务的函数
*pcName* 任务的描述名称,方便调试,不用的话可以设为Null
*usStackDepth* 每个任务有自己的栈空间,这里根据任务占用需求设置栈空间的大小
*pvParameters* 用于传递给任务的参数,不用的话可以设为Null
*uxPriority* 设置任务的优先级,范围由0到(configMAX_PRIORITIES – 1)。数值越大,等级越高
*pxCreatedTask* 任务的具柄(handle),通过具柄可以对任务进行设置,比如改变任务优先级等,不用可以设为Null
函数的返回值有两个pdPass和pdFail,pdPass表示任务创建成功,相反pdFail表示创建失败,创建失败的原因大多是因为系统没有足够的堆空间来保存任务的数据。
下图表示了建立了两个任务之后RAM中的占用情况。图中的堆空间(Heap,更多堆栈相关内容请参考上一讲)在设置好后,每执行xTaskCreate()创建一个任务便会在堆空间中开辟一个TCB块(包含任务函数属性)和一个存放数据的Stack栈区(栈区大小由usStackDepth参数决定)
[图片上传失败...(image-a36013-1652015272932)]任务在RAM中的占用情况
值得注意的是在STM32CubeIDE中的CMSIS RTOS的API函数中,负责创建任务的函数是osThreadNew()这个函数,其封装了FreeRTOS中xTaskCreate()这个函数
什么是调度器
调度器(scheduler)简单来说是一个决定哪个任务应该执行的算法代码。在FreeRTOS中采用了round-robin的调度算法,包含抢占式(preemptive)和合作式(cooperative)两种模式。模式的选择在FreeRTOSConfig.h头文件中由configUSE_PREEMPTION这个参数决定,为1时是抢占式模式,为0时是合作式模式。
在抢占式模式下,在每次调度器运行时,高优先级的任务会被切换优先执行,当前处于运行状态的低优先级的任务则会立刻进入就绪状态等待运行,如下图所示,高优先级的Task2抢占了Task1。如果几个任务的优先级一样的话,它们就会轮流执行共享CPU资源。
[图片上传失败...(image-4cde2b-1652015272932)]抢占式模式
在合作式模式下,高优先级任务不会抢占当前正在运行状态的低优先级任务,直到低优先级任务完成进入阻塞状态(比如调用osDelay()函数)或就绪状态(比如调用osThreadYield()函数)或者被系统置于挂起状态后才会切换任务,如下图所示。
[图片上传失败...(image-3b2d9f-1652015272932)]合作式模式
两种模式孰优孰劣就得看具体情况具体分析了。
在STM32CubeIDE中,如下图所示,当USE_PREEMPTION为Enabled时采用抢占式模式;当USE_PREEMPTION为Disabled时采用抢占式模式。
[图片上传失败...(image-1f8c9e-1652015272932)]
任务的调度
FreeRTOS对任务的调度采用基于时间片(time slicing)的方式。时间片,顾名思义,把一段时间等分成了很多个时间段,在每一个时间段保证优先级最高的任务能执行,同时如果几个任务拥有相等的优先级,则它们会轮流使用每个时间段占用CPU资源。调度器会在每个时间片结束的时候通过周期中断(tick interrupt)执行一次,调度器根据设置的抢占式还是合作式模式选择哪个任务在下一个时间片会运行。
时间片的大小由configTICK_RATE_HZ这个参数设置。如果configTICK_RATE_HZ设置为10HZ,则时间片的大小为100ms。configTICK_RATE_HZ的值由应用需求决定,通常设为100HZ(时间片大小相应为10ms)。
[图片上传失败...(image-1829ca-1652015272932)]任务调度演示
在上图任务调度的演示中,Kernel表示系统内核即调度程序,Task1和Task2是两个优先级相同的任务。t1到t2是一个时间片,t2到t3是另一个时间片。在每一个时间片快结束的时候,调度程序通过周期中断(tick interrupt)被调用并选择在下一个时间片要执行的任务(红色部分代表调度程序Kernel在运行)。此时因为两个任务的优先级相同,调度程序会让两个任务轮流占用时间片进行运行(蓝色部分代表Task1在运行,绿色部分代表Task2在运行)。
通过pdMS_TO_TICKS()这个函数可以把时间转换成节拍数(一个节拍代表一个时间片),调用这个函数可以保证configTICK_RATE_HZ的值不同时,时间是一致的。
案例介绍
这里讲展示了一个温度监测系统的案例。一个简单的温度监测系统可以由三个任务构成。第一个任务是温度传感器获取数据任务,这个任务的优先级最高,定期执行获取温度数据。第二个任务是传感器数据处理以及显示数据任务,等待第一个任务获取的数据并进行处理。第三个任务是用户的按键处理程序,优先级最低,可以对用户的按键输入进行处理执行不同的功能。
[图片上传失败...(image-d88df9-1652015272932)]温度监测系统的组成
一个应用可以包含多个任务程序,然而微处理器的计算资源有限,通常只有一个核心,所以在任意时间只能有一个任务被执行,由FreeRTOS的调度器(Scheduler)决定。
案例代码实现
我们简单实现上文说的温度监测系统的三个任务,创建并执行这三个任务。这里统一采用FreeRTOS的系统API函数,CMSIS RTOS的原理也是相通的。
首先,我们定义一个全局变量temp用于存放温度信息。定义全局变量其实不太安全,这里为了简便就使用
int temp = 0;//全局变量temp用于存放温度信息
然后分别实现三个任务
void vTask1(void const * argument)//温度传感器数据获取任务
{
//模拟温度传感器数据
int test=0;
for(;;){
temp = 20+test%20;
test++;
if(test>30000)
{
test=0;
}
}
vTaskDelay(1000);
}
void vTask2(void const * argument)//数据处理以及屏幕显示
{
char text[]= "Temperature:";
char tempText[10];
TM_LCD_SetFont(&TM_Font_11x18);
TM_FONT_GetStringSize(text, &FontSize, &TM_Font_11x18);
TM_LCD_Fill(COLOR_RED);
for(;;)
{
//液晶屏显示温度数据
TM_LCD_SetXY((TM_LCD_GetWidth() - FontSize.Width) / 2, FontSize.Height * 5);
TM_LCD_Puts(text);
sprintf(tempText, "%d", temp);
TM_LCD_SetXY((TM_LCD_GetWidth() - FontSize.Width) / 2, FontSize.Height * 7);
TM_LCD_Puts(strncat(tempText," Degree",7));
vTaskDelay(1000);
}
}
void vTask3(void const * argument)//用户按键处理
{
char text[12];
char userInput[2];
for(;;)
{
//模拟用户输入并显示
sprintf(text, "%s", "User Input");
sprintf(userInput, "%d", temp%10);
TM_LCD_SetXY((TM_LCD_GetWidth() - FontSize.Width) / 2, FontSize.Height * 9);
TM_LCD_Puts(strncat(text,userInput,2));
vTaskDelay(1000);
}
}
最后我们在main函数中创建这三个任务并启动调度器
int main(void)
{
......
xTaskCreate( vTask1, "get sensor data", 200, NULL, 3, NULL );
xTaskCreate( vTask2, "show sensor data", 200, NULL, 2, NULL );
xTaskCreate( vTask3, "get user input", 200, NULL, 1, NULL );
/* Start scheduler */
vTaskStartScheduler();
while (1)
{
}
}
案例效果演示
程序的运行视频如下,系统每过1秒会采集温度数据并输出到屏幕上,三个任务独立运行,以后如果维护或者添加功能的话只要添加新的任务或者修改相对应的任务程序就好了,并不会影响其它功能模块的运行。相对于Java中面向对象的编程思路,在嵌入式编程中我们要有模块化编程的思路。
温度监测系统的案例模拟运行
在视频中这三个任务看似是同时运行的,但其实是按先后顺序在一个CPU核心上运行的,拥有高优先级的采集温度任务可以有更大机会抢占CPU的运行资源保证数据的实时性和准确性。高优先级的任务程序代码应该简短不要占用cpu太长时间,不然低优先级的任务不能及时运行会让用户感觉系统卡顿不流畅。
相对的,比如一个低优先级的计算任务希望能快速地计算出结果而不被高优先级的任务平凡打断影响计算时效性的话,此时应该考虑采用合作式(cooperative)调度模式。
FreeRTOS任务调度
首先,让我们先看一下FreeRTOS的任务运行状态的转化图
[图片上传失败...(image-29f2b3-1652015272932)]FreeRTOS的任务运行状态转化图
其中每个状态的含义如下
- 阻塞状态(Blocked)当任务等待某个事件或信号的时候处于此状态
- 挂起状态(Suspended)当任务被vTaskSuspend()函数禁止运行的时候处于此状态
- 就绪状态(Ready)当任务没有被阻塞或者挂起等待运行的时候处于此状态
- 运行状态(Running)当任务被内核调度执行的时候处于此状态
在系统初始化所有任务被创建的时候,任务一开始都处于就绪状态(Ready)*,然后内核调度器开始调度首先选择执行优先级最高的任务,此时被执行的任务处于运行状态(Running)*。当任务执行延时命令或者等待某个同步事件的时候便交出了自己的运行权,此时将处于阻塞状态(Blocked)*。在任务运行的时候,它可以通过vTaskSuspend()函数将其他任务或者自身挂起进入挂起状态(Suspended)*。被挂起的任务只有通过vTaskResume()函数恢复成*就绪状态(Ready)。*
FreeRTOS中任务的运行状态机制:FreeRTOS通过xTaskCreate()函数创建任务;FreeRTOS通过vTaskSuspend()函数挂起任务;FreeRTOS通过vTaskResume()函数恢复任务到就绪状态;FreeRTOS通过vTaskDelete()函数删除任务。
调度算法及配置
抢占式时间片调度
抢占式时间片调度(Prioritized Pre-emptive Scheduling with Time Slicing)是比较通用的调度方式,上一篇提到的温度检测系统采用的就是这种方式。内核调度器在每个时间片结束的时候执行一次,选择处于就绪状态的任务中优先级最高的任务置于下一个时间片执行。如果优先级相同的话则交替执行。此时,FreeRTOSConfig.h头文件的设置如下:
configUSE_PREEMPTION(允许抢占) 1
configUSE_TIME_SLICING(采用时间片) 1
抢占式无时间片调度
抢占式无时间片调度(Prioritized Pre-emptive Scheduling without Time Slicing)这种调度方式下,因为没有采取时间片,所以调度器的执行开销会比较小。如果两个任务的优先级相同的话,在抢占式时间片调度下,两个任务会交替运行;然而在抢占式无时间片调度下,当前运行的任务会一直运行,直到它进入阻塞或者挂起状态,另一个相同优先级的任务才会运行。高优先级的任务会抢占低优先任务。此时,FreeRTOSConfig.h头文件的设置如下:
configUSE_PREEMPTION(允许抢占) 1
configUSE_TIME_SLICING(采用时间片) 0
合作式调度
合作式调度(Cooperative Scheduling)这种调度模式下,当前执行任务将会一直运行,同时高优先级的任务不会抢占低优先级任务。内核调度只会在当前执行任务进入阻塞状态的时候才会执行,选择处于就绪状态的任务中优先级最高的任务进行执行。此时,FreeRTOSConfig.h头文件的设置如下:
configUSE_PREEMPTION(允许抢占) 0
configUSE_TIME_SLICING(采用时间片) 任意
[图片上传失败...(image-da8e9c-1652015272932)]
FreeRTOS中这几个调度模式并没有优劣之分,要根据项目的具体情况来选择,这样才能发挥出系统最强大的性能。在下一讲中,笔者将介绍FreeRTOS中队列管理的知识。队列管理(Queue Management)提供了任务之间,任务和中断之间的通讯机制,在FreeRTOS中会被广泛应用到。
队列管理
进程之间的通讯方式
[图片上传失败...(image-f7a808-1652015272932)]进程之间的通讯方式
如上图中所示,进程之间的通讯有两种方式,(a)是采用进程间共享内存的方式,(b)是采用信息队列的方式。通过共享内存的方式,进程之间能够更快地共用和交互数据;数据队列则是提供了一种更通用且稳定的交互数据的方式。
全局变量的弊端
在以往的教程中,我们创建的任务都是相对独立的,无法互相通讯交换数据。有童鞋说当然可以通过定义一个全局变量来实现多任务之间共享数据呀。你有没有想过,如果有多个任务同时要对一个占多字节的全局变量进行修改,如果没有保护机制的话,当第一个任务赋值给全局变量赋值前几个字节后,如果此时调度器切换第二个任务执行给全局变量赋值另外的几个字节......这个全局变量的值就变得支离破碎毫无意义了。所以说使用全局变量其实是不太安全的,一种解决这个问题的方案就是采用队列。队列提供了一种任务间或者任务和中断间的通讯机制。
什么是队列
队列是一种数据结构,可以包含一组固定大小的数据。在创建队列的同时,队列的长度和所包含数据类型的大小就确认下来了。栈(Stack)也是一种数据结构,栈和队列的区别在于栈是后进先出(Last In First Out),而队列是先进先出(First In First Out)。先进先出表示的是先写入的数据会先被读取,相对而言符合人类的直觉思维。队列中的数据必须是相同类型的,可以是基本类型也可以是结构体。
队列的实现方式
队列有如下两种实现方式
- *复制队列(Queue by copy)* 表示写入队列的数据都被完整复制到队列中了
- *引用队列(Queue by reference)* 表示写入队列的是要写入数据的引用并不是数据本身
FreeRTOS采用是复制队列的实现方式,有如下优势:
- 有些栈变量是在函数运行结束后会被销毁,采用引用队列的话引用会失效
- 发送数据的函数可以重复使用变量,采用引用队列的话每发送一个数据需要一个新的变量
- 发送队列数据和接受队列数据的函数是没有耦合的,互相不影响
一个队列可以有多个写入数据的任务和多个读取数据的任务。当一个任务试图从队列读取数据的时候,它可以设置一个阻塞时间(block time)。这是当队列数据为空时,任务会进入阻塞状态的时间。当有数据在队列或者到达阻塞时间的时候,任务都会进入就绪状态。如果有多个任务同时在阻塞状态等待队列数据,优先级高的任务会在数据到达时进入就绪状态;在优先级相同的时候,等待时间长的任务会进入就绪状态。同理可以推及多个任务写入数据时候的运行状态。FreeRTOS还可以设置让任务等待一组队列(Sets of Queues)的任意队列的数据。
常见队列操作方法
FreeRTOS API
下面将介绍创建和使用队列需要用到的几个函数
QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize )
xQueueCreate()是创建队列用到的函数。函数的返回值是QueueHandle_t具柄类型,表示的是对所创建队列的一个引用。FreeRTOS从FreeRTOS的堆中指定一些内存空间给队列使用。如果堆中没有足够空间给队列使用的话函数的返回值会是NULL。
函数的几个参数介绍如下
- uxQueueLength 队列包含数据的最大长度
- uxItemSize 每个数据占用的字节大小
BaseType_t xQueueSend( QueueHandle_t xQueue, const void * pvItemToQueue,
TickType_t xTicksToWait )
xQueueSend()函数用于将数据发送到队列(具体一点就是队列的尾部)。如果要在中断程序调用的话需要使用xQueueSendFromISR()函数。
函数的几个参数介绍如下
- xQueue 队列的具柄,来自于xQueueCreate()的返回值
- pvItemToQueue 所发送数据的引用,然后这些数据会被复制到队列中
- xTicksToWait 队列如果满时发送任务的阻塞时间(block time),上文已经介绍过,可以通过pdMS_TO_TICKS()把时间转换成节拍数。如果设置为portMAX_DELAY的话任务将永远等待下去(需要FreeRTOSConfig.h头文件中INCLUDE_vTaskSuspend参数设置为1)
- 返回值 发送数据成功时返回pdPASS,失败时返回errQUEUE_FULL
下面两个函数用于明确指定发送数据到队列的头部还是尾部。
BaseType_t xQueueSendToFront( QueueHandle_t xQueue, const void * pvItemToQueue,
TickType_t xTicksToWait )
BaseType_t xQueueSendToBack( QueueHandle_t xQueue, const void * pvItemToQueue,
TickType_t xTicksToWait )
xQueueSend()函数和xQueueSendToBack()函数本质上是一样的,可以在下图xQueueSend()的宏定义中queueSEND_TO_BACK看到
[图片上传失败...(image-8c954f-1652015272932)]xQueueSend()宏定义
BaseType_t xQueueReceive( QueueHandle_t xQueue, void * const pvBuffer,
TickType_t xTicksToWait )
xQueueReceive()函数用于从队列中读取数据,同时读取到的数据会被从队列中移除。
函数的几个参数介绍如下
- xQueue 队列的具柄,来自于xQueueCreate()的返回值
- pvBuffer 指向内存空间的一个引用,读取的数据会被复制到这片内存
- xTicksToWait 队列如果空时接送任务的阻塞时间(block time),上文已经介绍过,可以通过pdMS_TO_TICKS()把时间转换成节拍数。如果设置为portMAX_DELAY的话任务将永远等待下去(需要FreeRTOSConfig.h头文件中INCLUDE_vTaskSuspend参数设置为1)
- 返回值 接收数据成功时返回pdPASS,失败时返回errQUEUE_EMPTY
UBaseType_t uxQueueMessagesWaiting( QueueHandle_t xQueue )
uxQueueMessagesWaiting()函数用于获得队列中数据的数量
CMSIS RTOS API
CMSIS RTOS API封装了FreeRTOS API的函数,下面这个表格罗列了CMSIS RTOS 对队列的操作方法
[图片上传失败...(image-c5b5bd-1652015272932)]CMSIS RTOS API对比FreeRTOS APPI
图形化配置
在STM32CubeIDE中,在FreeRTOS的选项中,Tasks and Queues一栏负责管理FreeRTOS所有的任务和队列,点击Add就可以添加队列,里面的选项也是简明易懂的,这里就不详细解释了。值得一提的是,Item Size可以换成用户自定义的结构体类型。设置完成后IDE会自动生成队列的初始化代码,减少了开发者的维护成本。
[图片上传失败...(image-23af8c-1652015272932)]STM32CubeIDE中队列的配置界面
队列的实例应用
在第五讲中
FreeRTOS 从入门到精通5--任务管理这件事(上)84 赞同 · 24 评论文章[图片上传失败...(image-250c85-1652015272932)]
笔者介绍了一个温度监测系统的案例,这个系统有三个任务。Task1把模拟的温度数据存入一个全局变量,Task2通过读取temp变量获取温度的数据并且在显示屏上显示出来。
int temp = 0;//全局变量temp用于存放温度信息
[图片上传失败...(image-955bc5-1652015272932)]一个温度监测系统的案例
当时我在第五章里说起定义全局变量其实不太安全。因为任何函数都可以随意修改全局变量的变量的值,对于程序的安全性和健壮性不太好。这里我打算把Task1和Task2的程序修改下,通过引入一个全局的队列xTemperatureQueue来使两个任务交换温度数据。
QueueHandle_t xTemperatureQueue;//定义一个全局队列
同时修改Task1和Task2,请参考程序的注释
void vTask1(void const * argument)//温度传感器数据获取任务
{
BaseType_t xStatus;
//把时间转换成
const TickType_t xTicksToWait = pdMS_TO_TICKS( 1000 );
int test = 0;
int temp = 0;
for(;;)
{
//模拟温度传感器数据
temp = 20+test%20;
//通过发送函数把温度发送到队列xTemperatureQueue中
xStatus = xQueueSend( xTemperatureQueue, &temp, xTicksToWait );
if( xStatus != pdPASS )
{
//如果发送数据失败在这里进行错误处理
}
test++;
if(test>30000)
{
test=0;
}
//模拟温度传感器的采样频率
osDelay(1000);
}
}
void vTask2(void const * argument)//数据处理以及屏幕显示
{
char text[]= "Temperature:";
char tempText[10];
BaseType_t xStatus;
int ReceivedValue = 0;
const TickType_t xTicksToWait = pdMS_TO_TICKS( 100 );
TM_LCD_SetFont(&TM_Font_11x18);
TM_FONT_GetStringSize(text, &FontSize, &TM_Font_11x18);
TM_LCD_Fill(COLOR_RED);
for(;;)
{
//通过接收函数从xTemperatureQueue队列中获取温度数据
xStatus = xQueueReceive( xTemperatureQueue, &ReceivedValue, xTicksToWait );
if(xStatus == pdPASS )
{
//在显示屏上显示数据
TM_LCD_SetXY((TM_LCD_GetWidth() - FontSize.Width) / 2, FontSize.Height * 5);
TM_LCD_Puts(text);
sprintf(tempText, "%d", ReceivedValue);
TM_LCD_SetXY((TM_LCD_GetWidth() - FontSize.Width) / 2, FontSize.Height * 7);
TM_LCD_Puts(strncat(tempText," Degree",7));
}
else
{
//如果接收数据失败在这里进行错误处理
}
}
}
同时在main()函数中建立队列并启动内核调度器运行程序
int main(void)
{
......
xTemperatureQueue = xQueueCreate( 10, sizeof( int32_t ) );
xTaskCreate( vTask1, "get sensor data", 200, NULL, 3, NULL );
xTaskCreate( vTask2, "show sensor data", 200, NULL, 2, NULL );
xTaskCreate( vTask3, "get user input", 200, NULL, 1, NULL );
/* Start scheduler */
vTaskStartScheduler();
while (1)
{
}
}
程序的运行效果和第五讲的运行效果是一致的,演示效果视频可以参考第五讲。
但通过使用队列任务间可以更加安全,方便地交互数据。通过队列不仅仅可以传递基本类型数据,也可以传递结构体。比如一个结构体里面可以包含发送命令的来源,要执行的命令,执行命令的附加参数等。接收函数通过接收结构体里的这些具体数据可以实现更强大的功能。
传递结构体的案例
下面这个图展示一种复杂场景中的应用例子,在这个场景中不同的任务比如CAN总线任务,显示处理任务等把数据包装到结构体中并发送结构体数据到队列。结构体数据包含数据的类别eDataID和具体数据的值IDataValue。 Controller任务从队列中获取结构体数据,并对不同类别的数据进行相应的处理,可以通过组合实现复杂的功能。
[图片上传失败...(image-568bc3-1652015272932)]一个复杂场景的应用例子
*队列的错误处理要重视,防止队列因为数据没有被及时处理使得队列为满造成后面的数据无法写入队列中,从而影响系统的性能。*
One More Thing
邮件队列
之前笔者提到过队列有引用队列(表示写入队列的是要写入数据的引用并不是数据本身)的方式但FreeRTOS里没有采用。在CMSIS RTOS里提供了对引用队列的实现,叫做邮件队列(mail queue),它传递的是储存数据块的地址指针。常见的API函数如下表格所示
[图片上传失败...(image-a890ce-1652015272932)] 邮件队列API
软定时器的应用
什么是软件定时器
如果一个任务要在未来某个时间运行或者周期性地运行以实现定时或者延时控制,就需要设定一个定时器。定时器分为硬件定时器和软件定时器。硬件定时器是单片机内部的特殊功能模块,通过硬件定时器中断就可以精准地对任务的运行时间进行控制。但是单片机的硬件资源通常有限,而且不同种类单片机的定时中断的代码是不同的,对程序代码的兼容性易用性提出很高的难度。相对而言,软件定时器是基于系统时钟中断且由软件来模拟的定时器,当经过设定的Tick 时钟计数值后会触发用户定义的回调函数。软件定时器不占用单片机宝贵的硬件资源和CPU资源。FreeRTOS提供了完善的软件定时器的支持,为了启用软件定时器,需要在头文件FreeRTOSConfig.h中设置configUSE_TIMERS的值为1.
软件定时器需要定时或者延时控制的函数称为回调函数。函数的原型如下:
void ATimerCallback( TimerHandle_t xTimer );
函数的返回值是空类型,xTimer的参数是软件定时器的具柄。回调函数的注意事项是代码应该尽可能地简短紧凑,并且避免调用FreeRTOS的API函数防止进入阻塞状态。
软件定时器的类型
软件定时器在FreeRTOS中分为两个类型
- 一次性定时器(One-shot timer)
- 自动重载定时器(Auto-reload timer)
一次性定时器启动后只会执行一次回掉函数;自动重载定时器会周期性地执行回调函数。
软件定时器的状态有以下两种
- 静止装态(Dormant)
- 运行状态(Running)
处于静止装态的定时器不会执行回调函数,可以通过调用定时器的具柄启用;处于运行状态的定时器会在设定的时间间隔(相对于定时器进入运行状态后)到达后调用回调函数。一次性定时器会在执行回调函数后进入静止状态,而自动重载定时器会在执行回调函数后重新进入运行状态。通过调用软件定时器相关的系统API函数可以在两种状态之间进行切换,其中两种定时器的状态转换图如下:
[图片上传失败...(image-c86b16-1652015272932)]自动重载定时器的状态转换图
[图片上传失败...(image-5987a-1652015272932)]一次性定时器的状态转换图
软件定时器的实质
软件定时器是由一个系统内核调度器自动生成的时间服务任务来管理。这个时间服务任务的优先级和栈大小由头文件FreeRTOSConfig.h中configTIMER_TASK_PRIORITY和configTIMER_TASK_STACK_DEPTH属性相应设定。定时器的回调函数调用FreeRTOS的API函数的话会导致时间服务任务管理进入阻塞状态,这种情况是要避免的。
针对软件定时器的命令如启动定时器,停止定时器和重置定时器等会通过命令队列发送到时间服务任务。队列长度由头文件FreeRTOSConfig.h中configTIMER_QUEUE_LENGTH 值设定。
图形化配置界面
在STM32CubeIDE的FreeRTOS配置选项中,Timers and Semaphores负责维护软件定时器和信号量(信号量的内容之后的文章会介绍到)。点击Add按钮就可以新增一个定时器,其中Callback选项是由用户实现的回调函数,Type选项可以选择一次性定时器或者自动重载定时器。当配置结束后,IDE会自动生成对应软件定时器的初始化代码,之后删除或修改都可以通过这个界面配置,极大减少用户的开发维护时间成本。
[图片上传失败...(image-a12024-1652015272932)]
定时器相关API函数
TimerHandle_t xTimerCreate( const char * const pcTimerName,
TickType_t xTimerPeriodInTicks,
UBaseType_t uxAutoReload,
void * pvTimerID,
TimerCallbackFunction_t pxCallbackFunction )
xTimerCreate()函数用于创建定时器
函数参数含义如下
- pcTimerName 定时器的名称,用于调试用
- xTimerPeriodInTicks 定时器的周期,dMS_TO_TICKS() 可把时间转成节拍数
- uxAutoReload 设置为pdTRUE为自动重载定时器,设置为pdFALSE为一次性定时器
- pvTimerID 定时器的ID,可以用于各种目的
- pxCallbackFunction 定时器执行的回调函数
- 返回值 返回值为NULL表示创建失败,非NULL表示创建成功
BaseType_t xTimerStart( TimerHandle_t xTimer, TickType_t xTicksToWait )
xTimerStart()函数用于启动定时器
函数参数含义如下
- xTimer 要执行定时器的具柄
- xTicksToWait 如果命令队列为满时函数进入阻塞状态等待命令队列有空的时间
- 返回值 返回值为pdPASS表示启动定时器成功;返回值为pdFALSE表示失败
void vTimerSetTimerID( const TimerHandle_t xTimer, void *pvNewID )
vTimerSetTimerID()用于设置定时器的pvTimerID 属性
函数参数含义如下
- xTimer 要执行定时器的具柄
- pvNewID 设定的pvTimerID的值
void *pvTimerGetTimerID( TimerHandle_t xTimer );
pvTimerGetTimerID()函数用于获取定时器的pvTimerID 属性
函数参数含义如下
- xTimer 要执行定时器的具柄
- 返回值 pvTimerID的值
BaseType_t xTimerChangePeriod( TimerHandle_t xTimer,
TickType_t xNewTimerPeriodInTicks,
TickType_t xTicksToWait );
xTimerChangePeriod函数用于改变定时器的运行周期
函数参数含义如下
- xTimer 要执行定时器的具柄
- xNewTimerPeriodInTicks 新的定时器的周期,dMS_TO_TICKS() 可把时间转成节拍数
- xTicksToWait 如果命令队列为满时函数进入阻塞状态等待命令队列有空的时间
- 返回值 返回值为pdPASS表示成功;返回值为pdFALSE表示失败
BaseType_t xTimerReset( TimerHandle_t xTimer, TickType_t xTicksToWait );
xTimerReset()函数用于重置定时器,定时器会重新运行并以此计算新的时间间隔
函数参数含义如下
- xTimer 要执行定时器的具柄
- xTicksToWait 如果命令队列为满时函数进入阻塞状态等待命令队列有空的时间
- 返回值 返回值为pdPASS表示成功;返回值为pdFALSE表示失败
软件定时器的案例
在这里笔者会举一个使用软件定时器的例子,本案例是通过一个定时器实现LED闪烁的效果
首先在t配置界面中新建一个定时器,设置如下
[图片上传失败...(image-6348ed-1652015272932)]定时器的配置
然后我们可以看到IDE在main.c中自动生成了如下的代码
/* Definitions for LedBlink */
osTimerId_t LedBlinkHandle;
const osTimerAttr_t LedBlink_attributes = {
.name = "LedBlink"
};
void CallbackBlink(void *argument);
int main(void)
{
...
/* Create the timer(s) */
/* creation of LedBlink */
LedBlinkHandle = osTimerNew(CallbackBlink, osTimerPeriodic, NULL, &LedBlink_attributes);
/* Start scheduler */
osKernelStart();
...
/* We should never get here as control is now taken by the scheduler */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
};
/* CallbackBlink function */
void CallbackBlink(void *argument)
{
/* USER CODE BEGIN CallbackBlink */
/* USER CODE END CallbackBlink */
}
在上面的代码中可以看到定时器的初始化代码骨架已经由IDE替我们搭建完毕,我们只要实现回调函数CallbackBlink()就好了
在这里我再贴上完整的实现代码,LED灯和PG13输入输出口相连
/* Definitions for LedBlink */
osTimerId_t LedBlinkHandle;
const osTimerAttr_t LedBlink_attributes = {
.name = "LedBlink"
};
void CallbackBlink(void *argument);
int main(void)
{
...
/* Create the timer(s) */
/* creation of LedBlink */
LedBlinkHandle = osTimerNew(CallbackBlink, osTimerPeriodic, NULL, &LedBlink_attributes);
/* start timers, add new ones, ... */
//配置PG13口为输出口
TM_GPIO_Init(GPIOG,GPIO_Pin_13,TM_GPIO_Mode_OUT,TM_GPIO_OType_PP,TM_GPIO_PuPd_UP,TM_GPIO_Speed_High);
osTimerStart(LedBlinkHandle, 1000);
/* USER CODE END RTOS_TIMERS */
...
/* Start scheduler */
osKernelStart();
/* We should never get here as control is now taken by the scheduler */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
};
/* CallbackBlink function */
void CallbackBlink(void *argument)
{
/* USER CODE BEGIN CallbackBlink */
//反转PG13口的输出口,实现LED灯闪烁的效果
TM_GPIO_TogglePinValue(GPIOG,GPIO_Pin_13);
/* USER CODE END CallbackBlink */
}
在上面代码中, osTimerStart(LedBlinkHandle,1000)负责启动定时器,并设置定时器任务为每隔1000 TICKS时间启动。下载到开发板后可以观察到LED灯每个一秒闪烁。
FreeRTOS 软件定时器组的时基是基于系统时钟节拍实现的,之所以叫软件定时器是因为它的实现不需要使用任何硬件定时器,而且可以创建很多个,具体功能由每个定时器绑定的回调函数实现。
中断管理
什么是中断
通俗来说,当CPU在执行某一事件A时,发生另外一个更重要紧急的事件B请求CPU去处理(产生了中断),于是CPU暂时中断当前正在执行的事件A任务而对对事件B进行处理,CPU处理完事件B后再返回之前中断的位置继续执行原来的事件A,这一过程统称为中断。
笔者在网上看到了一篇文章很形象地介绍了中断。
请设想这样一个场景:此刻我正在厨房用煤气烧一壶水,而烧开一壶水刚好需要 10 分钟,我是一个主体,烧水是一个目的,而且我只能时时刻刻在这里烧水,因为一旦水开了,溢出来浇灭煤气的话,有可能引发一场灾难。但就在这个时候呢,我又听到了电视里传来《天龙八部》的主题歌,马上就要开演了,我真想夺门而出,去看我最喜欢的电视剧。然而,听到这个水壶发出的“咕嘟”的声音,我清楚:除非等水烧开了,否则我是无法享受我喜欢的电视剧的。
这里边主体只有一个我,而我要做的有两件事情,一个是看电视,一个是烧水,而电视和烧水是两个独立的客体,它们是同时进行的。其中烧水需要 10 分钟,但不需要了解烧水的过程,只需要得到水烧开的这样一个结果就行了,提下水壶和关闭煤气只需要几秒的时间而已。所以我们采取的办法就是:烧水的时候,定上一个闹钟,定时 10 分钟,然后我就可以安心看电视了。当 10 分钟时间到了,闹钟响了,此刻水也烧开了,我就过去把煤气灭掉,然后继续回来看电视就可以了。
这个场景和单片机有什么关系呢?
在单片机的程序处理过程中也有很多类似的场景,当单片机正在专心致志的做一件事情(看电视)的时候,总会有一件或者多件紧迫或者不紧迫的事情发生,需要我们去关注,有一些需要我们停下手头的工作去马上去处理(比如水开了),只有处理完了,才能回头继续完成刚才的工作(看电视)。这种情况下单片机的中断系统就该发挥它的强大作用了,合理巧妙的利用中断,不仅可以使我们获得处理突发状况的能力,而且可以使单片机能够“同时”完成多项任务。
中断的注意事项
单片机通常拥有丰富的片内外设和很多中断源,中断程序相比于在主程序中使用循环语句轮询系统状态(Polling)能有效提高CPU的利用效率,同时能都够更加及时地响应外部事件。*中断系统虽然强大,但中断系统的引进有时会产生一些意想不到的问题。在FreeRTOS中,任务程序由内核调度器统一调度和执行,程序的运行情况在一定程度上是稳定可预测的;中断相对而言是由外部环境决定是不可预测的,会对系统引入不确定性,如果使用不当的话会导致系统的失效和不稳定。*就比如在上面设想的场景中,看电视可以作为一个主要任务,如果不同的事情同时发生,比如老板发信息催你迅速交报告,外卖到了要你下楼去拿一下等预期外的事件发生需要处理,你觉得你还能好好欣赏电视剧吗?所以在很多安全相关的程序中,通常为了稳定会减少对中断的使用。*总而言之,对中断的使用要根据实际需求和使用场景来决定,通过合理的配置和与FreeRTOS的中断管理(Interrupt Management)配合可以达到系统的高效能和高稳定性。*
FreeRTOS的中断管理
FreeRTOS对于中断没有特别的处理程序,但提供一些特性和系统API函数方便用户可以简单地实现和维护中断管理。首先我们要明白,在FreeRTOS中中断的优先级和任务的优先级是有区别的。可以把任务类比是人间模式,而中断是上帝模式,拥有更高的权限。
- 任务的优先级是由用户设置内核管理器管理的软件特性(software feature),与操作系统所在的硬件平台无关
- 中断的优先级是由硬件平台相关的硬件特性(hardware feature),在中断代码运行的时候任务的代码将无法运行。*即使是拥有最小优先级的中断也会打断拥有最高优先级的任务。*
FreeRTOS对于一些系统API函数提供两种版本,一种是供任务调用的,一种是供中断调用的(Interrupt Safe API)。由中断调用的API函数后缀上会有“FromISR”。
在任务中系统API函数被调用后,如果有更高优先级的任务处于就绪状态的话,内核调度器会自动执行有更高优先级的任务。(需要在FreeRTOSConfig.h头文件中设置configUSE_PREEMPTION为1)。在中断处理代码中系统API函数被调用后,内核调度器不会自动切换到有更高优先级的任务,会把系统API函数中指针参数pxHigherPriorityTaskWoken设置为pdTRUE,然后由用户根据pxHigherPriorityTaskWoken的值自行决定是否切换任务。在中断处理代码中,切换任务的系统API函数如下
portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
如果xHigherPriorityTaskWoken的值是pdFALSE的话,任务切换不会发生;如果xHigherPriorityTaskWoken的值是pdTRUE的话,任务切换会发生。
延迟中断处理
中断处理程序中代码可以尽量简短,并把处理中断的代码放在一个中断任务里。这种方法称为延迟中断处理(deferred interrupt processing)。*通过延迟中断处理用户可以把中断处理当成由调度器调度的一个任务并分配优先级,此时中断的优先级和任务的优先级是一致的。*用户可以把中断任务的优先级设置为最高级,当中断发生在中断处理程序调用portYIELD_FROM_ISR()函数后中断任务将会优先执行。当然如果程序简单的话用户可以直接在中断处理程序中处理中断。
伪代码实现
下面是延迟中断处理的伪代码实现
void InterruptHandling()
//模拟中断处理函数,中断处理函数是硬件平台相关,不属于FreeRTOS范畴
{
RecordInterrupt();//记录一下中断的信息方便中断任务判断中断来源等
ClearInterruptFlag();//清除中断标记,使能中断
portYIELD_FROM_ISR(pdTRUE);//切换任务,如果中断任务的优先级设置为最高级将会优先执行
}
void vInterruptHandling(void const * argument)
//FreeRTOS建立的中断任务,处理中断
{
ProcessInterrupt();
//因为中断的来源有很多,需要通过对中断的记录判断中断来源并执行相应中断处理代码
}
当中断产生进入中断服务程序InterruptHandling()后,中断服务程序会通过portYIELD_FROM_ISR()函数切换任务,此时优先级最高的中断处理函数vInterruptHandling()会抢占执行,并对中断进行分析和处理。
资源管理
什么是资源管理
资源管理(Resource Management)顾名思义,就是对资源的管理。资源在FreeRTOS中可以表现为一个负责保存数据的全局变量,一个队列上的数据等需要在任务之间共享的数据或者对UART串口的操作资源等。
资源管理主要包括两个方面内容--数据的同步机制*和资源的保护机制*。
数据的同步与信号量
数据的同步是指,如何能通知任务新数据的到来并同时提高CPU的利用率。假设一个简单的生产者消费者模型--有两个任务,一个任务是数据的生产者(Producer)任务,一个任务是数据的消费者(Consumer)任务,消费者任务等待生产者任务产生的数据并进行处理。按照正常的思路,消费者任务和生产者任务会轮流执行,但是如果在消费者任务执行的时候数据还没有产生的话,消费者任务的运行就是无用的,会降低CPU的使用效率。为此,FreeRTOS引入了信号量(Semaphore)概念,通过信号量的同步机制可以使消费者任务在数据还没到达的时候进入阻塞状态,并让出CPU资源给其他任务。信号量是一种同步机制,可以起到消息通知和提高CPU利用率的作用。*对于信号量的操作有两种,获取信号量(taking a semaphore)和给予信号量(giving a semaphore)。在生产者消费者模型中,生产者生产数据后给予信号量,消费者获取信号量后可以处理数据。信号量又分为二进制信号量(binary semaphore)*和*计数信号量(counting semaphore)*。二进制信号量中信号量的数目最多为1,即最多只能通知解锁一个任务;计数信号量中信号量的数目可以自定义设定为多个,即可以通知解锁多个任务。
信号量的示例
下图展示了二进制信号量(binary semaphore)和计数信号量(counting semaphore)的示例
[图片上传失败...(image-c81364-1652015272932)]信号量的示例
可以看出,左边二进制信号量中信号量的数目最多为1,最多只能通知解锁一个任务;右边计数信号量中信号量的数目为多个,可以通知解锁多个任务。接收到信号量的任务则根据情况可以进入运行状态或者就绪状态。
通常可以把二进制信号量(binary semaphore)类比成含有一个“钥匙”的队列,计数信号量(counting semaphore)可以类比成含有多个“钥匙”的队列。
信号量的常用函数
SemaphoreHandle_t xSemaphoreCreateBinary( void )
通过xSemaphoreCreateBinary()函数可以创建一个二进制信号量,如果返回值是NULL的话表示创建信号量失败(通常是因为没有足够可用的堆内存)。如果返回值非空的话表示创建成功,返回值即为所创建信号量的具柄。
BaseType_t xSemaphoreTake( SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait )
xSemaphoreTake()函数表示获取信号量,函数的参数如下
- *xSemaphore* 信号量的句柄
- *xTicksToWait* 如果信号量不可用的话任务处于阻塞状态的最长时间,设置为 portMAX_DELAY的话任务会一直处于阻塞状态直到信号量可用,设置为0的话如果信号量不可用的话会直接返回
函数的返回值为pdPASS表示成功获取了信号量,返回值为pdFALSE表示获取信号量失败
BaseType_t xSemaphoreGive( SemaphoreHandle_t xSemaphore );
xSemaphoreGive()函数表示给予信号量,函数的参数如下
- *xSemaphore* 信号量的句柄
函数的返回值为pdPASS表示成功给予信号量,返回值为pdFALSE表示给予信号量失败
在中断函数中应该使用如下版本
BaseType_t xSemaphoreGiveFromISR( SemaphoreHandle_t xSemaphore,
BaseType_t *pxHigherPriorityTaskWoken )
信号量的应用伪代码
首先申明个信号量的全局变量
SemaphoreHandle_t xBinarySemaphore;
然后在main函数中创建信号量
int main(void)
{
...
xBinarySemaphore = xSemaphoreCreateBinary();
...
}
void Task_Producer(void const * argument)
{
//生产者任务负责生产数据并给予信号量
for( ;; )
{
...
if(true==produce_some_data())
{
xSemaphoreGive(xBinarySemaphore);
}
...
}
}
void Task_Consumer(void const * argument)
{
//消费者任务等待获取信号量并进行数据处理
for( ;; )
{
xSemaphoreTake( xBinarySemaphore, portMAX_DELAY );
//下面是对数据的处理函数
...
}
}
*二进制信号量适用于在数据产生的频率比较低的场合。如果数据产生的频率较高,因为信号量最多只能保存一个信号,更多产生的数据将会直接被忽略抛弃。对此,我们需要使用计数信号量(counting semaphore)。*
计数信号量相关的函数如下
SemaphoreHandle_t xSemaphoreCreateCounting( UBaseType_t uxMaxCount,
UBaseType_t uxInitialCount );
xSemaphoreCreateCounting()用于创建计数信号量,函数的参数如下
- *uxMaxCount* 计数信号量包含信号量的最大值
- *uxInitialCount* 计数信号量中信号量的初始值
计数信号量创建好对信号量的操作函数和二进制信号量一样,所以应用代码参照二进制信号量
资源的保护与互斥量
资源的保护是指,如何保护资源不被多个任务同时操作。如果一个保存数据的全局变量在被一个任务操作的过程中,又被多个更高优先级的任务抢占并处理,那么最后这个支离破碎的数据将会毫无意义,破坏了数据的独立完整性;如果一个任务在使用uart串口发送数据的时候,又被多个更高优先级的任务抢占串口资源并发送数据,那么最后串口发送的数据将只是一堆乱码毫无意义,会导致系统的不稳定。
在一定程度上,使用上文介绍的信号量不仅可以同步数据,也可以起到资源保护的作用。
[图片上传失败...(image-b4869c-1652015272932)]信号量用于资源保护的作用
在上图中,有个低优先级的任务(LP)和高优先级的任务(HP),两个任务都可以对一个资源进行操作。为了对资源进行保护采用了信号量的机制。LP首先获得信号量可以对资源进行操作,在时刻1,HP因为优先级高在内核调度中抢占了LP,在时刻2,HP想获得信号量但失败因此进入了阻塞状态。之后LP可以继续对资源操作,在时刻3执行完毕后归还了信号量。在时刻4,HP因为优先级高在内核调度中抢占了LP并获取了信号量可以对资源可以操作。
优先级倒置
在上面这个示例中,不同优先级的任务采用信号量机制可以独立地使用资源并不会被打断,因此保护了资源。*但采用信号量保护资源的话会有个弊端,有时会产生一种现象叫做优先级倒置(Priority inversion )。*下图将介绍这个现象。
[图片上传失败...(image-22628a-1652015272932)]优先级倒置
这个示例和上图示例基本相同,就是多了一个优先级介于LP和HP之间的任务(MP),问题发生在时刻3。此时低优先级的任务获得了信号量和对资源的操作权,但被优先级更高的MP抢占了并执行。最高优先级的任务HP等待低优先级任务LP返还信号量,而低优先级因为被中等优先级的任务MD抢占无法返回信号量。*从外部的视角看,此时中等优先级的任务MD相当于抢占了高优先级的任务HP,而高优先级的任务HP表现起来却又像是有最低的优先级。这种不正常的现象就被称为优先级倒置。优先级倒置是采用信号量保护资源可能会产生的负面效果。*
什么是互斥量
FreeRTOS为了解决资源保护的问题引入了互斥量(Mutex)。互斥量又是何方神圣,如何解决优先级倒置的问题呢?
互斥量是二进制信号量的一个变种*,开启互斥量需要在头文件FreeRTOSConfig.h设置configUSE_MUTEXES* 为1。互斥量和信号量的主要区别如下
- 互斥量用于保护资源时必须要被返还
- 信号量用于数据同步时不需要返还
互斥量操作的相关函数
SemaphoreHandle_t xSemaphoreCreateMutex( void )
xSemaphoreCreateMutex()函数用于创建互斥量
互斥量的应用伪代码
首先申明个互斥量的全局变量
SemaphoreHandle_t xMutex;
然后在main函数中创建互斥量
int main(void)
{
...
xMutex = xSemaphoreCreateMutex();
...
}
void Function_Resource(void const * argument)
{
//要保护的资源函数
...
xSemaphoreTake( xMutex, portMAX_DELAY );
{
//对资源的处理
...
}
xSemaphoreGive( xMutex );
...
}
在上段代码中,如果有多个任务要调用资源函数的话,通过资源函数内部的互斥量机制可以保护资源不被其他任务打断。关于互斥量如何解决优先级倒置*的问题,FreeRTOS为互斥量赋予了优先级继承(priority inheritance)*的特性。优先级继承会暂时提高获得互斥量的任务的优先级,使得含有互斥量的任务的优先级和想要获取互斥量的任务中的最高优先级一样。互斥量无法彻底避免*优先级倒置*的问题,但能显著降低优先级倒置发生的概率。
关键区
关键区(Critical Section)是资源保护的另一种方法。在FreeRTOS有两个宏定义,分别是taskENTER_CRITICAL()*和taskEXIT_CRITICAL()*。
代码如下
taskENTER_CRITICAL();
...
taskEXIT_CRITICAL();
taskENTER_CRITICAL()*宏定义会关闭所有中断包括内核切换所在的中断,taskEXIT_CRITICAL()*宏定义会再次打开中断。所以处于宏定义之间的代码可以被独占地执行,这是保护资源的一种比较暴力的方式。