FREERTOS队列详解

在实际的应用中,常常会遇到一个任务或者中断服务需要和另外一个任务进行“沟通交流”,这个“沟通交流”的过程其实就是消息传递的过程。在没有操作系统的时候两个应用程序进行消息传递一般使用全局变量的方式,但是如果在使用操作系统的应用中用全局变量来传递消息就会涉及到“资源管理”的问题。FreeRTOS 对此提供了一个叫做“队列”的机制来完成任务与任务、任务与中断之间的消息传递。

任务与任务之间消息传递的常见场景有业务数据的缓存和使用、标志位的设置和使用等,裸机中都是使用全局变量,到了操作系统环境中,就不必使用全局变量了,而是使用系统提供的一种机制——队列——来实现,内部原理肯定也是使用的全局变量,只不过这些全局变量由操作系统进行合理地管理了。可以说,操作系统本身就是一种程序架构。

注意区分列表和队列。列表一般被叫做任务列表,队列一般被叫做消息队列。 

全局变量有什么问题呢? 

因为全局变量底层实际是要进行读改写的操作的,在多任务的场景下,全局变量可能会在一个任务中还没完成操作,就被下一个任务打断,这样,就会导致某些程序获取到的数据是不对的。

而队列在实现时,加入了临界区保护

因此,全局变量只适合在裸机环境下使用,因为裸机环境下不存在任务之间的中途切换问题。在系统环境下,就需要使用队列来进行消息传递。

队列简介

队列是为了任务与任务、任务与中断之间的通信而准备的,可以在任务与任务、任务与中断之间传递消息,队列中可以存储有限的、大小固定的数据项目。任务与任务、任务与中断之间要交流的数据保存在队列中,叫做队列项目。队列所能保存的最大数据项目数量叫做队列的长度,创建队列的时候会指定数据项目的大小和队列的长度。由于队列用来传递消息的,所以也称为消息队列。

FreeRTOS中的队列的队列与平时数据结构中所说的队列大同小异,只不过在FreeRTOS中将队列做了一个扩展,就是队列既可以先进先出(FIFO)也可以先进后出(FILO),即它兼具了数据结构中的队列和栈。

FreeRTOS的队列是一个循环队列,也就是一个循环缓冲区

我们一般使用的就是常规的队列模式,即队尾入队,队头出队,FIFO先进先出,这里的队头队尾并不是指的哪个具体方向,而是队头指针和队尾指针,也就是读指针和写指针。

队列的特点

接下来详细说明。

值传递

通常队列采用先进先出(FIFO)的存储缓冲机制,也就是往队列发送数据的时候(也叫入队)永远都是发送到队列的尾部,而从队列提取数据的时候(也叫出队)是从队列的头部提取的。但是也可以使用 LIFO 的存储缓冲,也就是后进先出,FreeRTOS 中的队列也提供了 LIFO 的存储缓冲机制。

数据发送到队列中会导致数据拷贝,也就是将要发送的数据拷贝到队列中,这就意味着在队列中存储的是数据的原始值,而不是原数据的引用(即只传递数据的指针),这个也叫做值传递。学过UCOS 的同学应该知道,UCOS 的消息队列采用的是引用传递,传递的是消息指针。采用引用传递的话消息内容就必须一直保持可见性,也就是消息内容必须有效,那么局部变量这种可能会随时被删掉的东西就不能用来传递消息,但是采用引用传递会节省时间啊!因为不用进行数据拷贝。

采用值传递的话虽然会导致数据拷贝,会浪费一点时间,但是一旦将消息发送到队列中原始的数据缓冲区就可以删除掉或者覆写,这样的话这些缓冲区就可以被重复的使用。FreeRTOS中使用队列传递消息的话虽然使用的是数据拷贝,但是也可以使用引用来传递消息啊,我直接往队列中发送指向这个消息的地址指针不就可以了!这样当我要发送的消息数据太大的时候就可以直接发送消息缓冲区的地址指针,比如在网络应用环境中,网络的数据量往往都很大的,采用数据拷贝的话就不现实。

多任务访问

队列不是属于某个特别指定的任务的,任何任务都可以向队列中发送消息,或者从队列中提取消息。就好像全局变量,哪个函数都能访问。只不过,不同的函数访问的是不同的全局变量,同理,我们可以为不同的任务指定不同的队列。

出队阻塞

当任务尝试从一个队列中读取消息的时候可以指定一个阻塞时间,这个阻塞时间就是当任务从队列中读取消息无效的时候任务阻塞的时间。出队就是就从队列中读取消息,出队阻塞是针对从队列中读取消息的任务而言的。比如任务 A 用于处理串口接收到的数据,串口接收到数据以后就会放到队列 Q 中,任务 A 从队列 Q 中读取数据。但是如果此时队列 Q 是空的,说明还没有数据,任务 A 这时候来读取的话肯定是获取不到任何东西,那该怎么办呢?任务 A 现在有三种选择,一:二话不说扭头就走,二:要不我在等等吧,等一会看看,说不定一会就有数据了,三:死等,死也要等到你有数据!选哪一个就是由这个阻塞时间决定的,这个阻塞时间单位是时钟节拍数。阻塞时间为 0 的话就是不阻塞,没有数据的话就马上返回任务继续执行接下来的代码,对应第一种选择。如果阻塞时间为 0~portMAX_DELAY当任务没有从队列中获取到消息的话就进入阻塞态,阻塞时间指定了任务进入阻塞态的时间,当阻塞时间到了以后还没有接收到数据的话就退出阻塞态,返回任务接着运行下面的代码,如果在阻塞时间内接收到了数据就立即返回,执行任务中下面的代码,这种情况对应第二种选择。当阻塞时间设置为portMAX_DELAY 的话,任务就会一直进入阻塞态等待,直到接收到数据为止!这个就是第三种选择。

入队阻塞

入队说的是向队列中发送消息,将消息加入到队列中。和出队阻塞一样,当一个任务向队列发送消息的话也可以设置阻塞时间。比如任务 B 向消息队列 Q 发送消息,但是此时队列 Q 是满的,那肯定是发送失败的。此时任务 B 就会遇到和上面任务 A 一样的问题,这两种情况的处理过程是类似的,只不过一个是向队列 Q 发送消息,一个是从队列 Q 读取消息而已。

队列操作过程图示

有一个问题一直没太弄明白,那就是队列的入队和出队到底是怎么入怎么出的?

队列结构体

有一个结构体用于描述队列,叫做 Queue_t,这个结构体在文件 queue.c 中定义如下:

老版本的 FreeRTOS 中队列可能会使用 xQUEUE 这个名字,新版本 FreeRTOS 中队列的名字都使用 Queue_t

其中内部联合体定义如下:

结构上,队列分为两个部分(队列结构体本身存储区+队列项的存储区):

看以上箭头的指向来辅助理解队列结构体某些成员的含义。

pcHead指向的是存储区域的起始地址,也就是队列项的存储区域;

pcWriteTo指向下一个要写入的位置,这个指针是依次变化的;

当用作队列使用时:

联合体里的xQueue.pcTail指向存储区的结束区域;

xQueue.pcReadFrom指向最后一个读取队列的地址。

队列占用的是freertos管理的堆内存。 

队列相关API函数

注意,这里说的队列是个结构体,队列里的队列项区域才是跟我们数据结构学的那个队列是类似的概念。注意区分。

使用队列的主要流程:创建队列 ——写队列——读队列。

创建队列

我们以动态创建队列为例,xQueueCreate函数原型如下所示:

这个函数本质上是个宏,真正完成队列创建的函数是 xQueueGenericCreate(),该函数在文件 queue.c 中有定义,有三个入口参数,分别为队列长度、队列项目的大小以及队列此时被用作的类型。

创建队列成功后,会返回一个队列句柄,后续对队列的写入和读取,都是操作该句柄。

前面说 FreeRTOS 基于队列实现了多种功能,每一种功能对应一种队列类型,队列类型的 queue.h 文件中有定义:

队列长度固定,各队列项大小一样,和数组很像。 

写入消息

写入消息有多个函数

写入时,我们可以往尾部写,也可以往头部写。

我们以常规级函数为例,看下这几个函数的调用关系

可以看到这几个写入函数调用的是同一个函数xQueueGenericSend(),只是指定了不同的写入位置!

另外,注意覆写方式写入队列,只有在队列的队列长度为 1 时,才能够使用,并且,覆写队列的超时时间设置默认为0,这是因为覆写时,不管原来有没有数据,都会写上去,所以不存在什么超时时间的概念。

我们接着看下函数xQueueGenericSend()的原型

注意,上面操作队列都是针对队列句柄。

队尾入队、队头出队,和日常买东西时排队是一样的。 

注意,任务级的发送函数里,第一个形参是目标队列的句柄,第二个形参是要发送的数据的指针(不是变量,是指针),第三个形参是超时时间。

但是,中断级的发送函数有点不一样,前两个参数是一样的,但是第三个参数不是超时时间,具体是什么我们继续往下看。

函数

xQueueSendFromISR()、

xQueueSendToBackFromISR()、

xQueueSendToFrontFromISR()

这三个函数也是向队列中发送消息的,这三个函数用于中断服务函数中。这三个函数本质也宏,其中函数 xQueueSendFromISR ()和 xQueueSendToBackFromISR ()是一样的,都是后向入 队,即将新的消息插入到队列的后面。函数 xQueueSendToFrontFromISR ()是前向入队,即将新消息插入到队列的前面。这三个函数同样调用同一个函数 xQueueGenericSendFromISR ()。这三个函数的原型如下:

 

我们注意观察,可以看出这些函数都没有设置阻塞时间值。原因很简单,这些函数都是在

中断服务函数中调用的,并不是在任务中,所以也就没有阻塞这一说了!

在中断中调用freertos的函数,往往都需要在退出中断函数之前判断下是否需要进行任务切换。

读取消息

注意,读取时只能从头部读取。

函数xQueueReceive

读取时,需要提供一个缓冲区来存储读出来的数据。

函数xQueuePeek

portMAX_DELAY 是比较常用的形参,条件没到就一直阻塞,有条件了再执行。 

队列创建函数内部实现

我们来详细的分析一下动态创建函数xQueueGenericCreate(),静态方法大同小异,大家可以自行分析一下。函数 xQueueGenericCreate()在文件 queue.c 中有如下定义:

(1)、队列是要存储消息的,所以必须要有消息的存储区,函数的参数 uxQueueLength 和 uxItemSize 指定了队列中最大队列项目(消息)数量和每个消息的长度,两者相乘就是消息存储区的大小。

(2)、调用函数 pvPortMalloc()给队列分配内存,注意这里申请的内存大小是队列结构体和队列中消息存储区的总大小。

(3)、计算出消息存储区的首地址,(2)中申请到的内存是队列结构体和队列中消存储区的总大小,队列结构体内存在前,紧跟在后面的就是消息存储区内存。

(4)、调用函数 prvInitialiseNewQueue()初始化队列。

可以看出函数 xQueueGenericCreate()重要的工作就是给队列分配内存,当内存分配成功以 后调用函数 prvInitialiseNewQueue()来初始化队列。

队列初始化函数 prvInitialiseNewQueue()用于队列的初始化,此函数在文件 queue.c 中有定义,可自行查阅,大致过程如下:

(1)、队列结构体中的成员变量 pcHead 指向队列存储区中首地址。

(2)、初始化队列结构体中的成员变量 uxQueueLength uxItemSize,这两个成员变量保存 队列的最大队列项目和每个队列项大小。

(3)、调用函数 xQueueGenericReset()复位队列。

PS:发一句牢骚,绕来绕去的,函数调了一个又一个的。

队列初始化函数 prvInitialiseNewQueue()中调用了函数 xQueueGenericReset()来复位队列,

函数 xQueueGenericReset()代码可自行查阅,大致过程如下:

(1)、初始化队列中的相关成员变量。

(2)、根据参数 xNewQueue 确定要复位的队列是否是新创建的队列,如果不是的话还需要做其他的处理。

(3)、初始化队列中的列表 xTasksWaitingToSend xTasksWaitingToReceive

至此,队列创建成功

任务级通用入队函数内部实现

不管是后向入队 、前向入队还是覆写入队 ,最终调用的都是通用入队函数xQueueGenericSend(),这个函数在文件 queue.c 文件中由定义,可自行查阅。

大致过程如下:

(1)、要向队列发送数据,肯定要先检查一下队列是不是满的,如果是满的话肯定不能发送的。当队列未满或者是覆写入队的话就可以将消息入队了。

(2)、调用函数 prvCopyDataToQueue()将消息拷贝到队列中。前面说了入队分为后向入队、前向入队和覆写入队,他们的具体实现就是在函数 prvCopyDataToQueue()中完成的。如果选择后向入队 queueSEND_TO_BACK 的话就将消息拷贝到队列结构体成员 pcWriteTo 所指向的队列项,拷贝成功以后 pcWriteTo 增加 uxItemSize 个字节,指向下一个队列项目。当选择前向入队queueSEND_TO_FRONT 或者 queueOVERWRITE 的话就将消息拷贝到 u.pcReadFrom 所指向的队列项目,同样的需要调整 u.pcReadFrom 的位置。当向队列写入一个消息以后队列中统计当前消息数量的成员 uxMessagesWaiting 就会加一,但是选择覆写入队 queueOVERWRITE 的话还会将 uxMessagesWaiting 减一,这样一减一加相当于队列当前消息数量没有变。

(3) 、 检查是否有任务由于请求队列消息而阻 塞,阻塞的任务会挂在队列的xTasksWaitingToReceive 列表上。

(4)、有任务由于请求消息而阻塞,因为在(2)中已将向队列中发送了一条消息了,所以调用函数 xTaskRemoveFromEventList()将阻塞的任务从列表 xTasksWaitingToReceive 上移除,并且把这个任务添加到就绪列表中,如果调度器上锁的话这些任务就会挂到列表xPendingReadyList 上。如果取消阻塞的任务优先级比当前正在运行的任务优先级高还要标记需要进行任务切换。当函数xTaskRemoveFromEventList()返回值为 pdTRUE 的话就需要进行任务切换。

(5)、进行任务切换。

(6)、返回 pdPASS,标记入队成功。

(7)(2)(6)都是非常理想的效果,即消息队列未满,入队没有任何障碍。但是队列满了以后呢?首先判断设置的阻塞时间是否为 0,如果为 0 的话就说明没有阻塞时间。

(8)、由(7)得知阻塞时间为 0,那就直接返回 errQUEUE_FULL,标记队列已满就可以了。

(9)、如果阻塞时间不为 0 并且时间结构体还没有初始化的话就初始化一次超时结构体变量,调用函数 vTaskSetTimeOutState()完成超时结构体变量 xTimeOut 的初始化。其实就是记录当前的系统时钟节拍计数器的值 xTickCount 和溢出次数 xNumOfOverflows

(10)、任务调度器上锁,代码执行到这里说明当前的状况是队列已满了,而且设置了不为 0的阻塞时间。那么接下来就要对任务采取相应的措施了,比如将任务加入到队列的 xTasksWaitingToSend 列表中。

(11)、调用函数 prvLockQueue()给队列上锁,其实就是将队列中的成员变量 cRxLock cTxLock 设置为 queueLOCKED_UNMODIFIED

(12)、调用函数 xTaskCheckForTimeOut()更新超时结构体变量 xTimeOut,并且检查阻塞时间是否到了。

(13)、阻塞时间还没到,那就检查队列是否还是满的。

(14)、经过(12)(13)得出阻塞时间没到,而且队列依旧是满的,那就调用函数vTaskPlaceOnEventList()将任务添加到队列的 xTasksWaitingToSend 列表中和延时列表中,并且将任务从就绪列表中移除。注意 !如果阻塞时间是 portMAX_DELAY并且宏INCLUDE_vTaskSuspend 1 的话,函数 vTaskPlaceOnEventList()会将任务添加到列表xSuspendedTaskList 上。

(15)、操作完成,调用函数 prvUnlockQueue()解锁队列。

(16)、调用函数 xTaskResumeAll()恢复任务调度器。

(17)、阻塞时间还没到,但是队列现在有空闲的队列项,那么就在重试一次。

(18)、相比于第(12)步,阻塞时间到了!那么任务就不用添加到那些列表中了,那就解锁队列,恢复任务调度器。

(19)、返回 errQUEUE_FULL,表示队列满了。

出队函数的具体过程和入队函数类似,具体的过程就不在详细的分析了,有兴趣的,大家

自行对照着源码看一下就可以了。

使用示例

以下提供一个简单的使用示例

在按键中断中记录当前按键按下的次数,然后将这个次数发送到队列,之后,一个打印次数的任务接收改队列的数据,并将次数打印出来。

中断中的程序示例:

//定义一个消息队列用来传递按键被按下的次数
QueueHandle_t q_KeyPushCountHandler;   		//按键按下次数消息队列句柄

//按键相关数据初始化
void KeyDataInit(void)
{
    q_KeyPushCountHandler = xQueueCreate(1, sizeof(uint16_t));
}
//按键被按下的响应处理
void EXTI0_IRQHandler(void)
{
    static uint16_t keyCount = 0;
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)//判断中断是否发生
    { 
        keyCount++;
        
        if(q_KeyPushCountHandler != NULL)
        {
            xQueueSendToBackFromISR(q_KeyPushCountHandler, &keyCount, &xHigherPriorityTaskWoken);
        }
        
        /* Now the buffer is empty we can switch context if necessary. */
        if( xHigherPriorityTaskWoken )
        {
            /* Actual macro used here is port specific. */
            portYIELD_FROM_ISR (xHigherPriorityTaskWoken);
        }
        
        EXTI_ClearITPendingBit(EXTI_Line0); //清除 LINE 上的中断标志位
    }
}

打印任务中程序示例
extern QueueHandle_t q_KeyPushCountHandler; 
//打印任务1
void PrintTask1(void *p)
{
    uint16_t keyPushCount = 0;
    BaseType_t err = 0;
    
    while(1)
    {
        err = xQueueReceive(q_KeyPushCountHandler, &keyPushCount, portMAX_DELAY);
//        taskENTER_CRITICAL();           //进入临界区
//        vTaskSuspendAll();
        if(err == pdTRUE)
        {
            printf("current count is %d\r\n", keyPushCount);
        }
//         xTaskResumeAll();
//        taskEXIT_CRITICAL();            //退出临界区
    }
}

注意,这里使用的是函数xQueueReceive来接收队列消息,如果是用xQueuePeek呢?

如果是用xQueuePeek来接收消息,那么原来的消息永远都在,也就是说永远都有消息可接收,所以打印任务会一直打印,并且,因为队列一直为满,所以按键中断里写入新值就会不生效,但是不会阻塞,因此打印任务会一直打印1,中断也没法更新写入新的按下次数。也就是说,读完消息之后要将原来的数据删除,以腾出地方给其他任务或者中断写入消息。因此大部分情况下我们会使用xQueueReceive来接收消息。

当然,用xQueuePeek也可以,只不过这时候中断里的队列要用复写的方式写入。这种情况下,也只能保证队列消息能更新,但是,打印任务还是会一直打印。

注意,接收数据的变量类型应当和发送的消息类型一致。

上面是消息队列只传递一个数据的情况,还有其他的情况,那就是消息队列会存储很多连续的数据,这常见于将通信中接收到的数据存储在消息队列中,然后在任务中去处理接收到的数据,也就是起到循环缓冲区的作用。比如存储串口接收到的数据。这种情况后续再补充。 

这么一想,消息队列兼具了标志位和传递数据的功能,即数据是否满可以作为标志位,任务根据是否满的状态决定是运行任务还是阻塞任务,同时,还能传递数据。

如果是对应到裸机里,就需要两个全局变量,一个标记当前是否有数据,另一个记录需要的数据。而读取任务后删除数据就相当于清除标志位,防止任务不断重复执行。

补充

忠告:一个任务接收来着多个队列的数据,这种设计模式在FreeRTOS中是不优雅的,不高效的,是不被推荐的,应该尽量让一个任务只接收来自一个队列的数据。如果应用程序迫不得已,必须让某一个任务接收来自多个队列的消息,那么可以使用队列集合来实现这种需求。

全局变量的问题

全局变量不在栈里面,而是属于程序数据,所以需要保护,而局部变量在栈里面,每个程序都有自己的栈空间,是独立的,所以不用保护。

所以,函数是否可重入,主要考虑的还是全局变量的问题。

如果一个函数没有用到任何全局性的数据,那就是可重入的,因为不同的任务使用不同的栈空间。如果用到了,那就是不可重入的。

在没有操作系统的时候两个应用程序进行消息传递一般使用全局变量的方式,但是如果在使用操作系统的应用中用全局变量来传递消息就会涉及到“资源管理”的问题。

多任务访问全局变量会带来共享资源管理问题,消息队列最终是用的全局变量!但是消息队列对这个全局变量做了保护,重点就是资源管理的保护!假如你直接使用全局变量,那么在代码中任何任务都可以随时随地的访问、修改这个全局变量!

例如:

A任务正在使用全局变量S,A任务由于任务切换暂停运行切换到B任务,而B任务也要使用S,这时候B任务修改了S的值。当再次切换到A任务的时候这个变量S就变了,A任务可能就运行出错。

如果使用消息队列的话,A任务要使用队列S,先申请,申请成功以后才可以使用。B任务也要使用S的时候也要先申请,当时发现S已经被A任务使用了,所以B任务就没法使用(假设当前的队列长度为1),直到A任务使用完S并且释放掉B任务才申请使用!当在等待队列时,任务会阻塞,等条件满足,再切换到该任务时会从原来的地方开始往后继续执行。而用全局变量的话,就会强行执行,就有可能导致全局变量被误操作。

这里要注意,说的是任务间的数据传递。

消息队列是在任务和任务之间,任务和中断之间传递消息的,并不能完全代替全局变量,什么意思呢?

假设我定义了一个按键的外部中断,然后在外部中断里记录按键被按下的次数,并把按下的次数发送给队列,其他任务再获取这个次数并打印出来。

这里,我使用队列是为了把次数发给队列,但是有个问题,我这个次数哪里来呢?还是得定义一个全局变量来记录,然后在传递时才使用队列。

而且,还有另外一个关键的地方,那就是我们貌似只能针对队列发消息和读消息,并不能像全局变量一样对队列里的数据进行操作,比如想用队列来记录次数,好像是做不到的。

因此,队列更像是一个数据载体,而不能代替全局变量被操作。

发送时被赋值,起到左值的作用,接收时拿值,起到右值的作用。

不像全局变量,根本就没什么发送方和接收方的区别,都可以既做左值又做右值。

而且不像全局变量一样,虽然队列内的数据可以通过重新发送而改变,但是并不能直接对其进行算术操作。

我如果想要记录按键按下的次数,还是需要有个全局变量来记录次数,然后再把这个次数发送给队列。这样的话,队列里加临界保护有啥用?

虽然不能直接对队列进行算术操作,但是会有多个任务对其写入或者读出,如果使用全局变量的话,多个任务读写会出现问题,但是队列就不会,所以还是有用的。

这种模式是可行的,局部变量就不讨论了,如果我有个全局变量,现在有任务要对其进行访问,要么是读,要么是写,一种就是直接对变量进行读写,一种就是提供接口来读写,再就是使用队列来读写,注意,我们此时并没有直接操作全局变量,也就不存在多任务访问该变量的问题,各任务需要的是变量的值,变量本身并不重要,全局变量将值传递到队列中,这个变量对其他任务而言就不重要了,我们现在访问队列,就能访问数据了。也就是说,关键的是数据,而不是那个全局变量。

就等于是把普通的全局变量的值赋值给了另外的一个特殊的全局变量,这个全局变量有临界段保护,不会让多线程访问时出错。

如果队列里的数据来自全局变量,则此时的全局变量不能少。

队列更像个专业的快递公司,而数据就是包裹。

队列句柄本身就是个全局变量。

消息队列的数据必须要有个来源,可能来自寄存器、可能来自通信、可能来自局部变量,也有可能来自全局变量,不可能无中生有。

一个全局变量只要是要传递出去,不管是传递到同一个文件内,还是传递到其他的文件内,就需要使用队列,不需要传递出去,就不用放到队列里。

队列是为全局变量的传递保驾护航的,而不是代替全局变量的,二者并不冲突。

只要出现消息传递,就一般会有多任务访问。

消息队列只是传递消息,并不会对里面的数据进行修改。

并且注意,是在任务和任务,以及任务和中断之间传递时才使用,如果某个消息只在一个任务内被使用,或者只在某个中断里被使用,则直接用全局变量即可,不必使用消息队列。

任务间进行数据交互,可能是任何的数据类型,可能是单类型数据,有可能是数组,也有可能是结构体等等,要根据实际情况设置队列的队列长度和队列项大小。

暂时别纠结那么多了,总之,只要是需要进行任务间或者任务与中断之间的消息传递,就使用消息队列。

  • 10
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值