FreeRTOS入门

前言

先叠个甲:此文章是根据韦东山老师RTOS入门资源所学习的,初心是记录所学知识,任何人不得用作其他用途,否则后果自负。鼠鼠菜鸡一个,大神轻喷 。韦东山哔站

RTOS的用处

说到RTOS的用处,那第一点肯定是丰富自己的技能树,找工作,涨马内了。第二点就是裸机开发确实是有它的缺点,比如说在裸机中的---轮询运行

 void main()
 {
    while(1)
    {
        doing_1();//做事情1
        doing_2();//做事情2
    }

 }

又或者是有中断的--前后台系统 

 void main()
 {
    while(1)
    {
        doing_1();//做事情1
    }

 }

 void TIM_OR_EXIT_ISR(void)
 {
        doing_2();//做事情2
 }
  • 如果事情1或者事情2要同时进行,且同时要花费很多时间的话,这时候就有问题了。 
  • RTOS的解决方法就是,依靠时间片轮询的多任务的实时性,也就是你执行一会,我执行一会的概念

FreeRTOS学习分类

主要分内存管理任务之间的同步,互斥,通信(方法:运用队列,信号量,互斥量,事件组,任务通知等)中断定时资源管理几部分。下面一一讲解:

内存管理

栈和堆的概念

栈:在main函数开始前开辟一大块空间内存,然后一个函数(自定义或者说main函数)在里面开辟一块小空间然后把返回地址存到寄存器中,寄存器值把这个值压入到其小空间中(入栈),然后数据也是如此。

堆:就是自己申请自己释放,用于长期数据的管理

文档配置

FreeRTOSConfig.h作用


FreeRTOS的系统配置文件为FreeRTOSConfig.h,我们可以在此文件中完成FreeRTOS的裁剪和配置。

首先从官网源码第一个(第二个没有配套工程)根据手册删改然后粘到非中文某目录下。

任务管理

任务

任务的函数    

关于任务的函数创建,任务的创建里面的C语言知识可以看这个--函数指针

函数里面是任务

void ATaskFunction( void *pvParameters );

这只是一个函数模版,其中ATaskFunction可以更换别的字符。

pvParameters是任务函数所带的参数,在函数定义中使用 void * 作为参数类型是一种通用的做法,因为它可以接受任何类型的指针,而不需要指定具体的数据类型,在函数内部需要对 pvParameters 进行类型转换才能使用其中的数据。如:

    int parameter = *(int *)pvParameters;

函数使用使用如下面:

void task1(void *pvParameters) {
    // 将传递的参数转换回正确的类型
    int parameter = *(int *)pvParameters;

    // 任务主循环
    while (1) {
        // 执行任务操作
        printf("Parameter: %d\n", parameter);

        // 我们假设任务需要延时一段时间
        vTaskDelay(pdMS_TO_TICKS(1000)); // 每隔一秒钟打印一次
    }
}

然后再进行任务创建,并在main函数里面启动调度,(就绪的)任务就RUN起来了 

任务的创建

函数:xTaskCreate(a,b,c,d,e,f)

返回值:

成功: pdPASS
失败: errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY( 失败原因只有内存
不足 )
注意:失败时返回值是 errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY -1
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 ); // 任务句柄, 以后使用它来操作这个任务

任务创建函数xTaskCreate()在main函数里面使用,在main函数前面写出任务函数void task1()。

用法如:

void aTask1(void* pvParameters)

{
//写自己的函数
}
int main()
{
prvSetupHardware();//硬件初始化
xTaskCreate(aTask1, "Task1", 1000, NULL, 1, NULL);//创建任务Task1

//如果创建两个任务,如加入aTask2,则加入下面调度函数
/* 启动调度器 */
vTaskStartScheduler();

/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
return 0;
}

任务的删除

删除函数

void vTaskDelete(TaskHandle_t xTaskToDelete);
//TaskHandle_t xTaskToDelete是删除任务函数的句柄
//也可传入NULL,这表示删除自己

具体举例子

我们要做这些事情:
创建任务 1 :任务 1 的大循环里,创建任务 2 ,然后休眠一段时间
任务 2 :打印一句话,然后就删除自己
void vTask1( void *pvParameters )
{
const TickType_t xDelay100ms = pdMS_TO_TICKS( 100UL );
BaseType_t ret;
/* 任务函数的主体一般都是无限循环 */
for( ;; )
{
/* 打印任务的信息 */
printf("Task1 is running\r\n");
ret = xTaskCreate( vTask2, "Task 2", 1000, NULL, 2, &xTask2Handle );
if (ret != pdPASS)
printf("Create Task2 Failed\r\n");
// 如果不休眠的话, Idle任务无法得到执行
// Idel任务会清理任务2使用的内存
// 如果不休眠则Idle任务无法执行, 最后内存耗尽
vTaskDelay( xDelay100ms );
}
void vTask2( void *pvParameters )
{
/* 打印任务的信息 */
printf("Task2 is running and about to delete itself\r\n");
// 可以直接传入参数NULL, 这里只是为了演示函数用法
vTaskDelete(xTask2Handle);
}

main函数创建任务:

int main( void )
{
prvSetupHardware();
xTaskCreate(vTask1, "Task 1", 1000, NULL, 1, NULL);
/* 启动调度器 */
vTaskStartScheduler();
/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
return 0;
}

任务的参数

就是在创建任务的时候xTaskCreate()的第四项,假如传入的是"hello word"

那么在任务Task1中接收参数* pvParameters就是hello word,这时候需要加入

const char *pcTaskText = pvParameters;表示将void*参数转化为char*类型。这时候

*pcTaskText就表示hello word了(pcTaskText是自己定义的不是系统的)

值得注意的是:

在同一个函数中如果有不同任务A,B,C.则在在不同任务中的参数pvParameters是相互独立的,其存在不同的栈中。

Tick

就是如果有两件优先级一样且都很长时间的事情要做,A执行一会,B执行一会的 “一会”

  • FreeRTOS中也有心跳,它使用定时器产生固定间隔的中断,每个任务执行的时间。这叫Tick、滴答,比如每10ms发生一次时钟中断。
  1. 两次中断之间的时间被称为时间片(time slicetick period)
  2. 时间片的长度由configTICK_RATE_HZ 决定,假设configTICK_RATE_HZ100,那么时间片长度就 是10ms
  • 有了 Tick 的概念后,我们就可以使用 Tick 来衡量时间了,比如:
    vTaskDelay(2); // 等待2个Tick,假设configTICK_RATE_HZ=100, Tick周期时10ms, 等待20ms
    // 还可以使用pdMS_TO_TICKS宏把tick转换为ms
    vTaskDelay(pdMS_TO_TICKS(100)); // 等待100ms

任务状态

任务无非就是  RUN和非RUN

非RUN的话就是:

  • 就绪:已经准备好了,该这个任务上场了
  • 阻塞:该任务一直在等待
  • 暂停:停止任务,且由别人唤醒

任务的优先级 

  • 优先级的取值范围是:0~(configMAX_PRIORITIES – 1),数值越大优先级越高。
  • FreeRTOS的调度器可以使用2种方法来快速找出优先级最高的、可以运行的任务。使用不同的方法时,configMAX_PRIORITIES 的取值有所不同。

关于对configMAX_PRIORITIES取值问题

  • 通用方法
使用 C 函数实现,对所有的架构都是同样的代码。对 configMAX_PRIORITIES 的取值没有限制。但
configMAX_PRIORITIES 的取值还是尽量小,因为取值越大越浪费内存,也浪费时间。
configUSE_PORT_OPTIMISED_TASK_SELECTION 被定义为 0 、或者未定义时,使用此方法。
  • 架构相关的优化的方法
架构相关的汇编指令,可以从一个 32 位的数里快速地找出为 1 的最高位。使用这些指令,可以快速
找出优先级最高的、可以运行的任务。
  使用这种方法时, configMAX_PRIORITIES 的取值不能超过 32
  configUSE_PORT_OPTIMISED_TASK_SELECTION 被定义为 1 时,使用此方法。
在学习调度方法之前,你只要初略地知道:
FreeRTOS 会确保最高优先级的、可运行的任务,马上就能执行
对于相同优先级的、可运行的任务,轮流执行

任务的调度 

所谓调度就是把准备就绪的任务run起来

调度算法的行为主要体现在两方面:
1,高优先级的任务先运行,2,同优先级的就绪态任务如何被选中。
调度算法要确保同优先级的就绪态任务,能"轮流"运行,策略是"轮转调度"(Round Robin Scheduling)。轮转调度并不保证任务的运行时间是公平分配的,我们还可以细化时间的分配方法。

通过配置文件FreeRTOSConfig.h的两个配置项来配置调度算法:

  • configUSE_PREEMPTION
  • configUSE_TIME_SLICING
  • 还有第三个配置项:configUSE_TICKLESS_IDLE,它是一个高级选项,用于关闭Tick中断来实现省电
3 个角度统一理解多种调度算法:
可否抢占?高优先级的任务能否优先执行 ( 配置项 : configUSE_PREEMPTION)
  • 可以:被称作"可抢占调度"(Pre-emptive),高优先级的就绪任务马上执行。
  • 不可以:不能抢就只能协商了,被称作"合作调度模式"(Co-operative Scheduling) 当前任务执行时,更高优先级的任务就绪了也不能马上运行,只能等待当前任务主动让CPU资源。 其他同优先级的任务也只能等待:更高优先级的任务都不能抢占,平级的更应该老实点
可抢占的前提下,同优先级的任务是否轮流执行 ( 配置项: configUSE_TIME_SLICING)
  • 轮流执行:被称为"时间片轮转"(Time Slicing),同优先级的任务轮流执行,你执行一个时间 片、我再执行一个时间片
  • 不轮流执行:英文为"without Time Slicing",当前任务会一直执行,直到主动放弃、或者被高优先级任务抢占
" 可抢占 "+" 时间片轮转 " 的前提下,进一步细化:空闲任务是否让步于用户任务 ( 配置项:
configIDLE_SHOULD_YIELD)
  • 空闲任务低人一等,每执行一次循环,就看看是否主动让位给用户任务
  • 空闲任务跟用户任务一样,大家轮流执行,没有谁更特殊

代码示例:

代码里创建了 3 个任务: Task1 Task2 的优先级都是 0 ,跟空闲任务一样, Task3 优先级最高为 2 。程序 里定义了4 个全局变量,当某个的任务执行时,对应的变量就被设为 1 ,可以通过 Keil 的逻辑分析仪查看 任务切换情况:
static volatile int flagIdleTaskrun = 0; // 空闲任务运行时flagIdleTaskrun=1
static volatile int flagTask1run = 0; // 任务1运行时flagTask1run=1
static volatile int flagTask2run = 0; // 任务2运行时flagTask2run=1
static volatile int flagTask3run = 0; // 任务3运行时flagTask3run=1

然后写出任务函数:

先写出任务1和任务2:

void vTask1( void *pvParameters )
{
/* 任务函数的主体一般都是无限循环 */
for( ;; )
{
flagIdleTaskrun = 0;
flagTask1run = 1;
flagTask2run = 0;
flagTask3run = 0;
/* 打印任务的信息 */
printf("T1\r\n");
}
}
void vTask2( void *pvParameters )
{
/* 任务函数的主体一般都是无限循环 */
for( ;; )
{
flagIdleTaskrun = 0;
flagTask1run = 0;
flagTask2run = 1;
flagTask3run = 0;
/* 打印任务的信息 */
printf("T2\r\n");
}
}

再写出任务3:(得由vTaskDelay函数休眠任务,否则由于优先级其他任务不能执行)

void vTask3( void *pvParameters )
{
const TickType_t xDelay5ms = pdMS_TO_TICKS( 5UL );
/* 任务函数的主体一般都是无限循环 */
for( ;; )
{
flagIdleTaskrun = 0;
flagTask1run = 0;
flagTask2run = 0;
flagTask3run = 1;
/* 打印任务的信息 */
printf("T3\r\n");
// 如果不休眠的话, 其他任务无法得到执行
vTaskDelay( xDelay5ms );
}
}

然后是钩子函数:

void vApplicationIdleHook(void)
{
flagIdleTaskrun = 1;
flagTask1run = 0;
flagTask2run = 0;
flagTask3run = 0;
/* 故意加入打印让flagIdleTaskrun变为1的时间维持长一点 */
printf("Id\r\n");
}

然后是main函数:

int main( void )
{
prvSetupHardware();
xTaskCreate(vTask1, "Task 1", 1000, NULL, 0, NULL);
xTaskCreate(vTask2, "Task 2", 1000, NULL, 0, NULL);
xTaskCreate(vTask3, "Task 3", 1000, NULL, 2, NULL);
/* 启动调度器 */
vTaskStartScheduler();
/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
return 0;
}

然后可以在FreeRTOSConfig.h里面配置三个配置项configUSE_PREEMPTION,configUSE_TIME_SLICING,configIDLE_SHOULD_YIELD来确定调度算法。

同步互斥与通信 

概念:所谓同步互斥与通信:可以把多任务系统当做一个团队,里面的每一个任务就相当于团队里的一个人。团队成员之间要协调工作进度(同步)、争用会议室(互斥)、沟通(通信)。多任务系统中所涉及的概念,都可以在现实生活中找到例子。

01 void 抢厕所(void)
02 {
03 if (有人在用) 我眯一会;
04 用厕所;
05 喂,醒醒,有人要用厕所吗;
06 }
       假设有 A B 两人早起抢厕所, A 先行一步占用了; B 慢了一步,于是就眯一会;当 A 用完后叫醒 B B 也 就愉快地上厕所了。
       在这个过程中, A B 是互斥地访问 厕所 厕所 被称之为临界资源。我们使用了 休眠 - 唤醒 的同步机 制实现了“ 临界资源 互斥访问
       同一时间只能有一个人使用的资源,被称为临界资源。比如任务 A B 都要使用串口来打印,串口就是临 界资源。如果A B 同时使用串口,那么打印出来的信息就是 A B 混杂,无法分辨。所以使用串口时,应该是这样:A 用完, B 再用; B 用完, A 再用。
但是同步互斥和通信实现起来有缺陷。要解决效率问题(等待者要进入阻塞状态),安全问题(使用临界资源的两个任务应该互斥,使用全局变量来保护临界资源的时候有可能会发生两个任务同时都能抢夺到临界资源的问题),和通信结果问题(使用全局变量来通信,可能会发生任务1正在写数据,但是任务2就读走数据的BUG),即等待者要进入阻塞状态。还要有互斥来保证正确性。
为了解决同步互斥和通信的缺陷有几类方法:
  •  能实现同步、互斥的内核方法有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)

队列 (Queue)

FIFO(先进先出),队列是任务和任务之间的一个结构体,用来传输数据,同步,互斥。

队列结构体包括一个存数据的buff(有头指针和尾指针),还有唤醒阻塞任务的发送函数,以及开拓队列空间的写函数。

队列的本质是环形缓冲区。有两个指针pcHead和pcWriteTo,在下面第二个代码中。

 一般使用用途

  • 队列在任务中常用就是可以用作事件的数据传输使用 

队列操作: 

  1. 先创建队列句柄(main函数外)
  2. 创建队列
  3. 对队列进行增删查改

创建队列

/* 创建队列: 长度为5,数据大小为4字节(存放一个整数) */
xQueue = xQueueCreate( 5, sizeof( int32_t ) );





/*再比如这样创建队列就是10个结构体*/
struct input_data {
	uint32_t dev;
	uint32_t val
};


g_xQueuePlatform = xQueueCreate(10, sizeof(struct input_data));

写队列

 

(上图)

在写队列时则pcWriteTo在最左边。指针往右边移动的时候执行代码pcWriteTo + =itemSize。

/*等同于xQueueSend*/

/* 往队列尾部写入数据,如果没有空间,阻塞时间为xTicksToWait*/

/*参数: 写入队列句柄   数据指针,这个数据的值会被复制进队列   等待时间*/

BaseType_t xQueueSendToBack(
QueueHandle_t xQueue,
const void *pvItemToQueue,
TickType_t xTicksToWait
);

在写队列时如果队列满了:(读队列同理)

  1. 无阻塞时间xTicksToWait,则该任务就结束返回。
  2. 有阻塞时间xTicksToWait,则该任务(如发送数据任务)就放在xTasksWaitingToSend队列中等待唤醒(队列空了就立马唤醒)。xTasksWaitingToSend在下面创建队列的代码中。

读队列

在读队列时:

  •  pcHead是在最左边不会变,变的是pcReadFrom上一次读的位置(读完了在上图最右边倒数第二个竖线位置)。         
  • 然后pcHead+itemSize 指向下一次写数据的位置。
  • 读到数据时,数据会删除
//队列句柄  数据传入的地址   等待时间
//返回值 pdPASS:从队列读出数据入 errQUEUE_EMPTY:读取失败,因为队列空了
BaseType_t xQueueReceive( QueueHandle_t xQueue,
                          void * const pvBuffer,
                          TickType_t xTicksToWait );

typedef struct QueueDefinition /* The old naming convention is used to prevent breaking kernel aware debuggers. */
{
    int8_t * pcHead;           /*< Points to the beginning of the queue storage area. */
    int8_t * pcWriteTo;        /*< Points to the free next place in the storage area. */

    union
    {
        QueuePointers_t xQueue;     /*< Data required exclusively when this structure is used as a queue. */
        SemaphoreData_t xSemaphore; /*< Data required exclusively when this structure is used as a semaphore. */
    } u;

    List_t xTasksWaitingToSend;             /*< List of tasks that are blocked waiting to post onto this queue.  Stored in priority order. */
    List_t xTasksWaitingToReceive;          /*< List of tasks that are blocked waiting to read from this queue.  Stored in priority order. */

    volatile UBaseType_t uxMessagesWaiting; /*< The number of items currently in the queue. */
    UBaseType_t uxLength;                   /*< The length of the queue defined as the number of items it will hold, not the number of bytes. */
    UBaseType_t uxItemSize;                 /*< The size of each items that the queue will hold. */

    volatile int8_t cRxLock;                /*< Stores the number of items received from the queue (removed from the queue) while the queue was locked.  Set to queueUNLOCKED when the queue is not locked. */
    volatile int8_t cTxLock;                /*< Stores the number of items transmitted to the queue (added to the queue) while the queue was locked.  Set to queueUNLOCKED when the queue is not locked. */

    #if ( ( configSUPPORT_STATIC_ALLOCATION == 1 ) && ( configSUPPORT_DYNAMIC_ALLOCATION == 1 ) )
        uint8_t ucStaticallyAllocated; /*< Set to pdTRUE if the memory used by the queue was statically allocated to ensure no attempt is made to free the memory. */
    #endif

    #if ( configUSE_QUEUE_SETS == 1 )
        struct QueueDefinition * pxQueueSetContainer;
    #endif

    #if ( configUSE_TRACE_FACILITY == 1 )
        UBaseType_t uxQueueNumber;
        uint8_t ucQueueType;
    #endif
} xQUEUE;

读队列和写队列的配合: 

  1. 当读任务读不到队列数据的时候会把自己删除自己,并且放进队列的一个链表里面和延时的一个队列里面,然后等到写任务来写数据。等到写数据写完之后会删除上面两个链表(一个队列,一个延时的)里面的任务,并且把任务放到就绪链表里面(唤醒读任务)。
  2. 当读任务一直等不到写数据的时候(非一直阻塞状态:portMAX_DELAY),即在延时那个链表里面判断任务一直没被写入数据,最后超时。就会在几个tick(自己定义的时间)时间之后把任务从上面两个链表删除,并且把任务到就绪链表里面,并且返回错误值。

队列举例:

本程序会创建一个队列,然后创建2个发送任务、1个接收任务:

  • 发送任务优先级为1,分别往队列中写入100200
  • 接收任务优先级为2,读队列、打印数值

故意让接受任务优先级别高,然后再自己阻塞(等待)一段时间让发送任务发送数据,形成闭环。

main函数:

/* 队列句柄, 创建队列时会设置这个变量 */
QueueHandle_t xQueue;
int main( void )
{
prvSetupHardware();
/* 创建队列: 长度为5,数据大小为4字节(存放一个整数) */
xQueue = xQueueCreate( 5, sizeof( int32_t ) );
if( xQueue != NULL )
{
/* 创建2个任务用于写队列, 传入的参数分别是100、200
* 任务函数会连续执行,向队列发送数值100、200
* 优先级为1
*/
xTaskCreate( vSenderTask, "Sender1", 1000, ( void * ) 100, 1, NULL );
xTaskCreate( vSenderTask, "Sender2", 1000, ( void * ) 200, 1, NULL );
/* 创建1个任务用于读队列
* 优先级为2, 高于上面的两个任务
* 这意味着队列一有数据就会被读走
*/
xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 2, NULL );
/* 启动调度器 */
vTaskStartScheduler();
}
else
{
/* 无法创建队列 */
}
/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
return 0;
}

发送任务:

static 关键字修饰了函数 vSenderTask,这意味着 vSenderTask 函数只能在当前文件中被调用,其他文件无法直接调用这个函数。这种用法通常用于限制函数的作用域,避免函数被其他文件误用。

static void vSenderTask( void *pvParameters )
{
int32_t lValueToSend;
BaseType_t xStatus;
/*BaseType_t xStatus; 这行代码声明了一个名为 xStatus 的变量,
其类型为 BaseType_t,用于存储任务或队列操作的返回状态。
通过检查 xStatus 的值,可以确定任务或队列操作是否成功执行,
从而采取相应的控制流程。
*/

/* 我们会使用这个函数创建2个任务
* 这些任务的pvParameters不一样
*/
lValueToSend = ( int32_t ) pvParameters;//参数使用前要明确是什么类型的参数

/* 无限循环 */
for( ;; )
{
/* 写队列
* xQueue: 写哪个队列
* &lValueToSend: 写什么数据? 传入数据的地址, 会从这个地址把数据复制进队列
* 0: 不阻塞, 如果队列满的话, 写入失败, 立刻返回
*/
xStatus = xQueueSendToBack( xQueue, &lValueToSend, 0 );
if( xStatus != pdPASS )
{
printf( "Could not send to the queue.\r\n" );
}
}
}

接受任务:接收任务的函数中,读取队列、判断返回值、打印,代码如下:

static void vReceiverTask( void *pvParameters )
{
/* 读取队列时, 用这个变量来存放数据 */
int32_t lReceivedValue;
BaseType_t xStatus;
const TickType_t xTicksToWait = pdMS_TO_TICKS( 100UL );
/* 无限循环 */
for( ;; )
{
/* 读队列
* xQueue: 读哪个队列
* &lReceivedValue: 读到的数据复制到这个地址
* xTicksToWait: 如果队列为空, 阻塞一会
*/
xStatus = xQueueReceive( xQueue, &lReceivedValue, xTicksToWait );
if( xStatus == pdPASS )
{
/* 读到了数据 */
printf( "Received = %d\r\n", lReceivedValue );
}
else
{
/* 没读到数据 */
printf( "Could not receive from the queue.\r\n" );
}
}
}

队列集 

队列集实质也是队列,是多个队列构成了队列集 ,队列集里面装的是队列的句柄

队列集的使用意义:

假设有2个输入设备:红外遥控器、旋转编码器,它们的驱动程序应该专注于“产生硬件数据”,不应该跟“业务”有任何联系。比如:红外,旋转编码器两者作为按键同时控制游戏。

红外遥控器驱动程序里,它只应该把键值记录下来、写入某个队列,它不应该把键值转换为游戏的控制键。在红外遥控器的驱动程序里,不应该有游戏相关的代码,在旋转编码器驱动里面也同理,这样,切换使用场景,不是控制游戏时,只需要这两个驱动程序还可以继续使用 

队列集的创建,使用流程

/* 输入设备的队列集 */
static QueueSetHandle_t g_xQueueSetInput;
/* 挡球板队列  红外队列和编码器队列都写到挡球班队列  然后就转化成游戏按键*/
static QueueHandle_t g_xQueuePlatform; 
static QueueHandle_t g_xQueueIR;//红外队列句柄
static QueueHandle_t g_xQueueRotary;//编码器队列


/* 创建队列,队列集,创建输入任务InputTask */
g_xQueuePlatform = xQueueCreate(10, sizeof(struct input_data));
g_xQueueSetInput = xQueueCreateSet(IR_QUEUE_LEN + ROTARY_QUEUE_LEN);

/*把红外队列和编码器队列加入到队列集当中*/
xQueueAddToSet(g_xQueueIR, g_xQueueSetInput);
xQueueAddToSet(g_xQueueRotary, g_xQueueSetInput);

/* 读队列集, 得到有数据的队列句柄 */
xQueueHandle = xQueueSelectFromSet(g_xQueueSetInput, portMAX_DELAY);

/*判断是哪个句柄,返回相对的队列*/
if (xQueueHandle)
		{
			/* 读队列句柄得到数据,处理数据 */
			if (xQueueHandle == g_xQueueIR)
			{
				ProcessIRData();//处理函数,把红外队列写进挡板队列
			}
			else if (xQueueHandle == g_xQueueRotary)
			{
				ProcessRotaryData();处理函数,把编码器队列写进挡板队列
			}
			
		}

多输入队列 

把输入的数据分发给多个队列,以供不同任务使用


/*g_queue_cnt 是一个计数器,用于跟踪数组 g_xQueues 中已经存储的队列数量。
if (g_queue_cnt < 10) 是一个条件语句,检查当前数组中的队列数量是否小于 10。
如果条件成立(即队列数量小于 10),则执行大括号 {} 中的代码。
在大括号中,g_xQueues[g_queue_cnt] = queueHandle; 将 queueHandle 存储到 g_xQueues 数组中的第 g_queue_cnt 个位置。
然后,g_queue_cnt++ 将 g_queue_cnt 的值增加 1,以便下次添加队列时将其存储到下一个位置。


这段代码的作用是将 queueHandle 存储到 g_xQueues 数组中的下一个可用位置,前提是数组中存储的队列数量尚未达到 10 个。*/
void RegisterQueueHandle(QueueHandle_t queueHandle)
{
	if (g_queue_cnt < 10)
	{
		g_xQueues[g_queue_cnt] = queueHandle;
		g_queue_cnt++;
	}
}
/*创建一个函数,三个任务*/
static void CarTask(void *params)
{
/* 创建自己的队列 */
QueueHandle_t xQueueIR = xQueueCreate(10, sizeof(struct ir_data));

/* 注册队列 */
RegisterQueueHandle(xQueueIR);//注册三个任务队列,然后红外数据都写到所有队列中

}
/*红外按键分别写入所有队列*/
static void DispatchKey(struct ir_data *pidata)
{

	int i;
	for (i = 0; i < g_queue_cnt; i++)
	{
		xQueueSendFromISR(g_xQueues[i], pidata, NULL);
	}

}

/*然后根据三个任务参数params结构体不同,实现不同按键分发给不同任务*/
struct car g_cars[3] = {
	{0, 0, IR_KEY_1},
	{0, 17, IR_KEY_2},
	{0, 34, IR_KEY_3},
};

信号量(Semaphore)

 一般使用用途

  • 信号量在任务中常用就是可以用作事件的开关使用 (保护临界)

信号量和队列都可以实现同步,不过:

  • 队列传输数据需要空间
  • 信号量不能传输数据,节省空间
  • 信号量不需要复制数据,效率更高
  • 信号量就需要自己设计代码保存数据传输的完整性。

give给出资源使得计数值加一,take获得资源使得计数值减一,且要先take先操作。

信号量不同于队列,其只能用来表示资源数量,关键是计数值。

配置问题:
 

#if ( configSUPPORT_DYNAMIC_ALLOCATION == 1 )
    #define xSemaphoreCreateCounting( uxMaxCount, uxInitialCount )    xQueueCreateCountingSemaphore( ( uxMaxCount ), ( uxInitialCount ) )
#endif

 configSUPPORT_DYNAMIC_ALLOCATION不需要手动配置为1

  • 系统默认配置为1
  • 如果不使用动态分配则配置为0
#if ( ( configUSE_COUNTING_SEMAPHORES == 1 ) && ( configSUPPORT_DYNAMIC_ALLOCATION == 1 ) )

    QueueHandle_t xQueueCreateCountingSemaphore( const UBaseType_t uxMaxCount,
                                                 const UBaseType_t uxInitialCount )

如果要使用 xQueueCreateCountingSemaphore函数,则需要在FreeRTOSConfig.h配置configUSE_COUNTING_SEMAPHORES == 1  和 configSUPPORT_DYNAMIC_ALLOCATION == 1才能使得函数有用。否则会弹出这样的错误:

.\RTOSDemo.axf: Error: L6218E: Undefined symbol xQueueCreateCountingSemaphore (referred from main.o).

计数型信号量:

(注意函数的用法:第一位是最大值)

/* 创建一个计数型信号量,返回它的句柄。
* 此函数内部会分配信号量结构体
* uxMaxCount: 最大计数值
* uxInitialCount: 初始计数值
* 返回值: 返回句柄,非NULL表示成功
*/
SemaphoreHandle_t xSemaphoreCreateCounting(UBaseType_t uxMaxCount, UBaseType_t
uxInitialCount);

二进制信号量: 

二进制和计数型一样的,唯一区别就是二进制的计数值是0和1跳变

/* 创建一个二进制信号量,返回它的句柄。
* 此函数内部会分配信号量结构体
* 返回值: 返回句柄,非NULL表示成功
*/
SemaphoreHandle_t xSemaphoreCreateBinary( void );

信号量实现同步功能:

让task2实现计数功能,然后这期间让task1 处于阻塞状态,使其不参与调度。

task 1: take 函数使其处于阻塞状态

task 2 :让sum加够指定次数(1000),然后删除自己

task 1: 打印sum,然后因为task2 自删了,故task1 一直处于阻塞状态了。

task2:

void vSemaphoregive( void *pvParameters )
{	printf("T2\r\n");	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		for(i=0;i<1000;i++)
		{
		//printf("task2 running\r\n");
			sum++;
		}
		//printf("task2 run over\r\n");
		xSemaphoreGive(xSemaphore);
	    /*自删*/
		vTaskDelete(NULL);
		
	}
		
}

task1:

void vSemaphoretake( void *pvParameters )
{	
	printf("T1\r\n");	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{

        flagTask1run = 0;  
		xSemaphoreTake(xSemaphore,portMAX_DELAY);
	    flagTask1run = 1;  
		printf("sum is %d\r\n",sum);
		/* 打印任务的信息 */
		//printf("T1\r\n");				
	}
}

main:

int main( void )
{
	prvSetupHardware();
	/*创建计数型信号量1-10*/
	xSemaphore=xSemaphoreCreateCounting(10,0);//最大值,开始值
	xTaskCreate(vSemaphoregive, "Task 2", 1000, NULL, 1, NULL);
	xTaskCreate(vSemaphoretake, "Task 1", 1000, NULL, 1, NULL);

	//xTaskCreate(vTask3, "Task 3", 1000, NULL, 0, NULL);

	/* 启动调度器 */

	vTaskStartScheduler();
    printf("chengxu is cuowu\r\n");

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

信号量实现互斥功能:

可以用二进制信号量来保护临界资源,这时候信号量不能实现谁上锁就由谁来上锁,互斥量也不能实现谁上锁就由谁来上锁,但是其实现了单个任务在一个tick时间段内对临界资源的完全占领,但是递归锁(Recursive)互斥量能实现谁上锁就由谁解锁的功能,且是单个任务完全占领的,。

在此示例中,先把task 3和task 4创建在同一任务函数中,然后让其依次打印相应的字符串,然后输出

task:

void vTaskGive_Take( void *pvParameters )
{	
	//const TickType_t xDelay5ms = pdMS_TO_TICKS( 5UL );		
	char *string=(void *)pvParameters;
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{ 
		flagIdleTaskrun = 1;
		xSemaphoreTake(xSemaphore,portMAX_DELAY);
		printf("task is %s running\r\n",string);
		xSemaphoreGive(xSemaphore);
		flagIdleTaskrun = 0;
		vTaskDelay(1);

	}
}

     vTaskDelay(1);的作用: 

vTaskDelay(1); 表示让当前任务延迟 1 个系统时钟周期的时间

vTaskDelay(); 函数中的参数是以时钟节拍(tick)为单位的时间间隔。每个 tick 的长度由 FreeRTOS 配置的系统节拍频率决定。假设系统节拍频率为 1ms,那么vTaskDelay(1);将使当前任务延迟 1ms。

通过延迟任务的执行,可以控制任务之间的执行顺序、时间间隔,以及系统资源的利用情况。在实际应用中,根据具体需求合理使用vTaskDelay();可以帮助优化系统的性能和资源利用。

main():

int main( void )
{
	prvSetupHardware();
	/*创建二进制信号量*/
	xSemaphore=xSemaphoreCreateBinary();
	xSemaphoreGive(xSemaphore);
	//xTaskCreate(vSemaphoregive, "Task 2", 1000, NULL, 1, NULL);
	//xTaskCreate(vSemaphoretake, "Task 1", 1000, NULL, 1, NULL);

	xTaskCreate(vTaskGive_Take, "Task 3", 1000, (void*)("task3 "), 1, NULL);
	xTaskCreate(vTaskGive_Take, "Task 4", 1000, (void*)("task4 "), 1, NULL);

	/* 启动调度器 */

	vTaskStartScheduler();
    printf("chengxu is cuowu\r\n");

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

            可以看到其字符串依次打印。

优先级反转 

其产生原因是,低优先级任务semaphoretake无效,导致其任务一直卡着,高优先级任务运行不了(站着茅坑不拉屎)。在互斥量章节做详细阐述 

 信号量的缺点:

  1. 尽可能不要使用同一个串口在多任务之间发送很多数据,因为可能这个任务数据没发送完成,下一个任务发送数据就又来了。临界资源占有时间不完全,互斥量的出现就解决了这一个问题
  2. 信号量还会出现优先级反转的情况,就是因为低优先级的获得不了信号量,一直卡着不让优先级的运行了。互斥量可以解决这一问题。
  3. 还有就是信号量不能保证数据的完整性,其实跟上一个缺点类似。 在信号量实现同步功能中,如果不把task2删除(即注释vTaskDelete(NULL);)则最后sum的数据就不是1000,就是比1000多,因为task2give给 task1 take有一段时间,则这段时间sum会继续增加。如下图

互斥量(mutex) 

要想使用互斥量,需要在配置文件 FreeRTOSConfig.h 中定义:
##define configUSE_MUTEXES 1

 一般使用用途

  • 互斥量在任务中常用就是可以用作事件的开关使用 

互斥量使用根信号量类似,互斥量初始值为1

互斥量的使用场景:(保护临界资源)

在多任务系统中,任务 A 正在使用某个资源,还没用完的情况下任务 B 也来使用的话,就可能导致问题。 比如对于串口,任务A 正使用它来打印,在打印过程中任务 B 也来打印,客户看到的结果就是 A B 的信 息混杂在一起。

创建互斥量:

/* 创建一个互斥量,返回它的句柄。
* 此函数内部会分配互斥量结构体
* 返回值: 返回句柄,非NULL表示成功
*/
SemaphoreHandle_t xSemaphoreCreateMutex( void );
/* 创建一个互斥量,返回它的句柄。
* 此函数无需动态分配内存,所以需要先有一个StaticSemaphore_t结构体,并传入它的指针
* 返回值: 返回句柄,非NULL表示成功
*/
SemaphoreHandle_t xSemaphoreCreateMutexStatic( StaticSemaphore_t *pxMutexBuffer
);

互斥量的常规使用 

互斥量虽然能实现对临界资源的保护,但是普通互斥量不能实现谁上锁就由谁解锁的问题,递归锁能实现

在实例中故意创建两个任务,使用同一个串口,在两个任务中故意发送大量的数据,结果发现各个任务打印之间没有发生产生交错打印的情况。 

main():

int main( void )
{
	prvSetupHardware();
	
    /* 创建互斥量 */
    xMutex = xSemaphoreCreateMutex( );


	if( xMutex != NULL )
	{
		/* 创建2个任务: 都是打印
		 * 优先级相同
		 */
		xTaskCreate( vSenderTask, "Sender1", 1000, (void *)1, 1, NULL );
		xTaskCreate( vSenderTask, "Sender2", 1000, (void *)2, 1, NULL );

		/* 启动调度器 */
		vTaskStartScheduler();
	}
	else
	{
		/* 无法创建互斥量 */
	}

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

任务:

static void vSenderTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 2UL );	
	int cnt = 0;
	int task = (int)pvParameters;
	int i;
	char c;
	
	/* 无限循环 */
	for( ;; )
	{	
		/* 获得互斥量: 上锁 */
		xSemaphoreTake(xMutex, portMAX_DELAY);
		
		printf("Task %d use UART count: %d, ", task, cnt++);
		c = (task == 1 ) ? 'a' : 'A';
		for (i = 0; i < 26; i++)
			printf("%c", c + i);
		printf("\r\n");
		
		/* 释放互斥量: 开锁 */
		xSemaphoreGive(xMutex);
		
		vTaskDelay(xTicksToWait);
	}
}

结果:

普通互斥量缺点: 

普通互斥量不能实现谁上锁就由谁解锁的问题,列如下列示例:

先让vtake上锁等待,然后在vGive中尝试是否能够解锁,如果能就继续试试能否再次上锁然后解锁:

vtake

void vTake( void *pvParameters )
{
	BaseType_t xMsataus;
	for(;;)
	{
	/*上锁*/	
	xMsataus=xSemaphoreTake(xMutex,portMAX_DELAY);
	printf("vTake shang suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
	}
	
}

vGive

void vGive( void *pvParameters )
{
	BaseType_t xMsataus;
	/*定义上锁次数*/
	int lock=0;
	int i=0;
	char C='A';
	while(1)
	{
	/*先尝试解锁*/	
	xMsataus=xSemaphoreGive(xMutex);
	printf("vGive jie suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
	if(xMsataus==pdTRUE)
	{
		/*重新上锁*/
		xMsataus=xSemaphoreTake(xMutex,0);
		printf("vGive shang suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
		/*上锁次数*/
		lock+=1;
		for(i=0;i<26;i++)
		{
			printf("vGive %d lock :%c\r\n",lock,C+i);

		}
		/*再次解锁*/
		xMsataus=xSemaphoreGive(xMutex);
		vTaskDelay(pdMS_TO_TICKS(1));
		
	}

	}
	
}

 main()

int main( void )
{
	prvSetupHardware();
	
    /* 创建互斥量 */
    xMutex = xSemaphoreCreateMutex( );

	if( xMutex != NULL )
	{
		/* 创建2个任务: 都是打印
		 * 优先级相同
		 */
		//xTaskCreate( vSenderTask, "Sender1", 1000, (void *)1, 1, NULL );
		//xTaskCreate( vSenderTask, "Sender2", 1000, (void *)2, 1, NULL );
		xTaskCreate( vGive, "give", 1000, NULL, 1, NULL );
		xTaskCreate( vTake, "take", 1000, NULL, 1, NULL );

		/* 启动调度器 */
		vTaskStartScheduler();
	}
	else
	{
		/* 无法创建互斥量 */
	}

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

        可以看到Take中的锁被give所解锁了,但是在Give中的临界资源(串口)在Give中再次上锁之后所保护了(打印A-Z)。然而在此代码中由于在Take任务中其上锁了,但是其却没有由自己解锁,故其占用临界资源失败了。如下图:

vTake shang suo is success打印多次,但是跟其他没有被保护起来的资源(打印vGive jie suo is %s)冲突了。所以这就要求程序员自己上锁自己解锁,来保护临界资源

递归锁 (Recursive)

在递归锁使用中要把configUSE_RECURSIVE_MUTEXES配置为1

普通互斥锁和递归锁在Take和Give函数区别

        xSemaphoreGive(xMutex)

        xSemaphoreGiveRecursive(xMutex)

        xSemaphoreTake(xMutex)

        xSemaphoreTakeRecursive(xMutex)

  • 在递归锁中真正实现了由谁上锁就由谁解锁的机制。
  • 任务A获得递归锁M后,它还可以多次去获得这个锁
  • "take"N次,要"give"N次,这个锁才会被释放

在上面代码中修改:

主要是把xSemaphoreCreateMutex( );修改为xSemaphoreCreateRecursiveMutex( );其次都是加入printf();查看能否解开别人锁。

main函数

int main( void )
{
	prvSetupHardware();
	
    /* 创建互斥量 */
    xMutex = xSemaphoreCreateRecursiveMutex( );
	

	if( xMutex != NULL )
	{
		/* 创建2个任务: 都是打印
		 * 优先级相同
		 */
		//xTaskCreate( vSenderTask, "Sender1", 1000, (void *)1, 1, NULL );
		//xTaskCreate( vSenderTask, "Sender2", 1000, (void *)2, 1, NULL );
		xTaskCreate( vGive, "give", 1000, NULL, 1, NULL );
		xTaskCreate( vTake, "take", 1000, NULL, 1, NULL );


		/* 启动调度器 */
		vTaskStartScheduler();
	}
	else
	{
		/* 无法创建互斥量 */
	}

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

vtake

void vTake( void *pvParameters )
{
	/*上锁次数*/
	int take_lock=0;
	BaseType_t xMsataus;
	for(;;)
	{
	/*上锁*/	
	xMsataus=xSemaphoreTakeRecursive(xMutex,portMAX_DELAY);
	printf("vTake shang suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");

	/*休眠一段时间查看Give任务能否解开Take的锁*/
	vTaskDelay(pdMS_TO_TICKS(30UL));
	/*休眠结束,查看自己能否再次上锁并解锁*/
	xMsataus=xSemaphoreTakeRecursive(xMutex,0);
	printf("vTake %d shang suo is %s\r\n",(take_lock+=2),(xMsataus==pdTRUE)?"success":"failed");
	/*解锁*/
	xSemaphoreGiveRecursive(xMutex);
	/*解开第一次的锁*/
	xSemaphoreGiveRecursive(xMutex);
	}
	
}

vGive

void vGive( void *pvParameters )
{
	BaseType_t xMsataus;
	/*定义上锁次数*/
	int lock=0;
	int i=0;
	char C='A';
	while(1)
	{
	/*先尝试解锁*/	
	xMsataus=xSemaphoreGiveRecursive(xMutex);
	printf("vGive jie suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
	if(xMsataus==pdTRUE)
	{
		/*重新上锁*/
		xMsataus=xSemaphoreTakeRecursive(xMutex,0);
		printf("vGive shang suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
		/*上锁次数*/
		lock+=1;
		for(i=0;i<26;i++)
		{
			printf("vGive %d lock :%c\r\n",lock,C+i);

		}
		/*再次解锁*/
		xMsataus=xSemaphoreGiveRecursive(xMutex);
		vTaskDelay(pdMS_TO_TICKS(1));
		
	}
	/*解锁失败*/
	else
	{
	   /*尝试能否自己再次上锁*/
		xMsataus=xSemaphoreTakeRecursive(xMutex,0);
		printf("vGive zaici shang suo is %s\r\n",(xMsataus==pdTRUE)?"success":"failed");
		/*解锁*/
		if(xMsataus==pdTRUE)
		xSemaphoreGiveRecursive(xMutex);

	}
	}
	
}

结果可以看出vGive不能解锁vTake上的锁且不能再次上锁

但是vtake任务仍能上锁(但是注意上多少锁解开多少锁) 

优先级问题 

优先级反转 

使用信号量来维持不同等级的任务运行的时候,容易发生优先级反转的问题,但是互斥量的优先级继承问题可以解决这个问题

优先级反转: 当低优先级任务A运行和高优先级任务B运行时时,A占用串口资源,B就得等其释放信号量,导致B无法运行。

在此例程中优先级从高到低依次是H,M,L    H先休眠一段时间让M运行,然后M上锁,然后L运行。等H休眠结束再此运行。结果发现不仅比H优先级低一级的M一直等待上锁,导致后来H也一直等待上锁,最后就是只有没有上锁任务的L运行了。  问题就是M上锁一直是等待状态。互斥量解决了这一个问题

H:

static void vHPTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 20UL );	
	BaseType_t xstatus;
	flagLPTaskRun = 0;
	flagMPTaskRun = 0;
	flagHPTaskRun = 1;

	printf("HPTask start\r\n");
	
	/* 让LPTask先运行 */	
	vTaskDelay(xTicksToWait);
	printf("HPTask contiune start\r\n");
	/* 无限循环 */
	for( ;; )
	{	
		flagLPTaskRun = 0;
		flagMPTaskRun = 0;
		flagHPTaskRun = 1;
		printf("HPTask wait for Lock\r\n");
		
		/* 获得互斥量/二进制信号量 */
		xstatus=xSemaphoreTake(xLock, portMAX_DELAY);
		printf("HPTask Lock is %s\r\n",(xstatus==pdTRUE)?"success":"failed");
		flagLPTaskRun = 0;
		flagMPTaskRun = 0;
		flagHPTaskRun = 1;
		
		/* 释放互斥量/二进制信号量 */
		xSemaphoreGive(xLock);
	}
}

 M:

static void vMPTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 30UL );	
	BaseType_t xstatus;
	flagLPTaskRun = 0;
	flagMPTaskRun = 1;
	flagHPTaskRun = 0;

	printf("MPTask start\r\n");
	
	
	/* 让LPTask、HPTask先运行 */	
	//vTaskDelay(xTicksToWait);
	xstatus=xSemaphoreTake(xLock,portMAX_DELAY);
	printf("MPTask Lock is %s\r\n",(xstatus==pdTRUE)?"success":"failed");
	/* 无限循环 */
	for( ;; )
	{	
		flagLPTaskRun = 0;
		flagMPTaskRun = 1;
		flagHPTaskRun = 0;
		printf("MPTask start!!!\r\n");
	}
	xSemaphoreGive(xLock);
}

L:

static void vLPTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 10UL );	
	uint32_t i;
	char c = 'A';

	printf("LPTask start\r\n");
	
	/* 无限循环 */
	for( ;; )
	{	
		flagLPTaskRun = 1;
		flagMPTaskRun = 0;
		flagHPTaskRun = 0;
		printf("LPTask\r\n");
		/* 获得互斥量/二进制信号量 */

		//xSemaphoreTake(xLock, portMAX_DELAY);
		
		/* 耗时很久 */

	
		printf("LPTask take the Lock for long time");
    	for (i = 0; i < 26; i++) 
		{
			flagLPTaskRun = 1;
			flagMPTaskRun = 0;
			flagHPTaskRun = 0;
			printf("%c", c + i);
		}
		printf("\r\n");
		
		/* 释放互斥量/二进制信号量 */
		//xSemaphoreGive(xLock);
		
		vTaskDelay(xTicksToWait);
	}
}

main()

int main( void )
{
	prvSetupHardware();
	
    /* 创建互斥量/二进制信号量 */
    xLock = xSemaphoreCreateBinary( );

	if( xLock != NULL )
	{
		/* 创建3个任务: LP,MP,HP(低/中/高优先级任务)
		 */
		xTaskCreate( vLPTask, "LPTask", 1000, NULL, 1, NULL );
		xTaskCreate( vMPTask, "MPTask", 1000, NULL, 2, NULL );
		xTaskCreate( vHPTask, "HPTask", 1000, NULL, 3, NULL );

		/* 启动调度器 */
		vTaskStartScheduler();
	}
	else
	{
		/* 无法创建互斥量/二进制信号量 */
	}

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

M一直等待上锁:

到M运行的时候发现 并没有运行

printf("MPTask Lock is %s\r\n",(xstatus==pdTRUE)?"success":"failed");故判断其一直等待上锁

等再次H运行的时候同理判断H也在等待上锁。故L后来一直运行,造成优先级反转

 解决优先级反转

修改: 

int main( void )
{
prvSetupHardware();
/* 创建互斥量/二进制信号量 */
//xLock = xSemaphoreCreateBinary( );
xLock = xSemaphoreCreateMutex( );

可以看出H休眠之后,M上锁成功,且M解锁了。后来被H成功上锁形成上锁,解锁循环。

事件组(event group)

要使用事件组,必须有如下配置:

/* 1. 工程中添加event_groups.c */
\FreeRTOS\Source\event_groups.c   这个路径
/* 2. 源码中包含头文件 */
##include "event_groups.h"
FreeRTOS 中,可以使用事件组 (event group) 来解决这些问题。

 一般使用用途

其实就是有一方发出事件,一方或多方接受事件
事件常用就是事件唤醒事件

概念

事件组可以简单地认为就是一个整数:
  • 每一位表示一个事件
  • 每一位事件的含义由程序员决定,比如:Bit0表示用来串口是否就绪,Bit1表示按键是否被按下
  • 这些位,值为1表示事件发生了,值为0表示事件没发生
  • 一个或多个任务、ISR都可以去写这些位;一个或多个任务、ISR都可以去读这些位
  • 可以等待某一位、某些位中的任意一个,也可以等待多位

配置:

事件组用一个整数来表示,其中的高 8位留给内核使用,只能用其他的位来表示事件。那么这个整数是 多少位的?(在event_groups.c里)
  • 如果configUSE_16_BIT_TICKS1,那么这个整数就是16位的,低8位用来表示事件
  • 如果configUSE_16_BIT_TICKS0,那么这个整数就是32位的,低24位用来表示事件

configUSE_16_BIT_TICKS是用来表示Tick Count的,怎么会影响事件组?这只是基于效率来考虑

  • 如果configUSE_16_BIT_TICKS1,就表示该处理器使用16位更高效,所以事件组也使用16
  • 如果configUSE_16_BIT_TICKS0,就表示该处理器使用32位更高效,所以事件组也使用32

和信号量和队列的不同

事件组和队列、信号量等不太一样,主要集中在 2 个地方:
  • 唤醒谁?
  1. 队列、信号量:事件发生时,只会唤醒一个任务
  2. 事件组:事件发生时,会唤醒所有符号条件的任务,简单地说它有"广播"的作用
  • 是否清除事件?
  1. 队列、信号量:是消耗型的资源,队列的数据被读走就没了;信号量被获取后就减少了
  2. 事件组:被唤醒的任务有两个选择,可以让事件保留不动,也可以清除事件

事件组的一般操作 

事件组的常规操作如下:
  1. 先创建事件组
  2. 任务CD等待事件:
等待什么事件?可以等待某一位、某些位中的任意一个,也可以等待多位。简单地说就
" " " " 的关系。
得到事件时,要不要清除?可选择清除、不清除。
  • 任务AB产生事件:设置事件组里的某一位、某些位

设置事件组:

/* 设置事件组中的位
* xEventGroup: 哪个事件组
* uxBitsToSet: 设置哪些位?
* 如果uxBitsToSet的bitX, bitY为1, 那么事件组中的bitX, bitY被设置为1
* 可以用来设置多个位,比如 0x15 就表示设置bit4, bit2, bit0
* 返回值: 返回原来的事件值(没什么意义, 因为很可能已经被其他任务修改了)
*/
EventBits_t xEventGroupSetBits( EventGroupHandle_t xEventGroup,const EventBits_t uxBitsToSet );

等待事件组: 

EventBits_t xEventGroupWaitBits( EventGroupHandle_t xEventGroup,
const EventBits_t uxBitsToWaitFor,
const BaseType_t xClearOnExit,
const BaseType_t xWaitForAllBits,
TickType_t xTicksToWait );
/*
xEventGroup 等待哪个事件组?
uxBitsToWaitFor 等待哪些位?哪些位要被测试?
xWaitForAllBits 怎么测试?是"AND"还是"OR"?
pdTRUE: 等待的位,全部为1;
pdFALSE: 等待的位,某一个为1即可
xClearOnExit 函数提出前是否要清除事件?
pdTRUE: 清除uxBitsToWaitFor指定的位
pdFALSE: 不清除
xTicksToWait 如果期待的事件未发生,阻塞多久。可以设置为0:判断后即刻返回;
可设置为portMAX_DELAY:一定等到成功才返回;可以设置为期望的Tick Count,
一般用 pdMS_TO_TICKS() 把ms转换为TickCount
返回值
返回的是事件值,如果期待的事件发生了,返回的是"非阻塞条件成立"时的事件值;
如果是超时退出,返回的是超时时刻的事件值。
*/

事件组举例:

举例一:

本例程创建3个任务,优先级从高到底依次为:vshangzao8,vqichuang,vnaozhong。优先级从低到高可以看做是做事的顺序:如1:闹钟响,2:起床,3:上早八。程序运行情况:先早八等待起床,起床等待闹钟,闹钟响过之后返回起床,起床执行之后返回早八。然后再早八里面又进行下一个循环。 本例程:本质上是各个任务依次等待,然后按照顺序执行的例程。

早八:

/*创建句柄*/
EventGroupHandle_t xEventGroup;
/*宏定义标识符*/
#define shangzao8 (1<<0)
#define naozhong (1<<1)
#define qichuang (1<<2)

/*-----------------------------------------------------------*/

void vshangzao8( void *pvParameters )
{
	int i=0;
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		/*等待闹钟响和起床*/
		xEventGroupWaitBits(xEventGroup,qichuang|naozhong,pdTRUE,pdTRUE,portMAX_DELAY);//事件组 等待那些位 是否清除位 等待方式pdTRUE与1,pdFALSE或1   等待时间
		printf("I am happy go to zao8 in %d day\r\n",i++);
		/*又来一天早8*/
		xEventGroupSetBits(xEventGroup,shangzao8);

			
	}
}

起床:

void vqichuang( void *pvParameters )
{	
	int i=0;
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		/*等待闹钟响*/
		xEventGroupWaitBits(xEventGroup,naozhong,pdTRUE,pdTRUE,portMAX_DELAY);//事件组 等待那些位 是否清除位 等待方式pdTRUE与1,pdFALSE或1   等待时间
		printf("happy qichuang in %d day\r\n",i++);
		/*闹钟响并且起床了*/
		xEventGroupSetBits(xEventGroup,qichuang|naozhong);//不能单设一个qinghcuang否则shangzao8不成立!!!

		
			
	}
}

闹钟:

void vnaozhong( void *pvParameters )
{	
	int i=0;
//	const TickType_t xDelay5ms = pdMS_TO_TICKS( 5UL );		
	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		printf("naozhong dingdingding in %d day\r\n",i++);
		/*闹钟响了*/
		xEventGroupSetBits(xEventGroup,naozhong);
		//printf("naozhong dingdingding is %d\r\n",i++);
	
	}
}

main函数

int main( void )
{
	prvSetupHardware();
	/*创建事件组*/
	xEventGroup=xEventGroupCreate();
	xTaskCreate(vshangzao8, "Task 1", 1000, NULL, 3, NULL);
	xTaskCreate(vqichuang, "Task 2", 1000, NULL, 2, NULL);
	xTaskCreate(vnaozhong, "Task 3", 1000, NULL, 1, NULL);

	/* 启动调度器 */
	vTaskStartScheduler();

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

举例二:

本例程主要是xEventGroupSync()函数的使用(sync(同步))

EventBits_t xEventGroupSync( EventGroupHandle_t xEventGroup,//事件组句柄
const EventBits_t uxBitsToSet,//自己的标志位
const EventBits_t uxBitsToWaitFor,//别人的标志位
TickType_t xTicksToWait//等待时间 );

此函数基本和xEventGroupWaitBits()类似,xEventGroupSync取消了是否清除标志位的参数,但是多了新的标志位参数,这就表明:

  •  xEventGroupSync()函数使用之后标志位会被自动清零
  • 不仅要满足uxBitsToSet(表示自己任务做了什么)的标志位还要满足uxBitsToWaitFor(其他任务标志位)才能正常返回函数值。

将创建3个任务优先级从低到高依次是:vCookingTask,vBuyingTask,vTableTask

  1. 当vTableTask执行到xEventGroupSync等待,
  2. 之后vBuyingTask执行到xEventGroupSync等待,
  3. 然后vCookingTask执行到xEventGroupSync,
  4. 至此参数uxBitsToWaitFor(也就是程序中的ALL)满足,则根据等待时间长短vTableTask,vBuyingTaskv,CookingTask依次执行串口函数,
  5. 之后再次循环。

vTableTask:

static void vTableTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 100UL );		
	int i = 0;
	
	/* 无限循环 */
	for( ;; )
	{
		/* 做自己的事 */
		printf("%s is do the table %d time....\r\n", (char *)pvParameters, i);
		
		/* 表示我做好了, 还要等别人都做好 */
		xEventGroupSync(xEventGroup, TABLE, ALL, portMAX_DELAY);
	
		/* 别人也做好了, 开饭 */
		printf("%s is eating %d time....\r\n", (char *)pvParameters, i++);
		vTaskDelay(xTicksToWait);
	}
}

vBuyingTask:

static void vBuyingTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 100UL );		
	int i = 0;
	
	/* 无限循环 */
	for( ;; )
	{
		/* 做自己的事 */
		printf("%s is buying %d time....\r\n", (char *)pvParameters, i);
		
		/* 表示我做好了, 还要等别人都做好 */
		xEventGroupSync(xEventGroup, BUYING, ALL, portMAX_DELAY);
	
		/* 别人也做好了, 开饭 */
		printf("%s is eating %d time....\r\n", (char *)pvParameters, i++);
		vTaskDelay(xTicksToWait);
	}
}

vCookingTask:

static void vCookingTask( void *pvParameters )
{
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 100UL );		
	int i = 0;
	
	/* 无限循环 */
	for( ;; )
	{
		/* 做自己的事 */
		printf("%s is cooking %d time....\r\n", (char *)pvParameters, i);
		
		/* 表示我做好了, 还要等别人都做好 */
		xEventGroupSync(xEventGroup, COOKING, ALL, portMAX_DELAY);
	
		/* 别人也做好了, 开饭 */
		printf("%s is eating %d time....\r\n", (char *)pvParameters, i++);
		vTaskDelay(xTicksToWait);
	}
}

定义:

/* 事件组句柄 */
EventGroupHandle_t xEventGroup;

/* bit0: 摆桌
 * bit1: 买东西
 * bit2: 炒菜
 */
#define TABLE    (1<<0)
#define BUYING   (1<<1)
#define COOKING  (1<<2)
#define ALL      (TABLE | BUYING | COOKING)

main(); 

int main( void )
{
	prvSetupHardware();
	
    /* 创建递归锁 */
    xEventGroup = xEventGroupCreate( );

	if( xEventGroup != NULL )
	{
		/* 创建3个任务: 洗菜/生火/炒菜
		 */
		xTaskCreate( vCookingTask, "task1", 1000, "A", 1, NULL );
		xTaskCreate( vBuyingTask,  "task2", 1000, "B", 2, NULL );
		xTaskCreate( vTableTask,   "task3", 1000, "C", 3, NULL );

		/* 启动调度器 */
		vTaskStartScheduler();
	}
	else
	{
		/* 无法创建事件组 */
	}

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

任务通知 (Task Notifications

       我们使用队列、信号量、事件组等等方法时,并不知道对方是谁。使用任务通知时,可以明确指定:通知哪个任务。而在使用任务通知的时候,在任务结构体 TCB 中就包含了内部对象,可以直接接收别人发过来的 " 通知"。
       结构体TCB有两个成员:
  • 一个是uint8_t类型,用来表示通知状态
  • 一个是uint32_t类型,用来表示通知值
typedef struct tskTaskControlBlock
{
......
/* configTASK_NOTIFICATION_ARRAY_ENTRIES = 1 */
volatile uint8_t ucNotifyState[ configTASK_NOTIFICATION_ARRAY_ENTRIES ];
volatile uint32_t ulNotifiedValue[ configTASK_NOTIFICATION_ARRAY_ENTRIES ];
......
} tskTCB;

任务通知有3种状态:

  • taskNOT_WAITING_NOTIFICATION:任务没有在等待通知
  • taskWAITING_NOTIFICATION:任务在等待通知
  • taskNOTIFICATION_RECEIVED:任务接收到了通知,也被称为pending(有数据了,待处理)
##define taskNOT_WAITING_NOTIFICATION ( ( uint8_t ) 0 ) /* 也是初始状态 */
##define taskWAITING_NOTIFICATION ( ( uint8_t ) 1 )
##define taskNOTIFICATION_RECEIVED ( ( uint8_t ) 2 )

 通知值可以有多种类型:

  • 计数值
  • 一个或某些位(类似事件组,对于函数xTaskNotify()而言)
  • 任意数值

典型举例:

简单函数使用:计数值传输(轻量级信号量)

先介绍使用的主要函数,这两个是简单的通知任务函数(青春版)

xTaskNotifyGive()      ulTaskNotifyTake()

/*参数:任务的句柄
使得通知值加一并使得通知状态变为"pending",
也就是 taskNOTIFICATION_RECEIVED ,表示有数据了、待处理*/
BaseType_t xTaskNotifyGive( TaskHandle_t xTaskToNotify );

/*取出通知值,如果通知值等于0,则阻塞(可以指定超时时间)
当通知值大于0时,任务从阻塞态进入就绪态在ulTaskNotifyTake返回之前,
还可以做些清理工作:把通知值减一(当就Give一次的时候就是清0),或者把通知值清零*
返回值:
如果xTicksToWait非0,则返回值有2种情况:
1. 大于0:Give计数的值(累计加的1)
2. 等于0:一直没有其他任务增加通知值,最后超时返回0/
uint32_t ulTaskNotifyTake( BaseType_t xClearCountOnExit, TickType_t xTicksToWait
);

然后介绍具体例子 

环形缓冲区:(队列)主要是用于在发送任务和接受任务之间数据传输和同步的作用,保证数据的完整性和顺序

/*-----------------------------------------------------------*/
/* 环形缓冲区 */
#define BUF_LEN  32

/*(i+1):首先,将当前位置的索引 i 增加1,表示下一个位置的索引。
*&0x1F:然后,通过按位与操作(&),将计算结果与 0x1F 进行与运算。
*0x1F 的二进制表示是 00011111,即低5位全为1,其余位为0。
*这个操作的目的是确保索引值不超出环形缓冲区的范围。
*假设环形缓冲区大小为32(即2的5次方,在第六个0的位置),那么通过按位与操作,
*将索引限制在0到31之间,实现环形循环的效果。当索引增加到31时,
*按位与操作会将其变为0,从而使索引回到环形缓冲区的起始位置。
*总之,这段代码实现了一个简单的环形缓冲区中下一个位置索引的计算方法,
*确保索引在环形缓冲区范围内循环。*/
#define NEXT_PLACE(i) ((i+1)&0x1F)

/*定义环形缓冲区大小,环形缓冲区由固定大小的数组构成,
数组长度为BUF_LEN*/
uint8_t txbuf[BUF_LEN];
/*定义读写指针: 环形缓冲区有两个指针,
*分别为读指针(Read Pointer)和写指针(Write Pointer),
*用于标记当前读取和写入的位置。*/
uint32_t tx_r = 0;
uint32_t tx_w = 0;


/*r==w时为空队列*/
static int is_txbuf_empty(void)
{
	return tx_r == tx_w;
}


/*w指针在r指针前一个位置时为满队列*/
static int is_txbuf_full(void)
{
	return NEXT_PLACE(tx_w) == tx_r;
}


/*往队列写入一个数据*/
static int txbuf_put(unsigned char val)
{
	if (is_txbuf_full())
		return -1;
	txbuf[tx_w] = val;
	tx_w = NEXT_PLACE(tx_w);
	return 0;
}


/*从队列读出一个数据,-1表示读出失败,0表示读出成功
*其中读出的数据存在pval指针中*/
static int txbuf_get(unsigned char *pval)
{
	if (is_txbuf_empty())
		return -1;
	*pval = txbuf[tx_r];
	tx_r = NEXT_PLACE(tx_r);
	return 0;
}


/*-----------------------------------------------------------*/

main函数:初始化

int main( void )
{
	prvSetupHardware();

	/* 创建1个任务用于发送任务通知
	 * 优先级为2
	 */
	xTaskCreate( vSenderTask, "Sender", 1000, NULL, 2, NULL );

	/* 创建1个任务用于接收任务通知
	 * 优先级为1
	 */
	 xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 1, &xRecvTask );

	/* 启动调度器 */
	vTaskStartScheduler();

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

发送函数:通知接收任务3次,并且往队列发送3个字符

static void vSenderTask( void *pvParameters )
{
	int i;
	int cnt_tx = 0;
	int cnt_ok = 0;
	int cnt_err = 0;
	char c;
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 20UL );	
	
	/* 无限循环 */
	for( ;; )
	{		
		for (i = 0; i < 3; i++)
		{
			/* 放入数据 */
			c = 'a'+cnt_tx;
			txbuf_put(c);
			cnt_tx++;
			
			/* 发出任务通知 */
			if (xTaskNotifyGive(xRecvTask) == pdPASS)
				printf("xTaskNotifyGive %d time: OK, val :%c\r\n", cnt_ok++, c);
			else
				printf("xTaskNotifyGive %d time: ERR\r\n", cnt_err++);
		}
				
		vTaskDelay(xTicksToWait);
	}
}

 接收任务:在发送任务休眠的时候接收通知值,且读出队列的值

static void vReceiverTask( void *pvParameters )
{
	int cnt_ok = 0;
	uint8_t c;
	int notify_val;
	
	/* 无限循环 */
	for( ;; )
	{
		/* 得到了任务通知, 让通知值清零 */
		notify_val = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

		/* 打印这几个字符 */
		printf("ulTaskNotifyTake OK: %d, data: ", cnt_ok++);
		
		/* 一次性把所有数据取出来 */
		while (notify_val--)
		{
			txbuf_get(&c);
			printf("%c", c);
		}
		printf("\r\n");

	}
}

结果 :

  1. 发送任务先通知接收任务3次,并且往队列发送3个字符
  2.  然后接收任务:在发送任务休眠的时候接收通知值,且读出队列的值,这时候由于vReceiverTask()新一边循环的portMAX_DELAY,导致其一直阻塞
  3. 紧接着又是发送任务的又一遍循环

  • 信号量是个公开的资源,任何任务、ISR都可以使用它:可以释放、获取信号量。
  • 而本节程序中,发送任务只能给指定的任务发送通知,目标明确;接收任务只能从自己的通知值中得到数据,来源明确。

复杂函数使用:传输任意值(轻量级队列  (长度1))

先介绍复杂函数的用法:

xTaskNotify 函数:

xTaskNotify 函数功能更强大,可以使用不同参数实现各类功能,比如:
  1. 信号量方面:让接收任务的通知值加一:这时 xTaskNotify() 等同于 xTaskNotifyGive()
  2. 事件组方面:设置接收任务的通知值的某一位、某些位,这就是一个轻量级的、更高效的事件组
  3. 队列方面:把一个新值写入接收任务的通知值:上一次的通知值被读走后,写入才成功。这就是轻量级的、长度为1的队列
  4. 用一个新值覆盖接收任务的通知值:无论上一次的通知值是否被读走,覆盖都成功。类似 xQueueOverwrite() 函数,这就是轻量级的邮箱。
  xTaskNotifyWait() 函数:
  1. 可以让任务等待(可以加上超时时间),等到任务状态为"pending"(也就是有数据)
  2. 还可以在函数进入、退出时,清除通知值的指定位

BaseType_t xTaskNotify( TaskHandle_t xTaskToNotify, uint32_t ulValue,
eNotifyAction eAction );


BaseType_t xTaskNotifyWait( uint32_t ulBitsToClearOnEntry,
uint32_t ulBitsToClearOnExit,
uint32_t *pulNotificationValue,
TickType_t xTicksToWait );

参数表:

Wait:

 具体实例:

main:初始化

int main( void )
{
	prvSetupHardware();

	/* 创建1个任务用于发送任务通知
	 * 优先级为2
	 */
	xTaskCreate( vSenderTask, "Sender", 1000, NULL, 2, NULL );

	/* 创建1个任务用于接收任务通知
	 * 优先级为1
	 */
	 xTaskCreate( vReceiverTask, "Receiver", 1000, NULL, 1, &xRecvTask );

	/* 启动调度器 */
	vTaskStartScheduler();

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

发送函数:发送3个字符a,b,c并且通知接收函数,因为是不能覆盖,所以最后在接收任务中的就是a了。

static void vSenderTask( void *pvParameters )
{
	int i;
	int cnt_tx = 0;
	int cnt_ok = 0;
	int cnt_err = 0;
	char c;
	const TickType_t xTicksToWait = pdMS_TO_TICKS( 20UL );	
	
	/* 无限循环 */
	for( ;; )
	{		
		for (i = 0; i < 3; i++)
		{
			/* 放入数据 */
			c = 'a'+cnt_tx;
			cnt_tx++;
			
			/* 发出任务通知 
			 * 发给谁? xRecvTask
			 * 发什么? c
			 * 能否覆盖? 不能, eSetValueWithoutOverwrite
			 */
			if (xTaskNotify(xRecvTask, 
							(uint32_t)c, /* 发送的数据 */
							eSetValueWithoutOverwrite) == pdPASS)
				printf("xTaskNotify %d time: OK, val :%c\r\n", cnt_ok++, c);
			else
				printf("xTaskNotify %d time: ERR, val :%c\r\n", cnt_err++, c);
		}
				
		vTaskDelay(xTicksToWait);
	}
}

 接收函数:接收字符,但是因为是不能覆盖,所以最后在接收任务中的就是a了。

static void vReceiverTask( void *pvParameters )
{
	int cnt_ok = 0;	
	uint32_t ulValue;
	BaseType_t xResult;	
	
	/* 无限循环 */
	for( ;; )
	{
		/* 等待数据 */
		xResult = xTaskNotifyWait(
					0, /* 发送任务会存入新数据, 无需接收方在进入函数时清零 */
					0, /* 发送任务会存入新数据, 无需接收方在退出函数时清零 */
					&ulValue, /* 用来保存读出的数据 */		
					portMAX_DELAY /* 一直等待数据 */
				);

		/* 打印这个字符 */
		if (xResult == pdPASS)
			printf("xTaskNotifyWait OK: %d, data: %c\r\n",
				cnt_ok++, (char)ulValue);		
	}
}

可以看出普通xTaskNotify()相较于xTaskNotifyGive()多了传输数据这一个参数。

结果:

  1. 发送函数:先通知接受任务3次,并且发送3次任意字符数据
  2. 接收函数:接收字符,因为不能覆盖所以只能接收第一个字符
  3. 因为vReceiverTask中的portMAX_DELAY 导致其阻塞然后等到发送函数又一轮循环

 

本程序使用 xTaskNotify/xTaskNotifyWait 实现了轻量级的队列 ( 该队列长度只有 1) ,代码更简单:
  • 无需创建队列
  • 消耗内存更少
  • 效率更高
队列是个公开的资源,任何任务、 ISR 都可以使用它:可以存入数据、取出数据。
而本节程序中,发送任务只能给指定的任务发送通知,目标明确;接收任务只能从自己的通知值中得到数据,来源明确。
注意:任务通知值只有一个,数据可能丢失,设计程序时要考虑这点。

软件定时器(software timer)

待续

  • 24
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值