7.FreeRTOS之队列

目录

前言

一、队列简介

1.1数据存储

1.2多任务访问

1.3出队阻塞

1.4入队阻塞

1.5队列操作过程图示

1.5.1创建队列

1.5.2向队列发送第一个消息

1.5.3向队列发送第二个消息

1.5.4从队列中读取消息

二、队列结构体

三、队列创建

3.1创建函数

3.2函数xQueueCreateStatic()

3.3函数xQueueCreate()

3.4函数xQueueGenericCreateStatic()

3.5函数xQueueGenericCreate()

四、向队列发送消息

4.1函数原型

4.2函数 xQueueSend()、xQueueSendToBack()和 xQueueSendToFront()

4.2函数 xQueueOverwrite()

4.3函数 xQueueGenericSend()

4.4函数 xQueueSendFromISR()、xQueueSendToBackFromISR()、xQueueSendToFrontFromISR()

4.5函数 xQueueOverwriteFromISR()

4.6函数 xQueueGenericSendFromISR()

4.7任务级通用入队函数

4.8中断级通用入队函数

五、队列上锁和解锁

六、从队列读取消息

6.1函数 xQueueReceive()

6.2函数 xQueuePeek()

6.3函数 xQueueGenericReceive()

6.4函数 xQueueReceiveFromISR()

6.5函数 xQueuePeekFromISR()

七、队列程序实例

7.1实例设计要求

7.2实例代码

前言

在编写项目应用时,常常会遇到一个任务和另一个任务进行“沟通交流”的情况,在没有操作系统时,全局变量可以解决这个问题,但是如果在使用操作系统的应用中用全部变量来传递信息就会涉及到“资源管理”的问题,而且全局变量不易维护,往往逻辑复杂的程序中,无法追踪全局变量被谁使用或被谁更改。 FreeRTOS对此提供一个叫做“队列”的机制。

一、队列简介

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

1.1数据存储

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

数据发送到队列中后,会被存储在队列中,这意味着队列存储的数据的原始值,而不是原数据的引用(即该数据的指针),这个传递过程也叫值传递。与UCOS不同,UCOS的消息队列采用引用传递,传递的是消息指针。但是这样做的缺点是采用引用传递的花消息就必须保持可见性,也就是消息内容必须有效,如此的话,例如函数的局部变量就存在会被随时删除的情况,但是采用引用传递的优势也很明显,可以大大减小数据传送到队列的时间。

另外值传递的优点是,在数据发送到队列后,原先存储数据的缓冲区可以被删除或者覆写,这样的话缓冲区就可以一直被重复使用。不过对于网络信息传递的情况往往需要传送大量的信息,那么势必会有很多时间用在消息队列传送的过程中,这种情况下,可以通过传递该组数据的指针地址即可,那么只要得到缓冲区的地址后就可以使用该地址为首的数据组。

1.2多任务访问

任何任务都可以向队列中发送消息,或从队列中提取消息。

1.3出队阻塞

当任务尝试从一个队列中读取消息的时候可以指定一个阻塞时间,这个阻塞时间就是当任务从队列中读取消息无效的时候任务阻塞的时间。出队就是就从队列中读取消息,出队阻塞是针对从队列中读取消息的任务而言的。比如任务A用于处理串口接收到的数据,串口接收到数据以后就会放到队列 Q 中,任务 A从队列Q 中读取数据。但是如果此时队列Q 是空的,说明还没有数据,任务A这时候来读取的话肯定是获取不到任何东西,那该怎么办呢?任务A现在有三种选择,

一:不读数据直接结束这个读取的过程;

二:等待一段时间,也就是所谓的阻塞时间,在这段时间内读取到队列的数据就结束,反之,则等待阻塞时间到了之后就从延时列表进入就绪列表;

三:设置等待时间的为最大值portMAX_DELAY,也就是如果没有读取到数据就一直进入阻塞态等待,直到接收到数据为止。

选哪一个就是由这个阻塞时间决定的,这个阻塞时间单位是时钟节拍数。

1.4入队阻塞

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

1.5队列操作过程图示

下面几幅图简单的演示了一下队列的入队和出队过程。

1.5.1创建队列

上图中任务A要向任务B发送消息,这个消息是 x 变量的值。首先创建一个队列,并且指定队列的长度和每条消息的长度。这里我们创建了一个长度为 4 的队列,因为要传递的是x 值,而 x 是个 int 类型的变量,所以每条消息的长度就是 int 类型的长度,在 STM32中就是 4字节,即每条消息是 4 个字节的。

1.5.2向队列发送第一个消息

上图中任务A 的变量x 值为10,将这个值发送到消息队列中。此时队列剩余长度就是3 了。前面说了向队列中发送消息是采用拷贝的方式,所以一旦消息发送完成变量x 就可以再次被使用,赋其他的值。

1.5.3向队列发送第二个消息

上图中任务A又向队列发送了一个消息,即新的 x 的值,这里是 20。此时队列剩余长度为 2。

1.5.4从队列中读取消息

上图中任务 B 从队列中读取消息,并将读取到的消息值赋值给 y,这样 y 就等于 10了。任务B 从队列中读取消息完成以后可以选择清除掉这个消息或者不清除。当选择清除这个消息的话其他任务或中断就不能获取这个消息了,而且队列剩余大小就会加一,变成 3。如果不清除的话其他任务或中断也可以获取这个消息,而队列剩余大小依旧是 2。

二、队列结构体

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

typedef struct QueueDefinition

{

//指向队列存储区开始地址

int8_t * pcHead;

//指向存储区中下一个空闲区域

int8_t * pcWriteTo;

union

{

QueuePointers_t xQueue;

SemaphoreData_t xSemaphore;

} u;

//等待发送任务列表,那些因为队列满导致入队失败而进入阻塞态的任务就会挂到此列表上。

List_t xTasksWaitingToSend;

//等待接收任务列表,那些因为队列空导致出队失败而进入阻塞态的任务就会挂到此列表上。

List_t xTasksWaitingToReceive;



//队列中当前队列项数量,也就是消息数

volatile UBaseType_t uxMessagesWaiting;

//创建队列时指定的队列长度,也就是队列中最大允许的队列项(消息)数量

UBaseType_t uxLength;

//创建队列时指定的每个队列项(消息)最大长度,单位:字节

UBaseType_t uxItemSize;



//当队列上锁以后用来统计从队列中接收到的队列项数量,也就是出队的队列项数量,当队列没有上锁的话此字段为 queueUNLOCKED

volatile int8_t cRxLock;

//当队列上锁以后用来统计发送到队列中的队列项数量,也就是入队的队列项数量,当队列没有上锁的话此字段为 queueUNLOCKED

volatile int8_t cTxLock;



//如果使用静态存储的话此字段设置为 pdTURE。

#if ( ( configSUPPORT_STATIC_ALLOCATION == 1 ) && ( configSUPPORT_DYNAMIC_ALLOCATION == 1 ) )

uint8_t ucStaticallyAllocated;

#endif



//队列集相关宏

#if ( configUSE_QUEUE_SETS == 1 )

struct QueueDefinition * pxQueueSetContainer;

#endif



//跟踪调试相关宏

#if ( configUSE_TRACE_FACILITY == 1 )

UBaseType_t uxQueueNumber;

uint8_t ucQueueType;

#endif

} xQUEUE;

三、队列创建

3.1创建函数

此函数也是用于创建队列的,但使静态方法创建队列的,需要的内存由用户自 行分配,此函数本质上也是一个宏行分配,此宏最终调用的是函数 xQueueGenericCreateStatic(),函数原型如下:

xQueueCreateStatic()

xQueueCreate()

这两个函数本质上都是宏,真正完成队列创建的函数是xQueueGenericCreate()和xQueueGenericCreateStatic()。

3.2函数xQueueCreateStatic()

该函数使用静态方法创建队列,队列所需的内存由用户自行分配。

QueueHandle_t xQueueCreateStatic(

                                 UBaseType_t uxQueueLength,

UBaseType_t uxItemSiz,

uint8_t* pucQueueStorageBuffer,

StaticQueue_t* pxQueueBuffer

                             )

3.3函数xQueueCreate()

此函数本质上是一个宏, 用来动态创建队列此函数本质上是一个宏, 用来动态创建队列此宏最终调用的是函数 xQueueGenericCreate(), 函数原型如下:

QueueHandle_t xQueueCreate(UBaseType_t uxQueueLength,

UBaseType_t uxItemSiz,)

3.4函数xQueueGenericCreateStatic()

QueueHandle_t xQueueGenericCreateStatic(

                                        const UBaseType_t uxQueueLength,

                                        const UBaseType_t uxItemSize,

                                        uint8_t *pucQueueStorage,

                                        StaticQueue_t *pxStaticQueue,

                                        const uint8_t ucQueueType

                                        )

    

3.5函数xQueueGenericCreate()

QueueHandle_t xQueueGenericCreate(

                                    const UBaseType_t uxQueueLength,

                                    const UBaseType_t uxItemSize,

                                    const uint8_t ucQueueType

                                )

四、向队列发送消息

4.1函数原型

创建好队列以后就可以向队列发送消息了,FreeRTOS 提供了 8 个向队列发送消息的 API 函数,如下表所示:

4.2函数 xQueueSend()、xQueueSendToBack()和 xQueueSendToFront()

这三个函数都是用于向队列中发送消息的,这三个函数本质都是宏,其中函数 xQueueSend()和 xQueueSendToBack()是一样的,都是后向入队,即将新的消息插入到队列的后面。函数xQueueSendToToFront()是前向入队,即将新消息插入到队列的前面。然而!这三个函数最后都是调用的同一个函数:xQueueGenericSend()。这三个函数只能用于任务函数中,不能用于中断服务函数,中断服务函数有专用的函数,它们以“FromISR”结尾,这三个函数的原型如下:

BaseType_t xQueueSend( QueueHandle_t xQueue,

const void * pvItemToQueue,

TickType_t xTicksToWait);

BaseType_t xQueueSendToBack(QueueHandle_t xQueue,

const void* pvItemToQueue,

TickType_t xTicksToWait);

BaseType_t xQueueSendToToFront(QueueHandle_t xQueue,

const void *pvItemToQueue,

TickType_t xTicksToWait);

参数:

xQueue: 队列句柄,指明要向哪个队列发送数据,创建队列成功以后会返回此队列的

队列句柄。

**pvItemToQueue:**指向要发送的消息,发送时候会将这个消息拷贝到队列中。

xTicksToWait: 阻塞时间,此参数指示当队列满的时候任务进入阻塞态等待队列空闲的最大时间。如果为 0 的话当队列满的时候就立即返回;当为 portMAX_DELAY 的

话就会一直等待,直到队列有空闲的队列 项,也就是死等,但是宏INCLUDE_vTaskSuspend 必须为 1。

返回值:

pdPASS: 向队列发送消息成功!

errQUEUE_FULL: 队列已经满了,消息发送失败。

4.2函数 xQueueOverwrite()

此函数也是用于向队列发送数据的,当队列满了以后会覆写掉旧的数据,不管这个旧数据有没有被其他任务或中断取走。这个函数常用于向那些长度为 1 的队列发送消息,此函数也是一个宏,最终调用的也是函数 xQueueGenericSend(),函数原型如下:

BaseType_t xQueueOverwrite(QueueHandle_t xQueue,

                         const void * pvItemToQueue);

参数:

xQueue: 队列句柄,指明要向哪个队列发送数据,创建队列成功以后会返回此队列的队列句柄。

**pvItemToQueue:**指向要发送的消息,发送的时候会将这个消息拷贝到队列中。

返回值:

pdPASS: 向队列发送消息成功,此函数也只会返回 pdPASS!因为此函数执行过程中不在乎队列满不满,满了的话我就覆写掉旧的数据,总之肯定能成功。

4.3函数 xQueueGenericSend()

此函数才是真正干活的,上面讲的所有的任务级入队函数最终都是调用的此函数,此函数也是我们后面重点要讲解的,先来看一下函数原型:

BaseType_t xQueueGenericSend( QueueHandle_t xQueue,

                             const void * const pvItemToQueue,

                             TickType_t xTicksToWait,

                             const BaseType_t xCopyPosition )

参数:

xQueue: 队列句柄,指明要向哪个队列发送数据,创建队列成功以后会返回此队列的队列句柄。

**pvItemToQueue:**指向要发送的消息,发送的过程中会将这个消息拷贝到队列中。

xTicksToWait: 阻塞时间。

xCopyPosition: 入队方式,有三种入队方式:

queueSEND_TO_BACK: 后向入队

queueSEND_TO_FRONT: 前向入队

queueOVERWRITE: 覆写入队。

上面讲解的入队 API 函数就是通过此参数来决定采用哪种入队方式的。

返回值:

pdTRUE: 向队列发送消息成功!

errQUEUE_FULL: 队列已经满了,消息发送失败

4.4函数 xQueueSendFromISR()、xQueueSendToBackFromISR()、xQueueSendToFrontFromISR()

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

BaseType_t xQueueSendFromISR(QueueHandle_t xQueue,

                             const void * pvItemToQueue,

                             BaseType_t * pxHigherPriorityTaskWoken);

                            

BaseType_t xQueueSendToBackFromISR(QueueHandle_t xQueue,

                                 const void * pvItemToQueue,

                                 BaseType_t * pxHigherPriorityTaskWoken);

                                

BaseType_t xQueueSendToFrontFromISR(QueueHandle_t xQueue,

                                     const void * pvItemToQueue,

                                     BaseType_t * pxHigherPriorityTaskWoken);

参数:

xQueue: 队列句柄,指明要向哪个队列发送数据,创建队列成功以后会返回此队列的队列句柄。

**pvItemToQueue:**指向要发送的消息,发送的时候会将这个消息拷贝到队列中。

pxHigherPriorityTaskWoken: 标记退出此函数以后是否进行任务切换,这个变量的值由这三个函数来设置的,用户不用进行设置,用户只需要提供一个变量来保存这个值就行了。当此值为 pdTRUE 的时候在退出中断服务函数之前一定要进行一次任务切换。

返回值:

pdTRUE: 向队列中发送消息成功!

errQUEUE_FULL: 队列已经满了,消息发送失败。

4.5函数 xQueueOverwriteFromISR()

此函数是 xQueueOverwrite()的中断级版本,用在中断服务函数中,在队列满的时候自动覆写掉旧的数据,此函数也是一个宏,实际调用的也是函数xQueueGenericSendFromISR(),此函数原型如下:

BaseType_t xQueueOverwriteFromISR(QueueHandle_t xQueue,

                                 const void * pvItemToQueue,

                                 BaseType_t * pxHigherPriorityTaskWoken);

此函数的参数和返回值同上面三个函数相同。

4.6函数 xQueueGenericSendFromISR()

上面说了 4 个中断级入队函数最终都是调用的函数 xQueueGenericSendFromISR(),这是真正干活的主啊,也是我们下面会详细讲解的函数,先来看一下这个函数的原型,如下:

BaseType_t xQueueGenericSendFromISR(QueueHandle_t xQueue,

                                    const void* pvItemToQueue,

                                    BaseType_t* pxHigherPriorityTaskWoken,

                                    BaseType_t xCopyPosition);

参数:

xQueue: 队列句柄,指明要向哪个队列发送数据,创建队列成功以后会返回此队列的队列句柄。

**pvItemToQueue:**指向要发送的消息,发送的过程中会将这个消息拷贝到队列中。

pxHigherPriorityTaskWoken: 标记退出此函数以后是否进行任务切换,这个变量的值由这三个函数来设置的,用户不用进行设置,用户只需要提供一个变量来保存这个值就行了。当此值为 pdTRUE 的时候在退出中断服务函数之前一定要进行一次任务切换。

xCopyPosition: 入队方式,有三种入队方式:

queueSEND_TO_BACK: 后向入队

queueSEND_TO_FRONT: 前向入队

queueOVERWRITE: 覆写入队。

返回值:

pdTRUE: 向队列发送消息成功!

errQUEUE_FULL: 队列已经满了,消息发送失败。

4.7任务级通用入队函数

不管是后向入队 、前向入队还是覆写入队 ,最终调用的都是通用入队函 数xQueueGenericSend(),这个函数在文件 queue.c 文件中由定义,缩减后的函数代码如下:

BaseType_t xQueueGenericSend( QueueHandle_t xQueue,

                             const void * const pvItemToQueue,

                             TickType_t xTicksToWait,

                             const BaseType_t xCopyPosition )

{

    BaseType_t xEntryTimeSet = pdFALSE, xYieldRequired;

    TimeOut_t xTimeOut;

    Queue_t * const pxQueue = ( Queue_t * ) xQueue;

    for( ;; )

    {

        taskENTER_CRITICAL(); //进入临界区

        {

            //查询队列现在是否还有剩余存储空间,如果采用覆写方式入队的话那就不用在

            //乎队列是不是满的啦。

            if( ( pxQueue->uxMessagesWaiting < pxQueue->uxLength ) ||\ (1)

                ( xCopyPosition == queueOVERWRITE ) )

            {

                traceQUEUE_SEND( pxQueue );

                xYieldRequired = prvCopyDataToQueue( pxQueue, pvItemToQueue,\ (2)

                 xCopyPosition );

                /**************************************************************************/

                /**************************省略掉与队列集相关代码**************************/

                /**************************************************************************/

                {

                //检查是否有任务由于等待消息而进入阻塞态

                if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) ==\(3)

                pdFALSE )

                {

                    if( xTaskRemoveFromEventList( &( pxQueue->\ (4)

                        xTasksWaitingToReceive ) ) != pdFALSE )

                    {

                        //解除阻塞态的任务优先级最高,因此要进行一次任务切换

                        queueYIELD_IF_USING_PREEMPTION(); (5)

                    }

                    else

                    {

                        mtCOVERAGE_TEST_MARKER();

                    }

                }

                else if( xYieldRequired != pdFALSE )

                {

                    queueYIELD_IF_USING_PREEMPTION();

                }

                else

                {

                    mtCOVERAGE_TEST_MARKER();

                }

            }

            taskEXIT_CRITICAL();

            return pdPASS; (6)

        }

        else

        {

            if( xTicksToWait == ( TickType_t ) 0 ) (7)

            {

                //队列是满的,并且没有设置阻塞时间的话就直接返回

                taskEXIT_CRITICAL();

                traceQUEUE_SEND_FAILED( pxQueue );

                return errQUEUE_FULL; (8)

            }

            else if( xEntryTimeSet == pdFALSE ) (9)

            {

                //队列是满的并且指定了任务阻塞时间的话就初始化时间结构体

                vTaskSetTimeOutState( &xTimeOut );

                xEntryTimeSet = pdTRUE;

            }

            else

            {

                //时间结构体已经初始化过了,

                mtCOVERAGE_TEST_MARKER();

            }

        }

    }

    taskEXIT_CRITICAL(); //退出临界区

    vTaskSuspendAll(); (10)

    prvLockQueue( pxQueue ); (11)

    //更新时间壮态,检查是否有超时产生

    if( xTaskCheckForTimeOut( &xTimeOut, &xTicksToWait ) == pdFALSE ) (12)

    {

        if( prvIsQueueFull( pxQueue ) != pdFALSE ) (13)

        {

            traceBLOCKING_ON_QUEUE_SEND( pxQueue );

            vTaskPlaceOnEventList( &( pxQueue->xTasksWaitingToSend ), \ (14)

            xTicksToWait );

            prvUnlockQueue( pxQueue ); (15)

            if( xTaskResumeAll() == pdFALSE ) (16)

            {

                portYIELD_WITHIN_API();

            }

        }

        else

        {

            //重试一次

            prvUnlockQueue( pxQueue ); (17)

            ( void ) xTaskResumeAll();

        }

    }

    else

    {

        //超时产生

        prvUnlockQueue( pxQueue ); (18)

        ( void ) xTaskResumeAll();

        traceQUEUE_SEND_FAILED( pxQueue );

        return errQUEUE_FULL; (19)

    }

    }

}

(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,表示队列满了。

4.8中断级通用入队函数

讲完任务级入队函数再来看一下中断级入队函数 xQueueGenericSendFromISR(),其他的中断级入队函数都是靠此函数来实现的。中断级入队函数和任务级入队函数大同小异,函数代码如下:

BaseType_t xQueueGenericSendFromISR( QueueHandle_t xQueue,

                                     const void * const pvItemToQueue,

                                     BaseType_t * const pxHigherPriorityTaskWoken,

                                     const BaseType_t xCopyPosition )

{

    BaseType_t xReturn;

    UBaseType_t uxSavedInterruptStatus;

    Queue_t * const pxQueue = ( Queue_t * ) xQueue;

    portASSERT_IF_INTERRUPT_PRIORITY_INVALID();

    uxSavedInterruptStatus = portSET_INTERRUPT_MASK_FROM_ISR();

    {

        if( ( pxQueue->uxMessagesWaiting < pxQueue->uxLength ) ||\ (1)

             ( xCopyPosition == queueOVERWRITE ) )

        {

            const int8_t cTxLock = pxQueue->cTxLock; (2)

            traceQUEUE_SEND_FROM_ISR( pxQueue );

            ( void ) prvCopyDataToQueue( pxQueue, pvItemToQueue, xCopyPosition ); (3)            

            {

                //队列上锁的时候就不能操作事件列表,队列解锁的时候会补上这些操作的。

                if( cTxLock == queueUNLOCKED ) (4)

                {

                    /**************************************************************************/

                    /**************************省略掉与队列集相关代码**************************/

                    /**************************************************************************/

                    if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) == \ (5)

                        pdFALSE )

                    {

                        if( xTaskRemoveFromEventList( &( pxQueue->\ (6)

                            xTasksWaitingToReceive ) ) != pdFALSE )

                        {

                            //刚刚从事件列表中移除的任务对应的任务优先级更高,所以

                            标记要进行任务切换

                            if( pxHigherPriorityTaskWoken != NULL )

                            {

                                *pxHigherPriorityTaskWoken = pdTRUE; (7)

                            }

                            else

                            {

                                mtCOVERAGE_TEST_MARKER();

                            }

                        }

                        else

                        {

                            mtCOVERAGE_TEST_MARKER();

                        }

                    }

                    else

                    {

                        mtCOVERAGE_TEST_MARKER();

                    }

                }

            }

            else

            {

                //cTxLock 加一,这样就知道在队列上锁期间向队列中发送了数据

                pxQueue->cTxLock = ( int8_t ) ( cTxLock + 1 ); (8)

            }

        xReturn = pdPASS; (9)

        }

        else

        {

            traceQUEUE_SEND_FROM_ISR_FAILED( pxQueue );

            xReturn = errQUEUE_FULL; (10)

        }

    }

    portCLEAR_INTERRUPT_MASK_FROM_ISR( uxSavedInterruptStatus );

    return xReturn;

}

(1)、队列未满或者采用的覆写的入队方式,这是最理想的壮态。

(2)、读取队列的成员变量 xTxLock,用于判断队列是否上锁。

(3)、将数据拷贝到队列中。

(4)、队列上锁了,比如任务级入队函数在操作队列中的列表的时候就会对队列上锁。

(5)、判断队列列表 xTasksWaitingToReceive 是否为空,如果不为空的话说明有任务在请求消息的时候被阻塞了。

(6)、将相应的任务从列表 xTasksWaitingToReceive 上移除。跟任务级入队函数处理过程一样。

(7)、如果刚刚从列表 xTasksWaitingToReceive 中移除的任务优先级比当前任务的优先级高,那么标记 pxHigherPriorityTaskWoken 为 pdTRUE,表示要进行任务切换。如果要进行任务切换的话就需要在退出此函数以后,退出中断服务函数之前进行一次任务切换。

(8)、如果队列上锁的话那就将队列成员变量 cTxLock 加一,表示进行了一次入队操作,在队列解锁(prvUnlockQueue())的时候会对其做相应的处理。

(9)、返回 pdPASS,表示入队完成。

(10)、如果队列满的话就直接返回 errQUEUE_FULL,表示队列满。

五、队列上锁和解锁

在上面讲解任务级通用入队函数和中断级通用入队函数的时候都提到了队列的上锁和解锁,队列的上锁和解锁是两个 API 函数:prvLockQueue()和 prvUnlockQueue()。

首先来看一下队列上锁函数 prvLockQueue(),此函数本质上就是一个宏,定义如下:

#define prvLockQueue( pxQueue ) \

taskENTER_CRITICAL(); \

{ \

    if( ( pxQueue )->cRxLock == queueUNLOCKED ) \

    { \

        ( pxQueue )->cRxLock = queueLOCKED_UNMODIFIED;\

    } \

    if( ( pxQueue )->cTxLock == queueUNLOCKED ) \

    { \

        ( pxQueue )->cTxLock = queueLOCKED_UNMODIFIED;\

    } \

} \

taskEXIT_CRITICAL()

prvLockQueue()函数很简单,就是将队列中的成员变量 cRxLock 和 cTxLock 设置为

queueLOCKED_UNMODIFIED 就行了。

在来看一下队列的解锁函数 prvUnlockQueue(),函数如下:

static void prvUnlockQueue( Queue_t * const pxQueue )

{

    //上锁计数器(cTxLock 和 cRxLock)记录了在队列上锁期间,入队或出队的数量,当队列

    //上锁以后队列项是可以加入或者移除队列的,但是相应的列表不会更新。

    taskENTER_CRITICAL();

    {

        //处理 cTxLock。

        int8_t cTxLock = pxQueue->cTxLock;

        while( cTxLock > queueLOCKED_UNMODIFIED ) (1)

        {

            /**************************************************************************/

            /**************************省略掉与队列集相关代码**************************/

            /**************************************************************************/

            {

                //将任务从事件列表中移除

                if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToReceive ) ) == \ (2)

                pdFALSE )

                {

                    if( xTaskRemoveFromEventList( &( pxQueue->\ (3)

                    xTasksWaitingToReceive ) ) != pdFALSE )

                    {

                        //从列表中移除的任务优先级比当前任务的优先级高,因此要

                        //进行任务切换。

                        vTaskMissedYield(); (4)

                    }

                    else

                    {    

                        mtCOVERAGE_TEST_MARKER();

                    }

                }

                else

                {

                    break;

                }

            }

            --cTxLock; (5)

        }

        pxQueue->cTxLock = queueUNLOCKED; (6)

    }

    taskEXIT_CRITICAL();

    //处理 cRxLock。

    taskENTER_CRITICAL();

    {

        int8_t cRxLock = pxQueue->cRxLock;

        while( cRxLock > queueLOCKED_UNMODIFIED ) (7)

        {

            if( listLIST_IS_EMPTY( &( pxQueue->xTasksWaitingToSend ) ) == pdFALSE )

            {

                if( xTaskRemoveFromEventList( &( pxQueue->xTasksWaitingToSend ) ) !=\

                pdFALSE )

                {

                    vTaskMissedYield();

                }

                else

                {

                    mtCOVERAGE_TEST_MARKER();

                }

                --cRxLock;

            }

            else

            {

                break;

            }

        }

        pxQueue->cRxLock = queueUNLOCKED;

    }

    taskEXIT_CRITICAL();

}

(1)、判断是否有中断向队列发送了消息,在前小节讲解中断级通用入队函数的时候说了,如果当队列上锁的话那么向队列发送消息成功以后会将入队计数器 cTxLock 加一。

(2)、判断列表 xTasksWaitingToReceive 是否为空,如果不为空的话就要将相应的任务从列表中移除。

(3)、将任务从列表 xTasksWaitingToReceive 中移除。

(4)、如果刚刚从列表 xTasksWaitingToReceive 中移除的任务优先级比当前任务的优先级高,那么就要标记需要进行任务切换。这里调用函数 vTaskMissedYield()来完成此任务,函数vTaskMissedYield()只是简单的将全局变量 xYieldPending 设置为 pdTRUE。那么真正的任务切换是在哪里完成的呢?在时钟节拍处理函数 xTaskIncrementTick()中,此函数会判断 xYieldPending的值,从而决定是否进行任务切换

(5)、每处理完一条就将 cTxLock 减一,直到处理完所有的。

(6)、当处理完以后标记 cTxLock 为 queueUNLOCKED,也就说 cTxLock 是没有上锁的了。

(7)、处理完 cTxLock 以后接下来就要处理 xRxLock 了,处理过程和 xTxLock 很类似

六、从队列读取消息

有入队就有出队,出队就是从队列中获取队列项(消息),FreeRTOS 中出队函数如下表所示:

6.1函数 xQueueReceive()

此函数用于在任务中从队列中读取一条(请求)消息,读取成功以后就会将队列中的这条数据删除,此函数的本质是一个宏,真正执行的函数是 xQueueGenericReceive()。此函数在读取消息的时候是采用拷贝方式的,所以用户需要提供一个数组或缓冲区来保存读取到的数据,所读取的数据长度是创建队列的时候所设定的每个队列项目的长度,函数原型如下:

BaseType_t xQueueReceive(QueueHandle_t xQueue,

                         void * pvBuffer,

                         TickType_t xTicksToWait);

参数:

xQueue: 队列句柄,指明要读取哪个队列的数据,创建队列成功以后会返回此队列的队列句柄。

pvBuffer: 保存数据的缓冲区,读取队列的过程中会将读取到的数据拷贝到这个缓冲区中。

xTicksToWait: 阻塞时间,此参数指示当队列空的时候任务进入阻塞态等待队列有数据的最大时间。如果为 0 的话当队列空的时候就立即返回;当为 portMAX_DELAY

的 话 就 会 一 直 等 待 , 直 到 队 列 有 数 据 , 也 就 是 死 等 , 但 是 宏

INCLUDE_vTaskSuspend 必须为 1。

返回值:

pdTRUE: 从队列中读取数据成功。

pdFALSE: 从队列中读取数据失败。

6.2函数 xQueuePeek()

此函数用于从队列读取一条(请求)消息,只能用在任务中!此函数在读取成功以后不会将

消息删除,此函数是一个宏,真正执行的函数是 xQueueGenericReceive()。此函数在读取消息的时候是采用拷贝方式的,所以用户需要提供一个数组或缓冲区来保存读取到的数据,所读取的数据长度是创建队列的时候所设定的每个队列项目的长度,函数原型如下:

BaseType_t xQueuePeek(QueueHandle_t xQueue,

                     void * pvBuffer,

                     TickType_t xTicksToWait);

参数:

xQueue: 队列句柄,指明要读取哪个队列的数据,创建队列成功以后会返回此队列的队列句柄。

pvBuffer: 保存数据的缓冲区,读取队列的过程中会将读取到的数据拷贝到这个缓冲区中。

xTicksToWait: 阻塞时间,此参数指示当队列空的时候任务进入阻塞态等待队列有数据的最大时间。如果为 0 的话当队列空的时候就立即返回;当为 portMAX_DELAY

的 话 就 会 一 直 等 待 , 直 到 队 列 有 数 据 , 也 就 是 死 等 , 但 是 宏INCLUDE_vTaskSuspend 必须为 1。

返回值:

pdTRUE: 从队列中读取数据成功。

pdFALSE: 从队列中读取数据失败。

6.3函数 xQueueGenericReceive()

不管是函数 xQueueReceive() 还 是 xQueuePeek() ,最终都是调用的函数xQueueGenericReceive(),此函数是真正干事的,函数原型如下:

BaseType_t xQueueGenericReceive(QueueHandle_t xQueue,

                                void* pvBuffer,

                                TickType_t xTicksToWait

                                BaseType_t xJustPeek)

参数:

xQueue: 队列句柄,指明要读取哪个队列的数据,创建队列成功以后会返回此队列的队列句柄。

pvBuffer: 保存数据的缓冲区,读取队列的过程中会将读取到的数据拷贝到这个缓冲区中。

xTicksToWait: 阻塞时间,此参数指示当队列空的时候任务进入阻塞态等待队列有数据的最大时间。如果为 0 的话当队列空的时候就立即返回;当为 portMAX_DELAY

的 话 就 会 一 直 等 待 , 直 到 队 列 有 数 据 , 也 就 是 死 等 , 但 是 宏

INCLUDE_vTaskSuspend 必须为 1。

xJustPeek: 标记当读取成功以后是否删除掉队列项,当为 pdTRUE 的时候就不用删除,也就是说你后面再调用函数 xQueueReceive()获取到的队列项是一样的。当为

pdFALSE 的时候就会删除掉这个队列项。

返回值:

pdTRUE: 从队列中读取数据成功。

pdFALSE: 从队列中读取数据失败。

6.4函数 xQueueReceiveFromISR()

此函数是 xQueueReceive()的中断版本,用于在中断服务函数中从队列中读取(请求)一条消息,读取成功以后就会将队列中的这条数据删除。此函数在读取消息的时候是采用拷贝方式的,所以需要用户提供一个数组或缓冲区来保存读取到的数据,所读取的数据长度是创建队列的时候所设定的每个队列项目的长度,函数原型如下:

BaseType_t xQueueReceiveFromISR(QueueHandle_t xQueue,

                                void* pvBuffer,

                                BaseType_t * pxTaskWoken);

参数:

xQueue: 队列句柄,指明要读取哪个队列的数据,创建队列成功以后会返回此队列的队列句柄。

pvBuffer: 保存数据的缓冲区,读取队列的过程中会将读取到的数据拷贝到这个缓冲区中。

pxTaskWoken: 标记退出此函数以后是否进行任务切换,这个变量的值是由函数来设置的,用户不用进行设置,用户只需要提供一个变量来保存这个值就行了。当此值

为 pdTRUE 的时候在退出中断服务函数之前一定要进行一次任务切换。

返回值:

pdTRUE: 从队列中读取数据成功。

pdFALSE: 从队列中读取数据失败。

6.5函数 xQueuePeekFromISR()

此函数是 xQueuePeek()的中断版本,此函数在读取成功以后不会将消息删除,此函数原型如下:

BaseType_t xQueuePeekFromISR(QueueHandle_t xQueue,

                             void * pvBuffer)

参数:

xQueue: 队列句柄,指明要读取哪个队列的数据,创建队列成功以后会返回此队列的队列句柄。

pvBuffer: 保存数据的缓冲区,读取队列的过程中会将读取到的数据拷贝到这个缓冲区中。

返回值:

pdTRUE: 从队列中读取数据成功。

pdFALSE: 从队列中读取数据失败。

七、队列程序实例

7.1实例设计要求

本实例设计三个任务: start_task、task1_task 、Keyprocess_task这三个任务的功能如下: 这三个任务的功能如下:

start_task:用来创建其他 2个任务。

task1_task :读取按键的值,然后将发送到队列 Key_Queue中,并且检查队列的剩 余容量等信息。

Keyprocess_task :按键处理任务,读取队列 Key_Queue中的消息,根据不同的消息值做相应的处理。

实例需要三个按键 KEY_UP、KEY2和 KEY0,不同的按键对应不同的按键值,任务 task1_task会将这些值发送到队列 Key_Queue中。

实例中创建了两个队列 Key_Queue和 Message_Queue,队列 Key_Queue用于传递按键值, 队列 Message_Queue用于传递串口发送过来的消息。

实例还需要两个中断,一是串口 1接收中断,一个是定时器 2中断,他们的作用如下:串口 1接收中断:接收串口发送过来的数据,并将接收的数据发送到队列 Message_Queue中。定时器 2中断:定时周期设置为500ms,在定时中断读取队列 Message_Queue中的消息,并将其显示在 LCD上。

7.2实例代码

#include "sys.h"

#include "delay.h"

#include "usart.h"

#include "led.h"

#include "timer.h"

#include "lcd.h"

#include "key.h"

#include "beep.h"

#include "string.h"

#include "malloc.h"

#include "FreeRTOS.h"

#include "task.h"

#include "queue.h"

//任务优先级

#define START_TASK_PRIO        1

//任务堆栈大小    

#define START_STK_SIZE         256

//任务句柄

TaskHandle_t StartTask_Handler;

//任务函数

void start_task(void *pvParameters);

//任务优先级

#define TASK1_TASK_PRIO        2

//任务堆栈大小    

#define TASK1_STK_SIZE         256

//任务句柄

TaskHandle_t Task1Task_Handler;

//任务函数

void task1_task(void *pvParameters);

//任务优先级

#define KEYPROCESS_TASK_PRIO 3

//任务堆栈大小    

#define KEYPROCESS_STK_SIZE 256

//任务句柄

TaskHandle_t Keyprocess_Handler;

//任务函数

void Keyprocess_task(void *pvParameters);

//按键消息队列的数量

#define KEYMSG_Q_NUM 1         //按键消息队列的数量

#define MESSAGE_Q_NUM 4     //发送数据的消息队列的数量

QueueHandle_t Key_Queue;         //按键值消息队列句柄

QueueHandle_t Message_Queue;    //信息队列句柄

//LCD刷屏时使用的颜色

int lcd_discolor[14]={    WHITE, BLACK, BLUE, BRED,

                        GRED, GBLUE, RED, MAGENTA,     

                        GREEN, CYAN, YELLOW,BROWN,             

                        BRRED, GRAY };

//用于在LCD上显示接收到的队列的消息

//str: 要显示的字符串(接收到的消息)

void disp_str(u8* str)

{

    LCD_Fill(5,230,110,245,WHITE);                    //先清除显示区域

    LCD_ShowString(5,230,100,16,16,str);

}

//加载主界面

void freertos_load_main_ui(void)

{

    POINT_COLOR = RED;

    LCD_ShowString(10,10,200,16,16,"ATK STM32F103/407");    

    LCD_ShowString(10,30,200,16,16,"FreeRTOS Examp 13-1");

    LCD_ShowString(10,50,200,16,16,"Message Queue");

    LCD_ShowString(10,70,220,16,16,"KEY_UP:LED1 KEY0:Refresh LCD");

    LCD_ShowString(10,90,200,16,16,"KEY1:SendMsg KEY2:BEEP");

    

    POINT_COLOR = BLACK;

    LCD_DrawLine(0,107,239,107);        //画线

    LCD_DrawLine(119,107,119,319);        //画线

    LCD_DrawRectangle(125,110,234,314);    //画矩形

    POINT_COLOR = RED;

    LCD_ShowString(0,130,120,16,16,"DATA_Msg Size:");

    LCD_ShowString(0,170,120,16,16,"DATA_Msg rema:");

    LCD_ShowString(0,210,100,16,16,"DATA_Msg:");

    POINT_COLOR = BLUE;

}

//查询Message_Queue队列中的总队列数量和剩余队列数量

void check_msg_queue(void)

{

    u8 *p;

    u8 msgq_remain_size;    //消息队列剩余大小

    u8 msgq_total_size; //消息队列总大小

    taskENTER_CRITICAL(); //进入临界区

    msgq_remain_size=uxQueueSpacesAvailable(Message_Queue);//得到队列剩余大小

    msgq_total_size=uxQueueMessagesWaiting(Message_Queue)+uxQueueSpacesAvailable(Message_Queue);//得到队列总大小,总大小=使用+剩余的。

    p=mymalloc(SRAMIN,20);    //申请内存

    sprintf((char*)p,"Total Size:%d",msgq_total_size);    //显示DATA_Msg消息队列总的大小

    LCD_ShowString(10,150,100,16,16,p);

    sprintf((char*)p,"Remain Size:%d",msgq_remain_size);    //显示DATA_Msg剩余大小

    LCD_ShowString(10,190,100,16,16,p);

    myfree(SRAMIN,p);        //释放内存

    taskEXIT_CRITICAL(); //退出临界区

}

int main(void)

{

    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);//设置系统中断优先级分组4

    delay_init(168);                    //初始化延时函数

    uart_init(115200);                 //初始化串口

    LED_Init();                     //初始化LED端口

    KEY_Init();                            //初始化按键

    BEEP_Init();                        //初始化蜂鸣器

    LCD_Init();                            //初始化LCD

    TIM9_Int_Init(5000,16800-1);        //初始化定时器9,周期500ms

    my_mem_init(SRAMIN);     //初始化内部内存池

    freertos_load_main_ui();     //加载主UI

    

    //创建开始任务

    xTaskCreate((TaskFunction_t )start_task, //任务函数

    (const char* )"start_task", //任务名称

    (uint16_t )START_STK_SIZE, //任务堆栈大小

    (void* )NULL, //传递给任务函数的参数

    (UBaseType_t )START_TASK_PRIO, //任务优先级

    (TaskHandle_t* )&StartTask_Handler); //任务句柄

    vTaskStartScheduler(); //开启任务调度

}

//开始任务任务函数

void start_task(void *pvParameters)

{

    taskENTER_CRITICAL(); //进入临界区

    

    //创建消息队列

    Key_Queue=xQueueCreate(KEYMSG_Q_NUM,sizeof(u8)); //创建消息Key_Queue

    Message_Queue=xQueueCreate(MESSAGE_Q_NUM,USART_REC_LEN); //创建消息Message_Queue,队列项长度是串口接收缓冲区长度

    

//创建TASK1任务

    xTaskCreate((TaskFunction_t )task1_task,

    (const char* )"task1_task",

    (uint16_t )TASK1_STK_SIZE,

    (void* )NULL,

    (UBaseType_t )TASK1_TASK_PRIO,

    (TaskHandle_t* )&Task1Task_Handler);

    //创建TASK2任务

    xTaskCreate((TaskFunction_t )Keyprocess_task,

    (const char* )"keyprocess_task",

    (uint16_t )KEYPROCESS_STK_SIZE,

    (void* )NULL,

    (UBaseType_t )KEYPROCESS_TASK_PRIO,

    (TaskHandle_t* )&Keyprocess_Handler);

    vTaskDelete(StartTask_Handler); //删除开始任务

    taskEXIT_CRITICAL(); //退出临界区

}

//task1任务函数

void task1_task(void *pvParameters)

{

    u8 key,i=0;

    BaseType_t err;

    while(1)

    {

       key=KEY_Scan(0);     //扫描按键

    if((Key_Queue!=NULL)&&(key))     //消息队列Key_Queue创建成功,并且按键被按下

    {

        err=xQueueSend(Key_Queue,&key,10);

        if(err==errQUEUE_FULL)     //发送按键值

        {

        printf("队列Key_Queue已满,数据发送失败!\r\n");

        }

    }

    i++;

    if(i%10==0) check_msg_queue();//检Message_Queue队列的容量

    if(i==50)

    {

        i=0;

        LED0=!LED0;

    }

    vTaskDelay(10); //延时10ms,也就是10个时钟节拍    

    }

}

//Keyprocess_task函数

void Keyprocess_task(void *pvParameters)

{

    u8 num,key;

    while(1)

    {

if(Key_Queue!=NULL)

{

if(xQueueReceive(Key_Queue,&key,portMAX_DELAY))//请求消息Key_Queue

{

switch(key)

{

case WKUP_PRES:        //KEY_UP控制LED1

LED1=!LED1;

break;

case KEY2_PRES:        //KEY2控制蜂鸣器

BEEP=!BEEP;

break;

case KEY0_PRES:        //KEY0刷新LCD背景

num++;

LCD_Fill(126,111,233,313,lcd_discolor[num%14]);

break;

}

}

}

        vTaskDelay(10); //延时10ms,也就是10个时钟节拍    

    }

}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小吴的嵌入式笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值