前言
这边插一篇介绍FreeRTOS的文章,因为我在写后续快速上手ESP32系列的文章的时候发现FreeRTOS是越不过去的坎,因此这边补充一下。
FreeRTOS
FreeRTOS是一款广泛使用的开源实时操作系统(RTOS),为嵌入式开发提供了可靠、高效的实时调度和任务管理。它由英国工程师Richard Barry创建,是一款免费、开源且小巧的RTOS,非常适合在资源有限的微控制器中运行。FreeRTOS具有可裁剪、可固化到ROM的抢占式实时内核,并且可管理的任务数量不受限制。
FreeRTOS支持多种调度算法,包括抢占式和非抢占式,这使得用户可以根据具体应用的需求进行选择。此外,它还提供了一系列的进程间通信(IPC)机制,如消息队列、信号量、互斥锁等,以确保任务能够安全、有序地协同工作。这种任务间的通信和同步机制是FreeRTOS广泛应用于实时嵌入式系统的关键之一。
实时系统需要能够对时间进行精确的管理,以满足任务的实时性要求。FreeRTOS提供了定时器和中断处理功能,使得用户能够在特定的时间点执行任务,实现系统的高精度时间控制。它还支持多种处理器体系结构和编译器,使得用户能够方便地将其移植到不同的硬件平台上,为嵌入式系统的设计提供了更大的自由度。
以上是文心一言的介绍。
我们就简单记着FreeRTOS是一个很小巧的适用于嵌入式开发的操作系统,可以参考Linux系统下编程,大体上是差不多的。
ESP-IDF中的FreeRTOS
ESP-IDF中天然支持FreeRTOS,因此我们可以比较方便地使用。
先看看编程指南中的介绍。
总而言之就是ESP-IDF已经集成了FreeRTOS,并且我们一直都在使用。
小伙伴会疑惑,啊?之前的代码都没有涉及到FreeRTOS呀,怎么说是一直在使用。
因为我们之前的程序都是写在app_main里的,而app_main正是ESP-IDF中FreeRTOS的用户应用程序的入口点。也可以当成app_main就是主进程,不过在FreeRTOS中我们是说任务。
那么除了app_main这个任务之外,还会有其他任务,比如下面这些。
那么除了上面这些已经有的任务,我们也可以另外再自己创建别的任务(不能和上面的重名)。
FreeRTOS常用函数
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
任务
创建
BaseType_t xTaskCreate( TaskFunction_t pxTaskCode, // 函数指针, 任务函数
const char * const pcName, // 任务的名字
const configSTACK_DEPTH_TYPE usStackDepth, // 栈大小,单位为word,10表示40字节
void * const pvParameters, // 调用任务函数时传入的参数
UBaseType_t uxPriority, // 优先级
TaskHandle_t * const pxCreatedTask ); // 任务句柄, 以后使用它来操作这个任务
上面这个函数就可以创建出一个新的任务。
第一个参数传入函数指针,这个函数里的内容就是我们这个任务要执行的内容。需要注意的是这个任务无返回值,可以接受一个void*类型的参数(也可以不接收)。另外这个函数要么内部是死循环,要么需要自己把自己杀死,否则编译报错。具体如何杀死自己,后面会介绍。
第二个参数传入一个字符串表示这个任务的名字,这个随便起,不懂起名字的小伙伴可以和自己的任务函数的函数名一致(记得是传入字符串)。
第三个参数传入这个任务函数的栈大小,单位是字(word),我们ESP32是32位的,因此一个字是4个Byte,这个单位换算要清楚。每个任务函数都有各自独立的栈,我们需要各自指定栈的大小,这个栈的大小我们大概的估计一下就行,不用很精确。一般来说不确定就给个1024,多余总比不够用好,不过还是要具体问题具体分析。
第四个参数传入给任务函数传的参数,没有就填NULL。
第五个是优先级,数值越小优先级越小(注意这个,这边和STM32中的优先级不一样)。优先级用于任务的调度,也就是说多个任务理论上是可以看成是并行的,这个我们后面会验证。
优先级的数值我们可以根据宏定义configMAX_PRIORITIES来判断,这个宏定义就是我们能用的最大的优先级+1。
跳转定义之后我们发现最大的25,因此我们能传入的优先级范围是0~24,也就是说不管我们传入多大的值,最终也就是最大24(configMAX_PRIORITIES-1)。不过不建议把优先级调太大,因为优先级太大会浪费内存和时间。
第六个是任务句柄,是传出参数,我们可以先定义一个任务句柄,然后在创建任务的时候传进去。任务句柄主要是用来删除任务的,当然删除任务并不一定是要使用到任务句柄,也可以让任务自杀,这也是上面第一个参数提到的。
另外还有一个ESP-IDF特有的创建任务的函数。
和上面的相比多了一个参数,也就是指定将任务固定到哪个核心,因为我们知道ESP32是双核的。不过一般情况我们使用上面那个创建任务的方法就行。
删除
删除有两种办法,一种是我们手动删除,另一种是自己删除自己。
我们拿着创建任务的任务句柄,用下面的函数就能把对应的任务删除。
void vTaskDelete( TaskHandle_t xTaskToDelete );
如果我们在创建任务的时候传入的任务句柄是NULL,那么就没有任务句柄,这种情况下也可以让任务自己删除自己。
还是上面这个函数,我们传入NULL,那就是自己把自己这个任务删除了。
接下来直接演示一下,我们创建一个任务,在任务和主任务的死循环中各自打印一句话。
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
void Z_Task(void){
while(1){
printf("Z_TAsk\r\n");
vTaskDelay(1000/portTICK_PERIOD_MS);
}
}
void app_main(void){
xTaskCreate(Z_Task,"Z_Task",1024,NULL,1,NULL);
while (1){
printf("Hello World\r\n");
vTaskDelay(1000/portTICK_PERIOD_MS);
}
}
看的出来两个任务是交替着输出的。
同时也看到了我使用的延时函数跟我们之前用的sleep不一样。
那我们再看看延时函数。
延时
void vTaskDelay( const TickType_t xTicksToDelay );
传入一个32位的数,表示我们至少延时这么多个的Tick中断,也就是说可能会延时更长的时间(一般不会差多少)。
这个延时函数跟我们之前用的不一样,因为它的FreeRTOS里的,因此当我们使用了这个延时函数之后,实际上不只是单纯的延时,而是在延时的同时把控制权交给其他任务。也就是说我们之前的延时函数只是单纯的死循环空转,没有任何意义。而这个延时函数可以在“死循环空转”的时候让其他任务正常运行。
那么Tick是什么呢?
在FreeRTOS中,Tick是一个核心概念,用于计时和任务调度。它代表了一个基本的时间单位,通常表示为一个整数值,可以映射到实际时间。Tick的精度和长度是可配置的,大多数情况下,它是以毫秒为单位的,但也可以配置为更短或更长的时间间隔。
FreeRTOS通过硬件定时器或软件定时器,每隔一定的时间(即Tick间隔)产生一次中断,这就是所谓的系统心跳。这个心跳对于FreeRTOS来说至关重要,它确保了任务能够按照预定的时间间隔执行,并为相同优先级的任务提供了轮流执行的基础。
在创建定时器时,Tick也起到了关键作用。开发者可以指定定时器的周期,即多少个Tick执行一次。这样,FreeRTOS就能根据这个周期来触发定时器的回调函数,实现定时功能。
可以大概的把Tick当成是时钟脉冲信号(实际上不是),那么我们如何知道每个Tick是多长的时间呢?
我们不需要知道,我们只需要把传入的参数除一个宏定义portTICK_PERIOD_MS即可,就像下面这样。
vTaskDelay(1000/portTICK_PERIOD_MS);
这样1000/portTICK_PERIOD_MS它所表示的数量的Tick的时间就是1000ms。
我们可以看看这个宏定义是什么东西。
所以portTICK_PERIOD_MS实际上就是10。因此vTaskDelay这个延时函数最少是延时10ms,因为我们要除portTICK_PERIOD_MS,也就是除10。
如果我们要延时小于10ms的时间那就还是只能用usleep这个函数了。或者去修改每个Tick的时间。
怎么修改呢?
打开查看 -> 命令面板
然后找到Tick的频率。
默认是100,所以我们在上面延时函数最少就是延时10ms。
队列
#include "freertos/queue.h"
队列我们都知道,是一种数据结构,是一个先进先出的容器。
在FreeRTOS中,队列可以用来在不同的任务以及不同的中断之间传递信息。
创建&删除
QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
参数一指定队列的长度,参数二指定每个元素的大小,单位是Byte。
这个函数会返回队列的句柄,我们就是用句柄来操作队列的。
删除也很容易,使用下面这个函数,把队列的句柄传进去就可以了。
void vQueueDelete( QueueHandle_t xQueue );
写数据
//往队列尾部写入数据,如果没有空间,阻塞时间为xTicksToWait
BaseType_t xQueueSend(QueueHandle_t xQueue,const void *pvItemToQueue,TickType_t xTicksToWait);
//往队列尾部写入数据,如果没有空间,阻塞时间为xTicksToWait
BaseType_t xQueueSendToBack(QueueHandle_t xQueue,const void *pvItemToQueue,TickType_t xTicksToWait);
//往队列尾部写入数据,此函数可以在中断函数中使用,不可阻塞
BaseType_t xQueueSendToBackFromISR(QueueHandle_t xQueue,const void *pvItemToQueue,BaseType_t *pxHigherPriorityTaskWoken);
//往队列头部写入数据,如果没有空间,阻塞时间为xTicksToWait
BaseType_t xQueueSendToFront(QueueHandle_t xQueue,const void *pvItemToQueue,TickType_t xTicksToWait);
//往队列头部写入数据,此函数可以在中断函数中使用,不可阻塞
BaseType_t xQueueSendToFrontFromISR(QueueHandle_t xQueue,const void *pvItemToQueue,BaseType_t *pxHigherPriorityTaskWoken);
有上面五种写数据的方法。
前两个实际上是一样的,所以可以当成是四种。
简单来说可以分为两类,从头写或从尾写,还有普通写或中断中写(不可阻塞),两类分四种。
参数应该都能看懂,队列句柄,传入的数据的指针,超时时间(Tick)。在不可阻塞的函数中没有超时时间,取而代之的是pxHigherPriorityTaskWoken这个参数,这个参数是传出参数,我们传入一个BaseType_t类型的变量的指针,之后根据这个变量的值判断是否有别的任务因为这次写入而解除阻塞,如果有,那么这个变量的值是pdTRUE。
比如说我现在需要读取队列的数据,但是队列此时没有数据可供我读取,那么我将阻塞,直到队列中有数据可以读取。
那么别的任务中写入数据之后,我就可以读取数据了,我也就解除了阻塞,那么在别的任务中,写入数据的函数中的参数pxHigherPriorityTaskWoken就会变成pdTRUE。
读数据
BaseType_t xQueueReceive(QueueHandle_t xQueue,void * const pvBuffer,TickType_t xTicksToWait );
BaseType_t xQueueReceiveFromISR(QueueHandle_t xQueue,void *pvBuffer,BaseType_t *pxTaskWoken);
读取数据比较简单,就分为两类,第一个是普通的读取,第二个是在中断中读取。
区别还是中断中不能阻塞。
在等待时间中有两个特殊的写法,一个是填0表示不阻塞,没读到数据就立刻返回。另一个是portMAX_DELAY表示没读到数据就一直阻塞。
查询
//返回队列中可用数据的个数
UBaseType_t uxQueueMessagesWaiting(const QueueHandle_t xQueue);
//返回队列中可用空间的个数
UBaseType_t uxQueueSpacesAvailable(const QueueHandle_t xQueue);
基本的操作就上面这些,接下来我们可以来结果小实验验证一下队列是否可以在不同的任务中传递数据。
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
QueueHandle_t Z_Queue;
void Z_Task(void* a){
int date=0;
while(1){
if(xQueueSendToBack(Z_Queue,&date,100/portTICK_PERIOD_MS)){
printf("Z_Task send date %d\r\n",date);
}
vTaskDelay(1000/portTICK_PERIOD_MS);
++date;
}
}
void app_main(void){
Z_Queue=xQueueCreate(10,sizeof(int));
if(Z_Queue!=NULL) printf("create queue success\r\n");
xTaskCreate(Z_Task,"Z_Task",2048,NULL,1,NULL);
int date=0;
while (1){
if(xQueueReceive(Z_Queue,&date,portMAX_DELAY)){
printf("main receive date is %d\r\n",date);
}else{
printf("not receive date\r\n");
}
}
}
看的出来可以正常的进行两个任务之间的信息交互。
如果是需要在中断中进行数据的传递,只需要把读和写的函数按照需求改成不可阻塞的版本即可。
信号量
#include "freertos/semphr.h"
信号量这个东西,相信懂得Linux编程或者是学习过操作系统这门课程的小伙伴应该不陌生。
信号量就是用来保证数据的原子性的。也就是同一个变量,我在同一时间在不同的任务之间只能有一个任务可以进行操作,用来保证只有一个任务可以操作这个公共变量的手段就是信号量(另外还有互斥量,其实差不多)。
照例还是让文心一言来介绍一下。
信号量(Semaphore)是在多线程环境下使用的一种设施,主要用于实现进程间的互斥和同步操作,保证临界资源的正确访问和共享。它可以看作是一个计数器,用来记录临界资源内资源可用数,确保在进入临界区时有资源可用。
在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。
信号量有多种用途,例如:
- 线程同步:通过信号量,可以同步两个线程的执行。当一个线程需要等待另一个线程完成某项任务时,可以使用信号量来实现。
- 中断与线程同步:当中断触发时,中断服务程序可以通知线程进行相应的数据处理。通过释放信号量,可以唤醒挂起的线程,使其继续执行后续的处理。
- 锁(二值信号量):信号量也可以用作锁,保证对共享资源的互斥访问。通常将信号量资源个数初始化为1,表示默认只有一个资源可用。
总的来说,信号量是一个强大的同步工具,能够有效地管理多线程环境中的资源访问和代码执行顺序,确保系统的稳定性和数据的一致性。
创建&删除
首先信号量分为两种,二进制信号量和计数型信号量。区别就在于二进制信号量只有两种状态,也就是0和1。因此如果计数型信号量只让它记一个数,实际上和二进制信号量是没什么差别的,所以一般情况下我不用二进制信号量。
两种信号量的创建方式如下。
//创建一个二进制信号量,初始值为0,返回它的句柄。
SemaphoreHandle_t xSemaphoreCreateBinary( void );
//创建一个计数型信号量,返回它的句柄。
SemaphoreHandle_t xSemaphoreCreateCounting(UBaseType_t uxMaxCount, UBaseType_t uxInitialCount);
创建二进制信号量不需要参数。
创建计数型信号量的第一个参数指定最大计数值,第二个指定初始值。如果给的参数是1和0,那么和二进制信号量没有什么差别。
删除的话就用下面的函数,传入信号量句柄即可。
void vSemaphoreDelete( SemaphoreHandle_t xSemaphore );
PV / take&give
学过操作系统的小伙伴应该更习惯PV操作这个说法,不过FreeRTOS的函数名是take和give。
take是拿的意思,也就是让信号量减一,对应的是P操作。
give是给的意思,也就是让信号量加一,对应的是V操作。
从字面意思上看take和give其实更直观了,因此我们下面统一说take和give。
give和take各自有两个函数,名字带有FromISR的就是在中断中使用,不可阻塞的。
BaseType_t xSemaphoreGive(SemaphoreHandle_t xSemaphore);
BaseType_t xSemaphoreGiveFromISR(SemaphoreHandle_t xSemaphore,BaseType_t *pxHigherPriorityTaskWoken);
先看give的第一个函数,就给一个参数,也就是信号量句柄。
第二个函数多了个参数,是传出参数,如果因为这次的give,有任务变成了就绪态,那么这个参数就会被赋值pdTRUE,就当普通的True使用就可以。
BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore,TickType_t xTicksToWait);
BaseType_t xSemaphoreTakeFromISR(SemaphoreHandle_t xSemaphore,BaseType_t *pxHigherPriorityTaskWoken);
take的第一个函数,传入信号量句柄和最长等待时间,可以参考上面队列的超时时间写法。
第二个函数和第一个相比,把等待时间替换成了传出参数,跟give的一样。
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
SemaphoreHandle_t Z_Sem;
void Z_Task(void* a){
while(1){
if(xSemaphoreTake(Z_Sem,1000/portTICK_PERIOD_MS)){
printf("I take the date!\r\n");
}
vTaskDelay(500/portTICK_PERIOD_MS); //0.5秒对信号量进行一次take操作.
}
}
void app_main(void){
Z_Sem=xSemaphoreCreateCounting(3,0);
if(Z_Sem!=NULL) printf("create sem success\r\n");
if(xTaskCreate(Z_Task,"Z_Task",2048,NULL,1,NULL)) printf("create task success\r\n");
while (1){
if(xSemaphoreGive(Z_Sem)){
printf("give success!\r\n");
}else{
printf("give fail!\r\n");
}
vTaskDelay(400/portTICK_PERIOD_MS);
}
}
在上面代码中我在主任务中每400ms对信号量进行一次give操作,在另外一个任务中每500ms对信号量进行一次take操作。
从上面结果的截图可以看出正常情况下是可以正常的give和take,但是由于give的速度更快,因此也会出现give失败的情况。
互斥量
我觉得互斥量和二进制信号量差不多。所以它的头文件和信号量是同一个。
但还是问问文心一言。
二进制信号量和互斥量在多个方面存在显著的区别。
首先,从基本概念来看,二进制信号量(也叫二值信号量)是一个开关,其状态只能是0或1。它主要用于控制对共享资源的访问,通过Take和Give的方式获取和释放。在每次访问共享资源之前,需要获取二进制信号量,若已被获取则任务会被阻塞直到二进制信号量可用。并且,二进制信号量可以通过多次获取而被同一个任务持有,即可用于同一任务对多个共享资源的排他性访问。
互斥量(又称互斥信号量)则是一种特殊的二值信号量,它支持互斥量所有权、递归访问以及防止优先级翻转的特性。任意时刻互斥量的状态只有两种:开锁或闭锁。当互斥量被任务持有时,该互斥量处于闭锁状态,这个任务获得互斥量的所有权。持有该互斥量的任务也能够再次获得这个锁而不被挂起,这是递归互斥量的特性。
其次,从应用角度看,二进制信号量主要用于实现同步或互斥,它没有优先级反转保护,不支持优先级继承协议和嵌套。不同的任务都可以对二进制信号量进行释放,甚至在中断任务中也可以释放。然而,互斥信号量主要用于解决互斥问题,它具有优先级反转保护,支持优先级继承协议。对于互斥信号量,只有申请该信号的任务才能释放它,其他任务或中断处理程序都无法提取和释放它。
小小的总结一下,互斥量更适用于让某段逻辑具有原子性,并且互斥量能导致优先级反转、优先级继承。
互斥量的初始值是1,如果要使用互斥量使得某段逻辑具有原子性,那么需要在逻辑开始之前进行take的操作,这样别的任务就take不到这个互斥量了,在逻辑结束之后再give。使用起来其实和信号量是一样的。
不一样的是互斥量无法在中断中使用,这个也好理解,因为当互斥量被take之后,其他任务要获取互斥量就会被阻塞,而中断中无法阻塞。
创建&删除&take&give
SemaphoreHandle_t xSemaphoreCreateMutex(void);
创建不需要参数,并且句柄的类型和信号量的类型是同一个。
void vSemaphoreDelete(SemaphoreHandle_t xSemaphore);
删除传入句柄。
BaseType_t xSemaphoreGive(SemaphoreHandle_t xSemaphore);
BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore,TickType_t xTicksToWait);
give和take和信号量也差不多,不过没有ISR的。
但互斥锁和信号量还是有区别的,那就是优先级方面的。
如果一个任务获了互斥锁,导致了另一个任务进入阻塞,并且另一个任务的优先级比获得了互斥锁的任务的优先级更高,那么获取了互斥锁的任务将会继承更高的优先级,在释放互斥锁之后优先级恢复。
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
void Z_Task1(void* a){
while(1){
if(xSemaphoreTake(Z_Mutex,portMAX_DELAY)){
printf("1 take mutex\r\n");
vTaskDelay(2000/portTICK_PERIOD_MS);
printf("1 give mutex\r\n");
xSemaphoreGive(Z_Mutex);
}
vTaskDelay(100/portTICK_PERIOD_MS);
}
}
void Z_Task2(void* a){
while(1){
if(xSemaphoreTake(Z_Mutex,portMAX_DELAY)){
printf("2 take mutex\r\n");
vTaskDelay(2000/portTICK_PERIOD_MS);
printf("2 give mutex\r\n");
xSemaphoreGive(Z_Mutex);
}
vTaskDelay(100/portTICK_PERIOD_MS);
}
}
void app_main(void){
if(xTaskCreate(Z_Task1,"Z_Task1",2048,NULL,1,NULL)) printf("create task1 success\r\n");
if(xTaskCreate(Z_Task2,"Z_Task2",2048,NULL,2,NULL)) printf("create task2 success\r\n");
while (1){
vTaskDelay(100/portTICK_PERIOD_MS);
}
}
我上面代码创建了两个任务,两个任务的内容是一样的,获取互斥锁,打印,释放互斥锁。
从打印结果看的出互斥锁是起了效果的,两个任务是轮番进行的。
软件定时器
#include "freertos/timers.h"
这个没什么好介绍的,就是基于Tick的软件定时器。
创建&删除
//创建定时器返回句柄
TimerHandle_t xTimerCreate(const char * const pcTimerName,const TickType_t xTimerPeriodInTicks,const UBaseType_t uxAutoReload,void * const pvTimerID,TimerCallbackFunction_t pxCallbackFunction );
创建定时器,参数一指定定时器名字,没啥用,随便起就行。
参数二是以Tick为单位的定时器周期(我这里一个Tick是10ms)。
参数三指定定时器类型,pdTRUE表示自动加载,pdFALSE表示一次性。
参数四是分配给定时器的ID,可以填NULL。
参数五是回调函数,无返回值,并且有一个TimerHandle_t类型的参数。
BaseType_t xTimerDelete(TimerHandle_t xTimer,TickType_t xTicksToWait);
删除定时,传入句柄和超时时间,超时时间就是等待这么久之后再删除。
开启&停止
//启动定时器
BaseType_t xTimerStart(TimerHandle_t xTimer,TickType_t xTicksToWait );
//启动定时器(ISR)
BaseType_t xTimerStartFromISR(TimerHandle_t xTimer,BaseType_t *pxHigherPriorityTaskWoken);
//停止定时器
BaseType_t xTimerStop(TimerHandle_t xTimer,TickType_t xTicksToWait );
//停止定时器(ISR)
BaseType_t xTimerStopFromISR(TimerHandle_t xTimer,BaseType_t *pxHigherPriorityTaskWoken);
参数就不再介绍了,之前都重复了好多次了。
复位
//复位
BaseType_t xTimerReset(TimerHandle_t xTimer,TickType_t xTicksToWait);
//复位(ISR)
BaseType_t xTimerResetFromISR(TimerHandle_t xTimer,BaseType_t *pxHigherPriorityTaskWoken);
如果定时器是停止的状态,那么复位之后会再次开启。
如果定时器是启动的状态,那么复位之后会使得当前的定时的周期重新计数。
修改定时周期
//修改定时周期
BaseType_t xTimerChangePeriod(imerHandle_t xTimer,TickType_t xNewPeriod,TickType_t xTicksToWait);
//修改定时周期(ISR)
BaseType_t xTimerChangePeriodFromISR(TimerHandle_t xTimer,TickType_t xNewPeriod,BaseType_t *pxHigherPriorityTaskWoken);