手把手教你FreeRTOS源码详解(二)——任务管理

8 篇文章 9 订阅
5 篇文章 9 订阅

FreeRTOS源码解析集合(全网最详细)
手把手教你FreeRTOS源码解析(一)——内存管理
手把手教你FreeRTOS源码详解(二)——任务管理
手把手教你FreeRTOS源码详解(三)——队列
手把手教你FreeRTOS源码详解(四)——信号量、互斥量、递归互斥量

1、任务创建

1.1 任务控制块TCB

在FreeRTOS中,每个任务都有一个属于自己的任务控制块,方便对任务进行管理,TCB结构体源码如下(已经删去了一些条件编译):

typedef struct tskTaskControlBlock
{
	volatile StackType_t	*pxTopOfStack;	/*·指向任务栈顶*/
	ListItem_t			xStateListItem;		/* 表示任务状态的链表(就绪, 阻塞, 挂起 )*/
	ListItem_t			xEventListItem;		/* 指向事件链表中的某一任务*/
	UBaseType_t			uxPriority;			/*任务的优先级,0为最低优先级 */
	StackType_t			*pxStack;			/*指向栈的起始地址 */
	char				pcTaskName[ configMAX_TASK_NAME_LEN ];/*保存任务名称*/
} tskTCB;

1.2 任务创建xTaskCreate

首先我们来看一下任务创建函数xTaskCreate的参数。

BaseType_t xTaskCreate(	TaskFunction_t pxTaskCode,
							const char * const pcName,
							const uint16_t usStackDepth,
							void * const pvParameters,
							UBaseType_t uxPriority,
							TaskHandle_t * const pxCreatedTask )

pxTaskCode:函数指针,指向要执行的函数
pcName:任务的名称
usStackDepth:任务栈大小,注意单位为字(4个字节)
pvParameters:传递给任务函数的参数
uxPriority:任务的优先级
pxCreatedTask :任务句柄,用于管理任务

在FreeRTOS中,很多项目是根据堆栈的增长方向来的配置的,这使FreeRTOS有了更好的兼容性。堆栈的增长方向有向下增长向上增长两种。
向下增长:高地址向低地址增长。
向上增长:低地址向高地址增长。

在这里插入图片描述
在任务创建之初,需要分别给TCB任务控制块和任务分配内存空间,对于堆栈增长方向的不同,给TCB任务控制块和任务分配内存空间的顺序也有所差异。
源码如下:

#if( portSTACK_GROWTH > 0 )
		{
			/* 为TCB结构体申请内存堆  */
			pxNewTCB = ( TCB_t * ) pvPortMalloc( sizeof( TCB_t ) );

			if( pxNewTCB != NULL )
			{
				/* 为任务申请内存堆 */
				pxNewTCB->pxStack = ( StackType_t * ) pvPortMalloc( ( ( ( size_t ) usStackDepth ) * sizeof( StackType_t ) ) ); /*lint !e961 MISRA exception as the casts are only redundant for some ports. */

				if( pxNewTCB->pxStack == NULL )
				{
					/* Could not allocate the stack.  Delete the allocated TCB. */
					vPortFree( pxNewTCB );
					pxNewTCB = NULL;
				}
			}
		}
		/*栈向下增长*/
		#else /* portSTACK_GROWTH */
		{
		StackType_t *pxStack;

			/* 为任务申请内存堆 */
			pxStack = ( StackType_t * ) pvPortMalloc( ( ( ( size_t ) usStackDepth ) * sizeof( StackType_t ) ) ); /*lint !e961 MISRA exception as the casts are only redundant for some ports. */

			if( pxStack != NULL )
			{
				/* 为TCB结构体申请内存堆 */
				pxNewTCB = ( TCB_t * ) pvPortMalloc( sizeof( TCB_t ) ); /*lint !e961 MISRA exception as the casts are only redundant for some paths. */

				if( pxNewTCB != NULL )
				{
					/* 用TCB结构体的成员变量来指向任务内存堆 */
					pxNewTCB->pxStack = pxStack;
				}
				else
				{
					/* 由于TCB结构体创建失败,因此任务堆不能使用,释放任务内存堆 */
					vPortFree( pxStack );
				}
			}
			else
			{
				pxNewTCB = NULL;
			}
		}

portSTACK_GROWTH>0表示堆栈向上增长,先申请TCB任务控制块的堆栈空间,再申请任务堆栈;反之,如果堆栈向下增长,先申请任务堆栈,再申请TCB任务控制块的堆栈空间。按这样的顺序申请堆栈空间,堆栈的扩展就不会覆盖掉TCB任务控制块的内容,Cortex-M采用的是向下增长
以向下增长为例,若先申请TCB任务控制块堆栈空间,再申请任务堆栈(谁先申请堆栈谁的地址更低):
在这里插入图片描述
这样任务堆栈的扩展可能会覆盖掉TCB区域,若先申请任务堆栈,再申请TCB任务控制块的堆栈空间:
在这里插入图片描述
任务控制块就没有被覆盖的危险了。堆栈向上增长也类似。

堆栈申请完成,判断任务控制块内存是否申请成功(申请失败返回NULL)

if( pxNewTCB != NULL )

如果是动态创建的任务,则进行标记,便于后面删除任务

#if( tskSTATIC_AND_DYNAMIC_ALLOCATION_POSSIBLE != 0 )
			{
				/*将任务标记为动态创建,以便于后面删除任务 */
				pxNewTCB->ucStaticallyAllocated = tskDYNAMICALLY_ALLOCATED_STACK_AND_TCB;
			}

初始化新任务以及将任务添加至就绪列表–后续讲解

prvInitialiseNewTask( pxTaskCode, pcName, ( uint32_t ) usStackDepth, pvParameters, uxPriority, pxCreatedTask, pxNewTCB, NULL );
prvAddNewTaskToReadyList( pxNewTCB );

1.3 初始化任务prvInitialiseNewTask

首先计算出任务栈顶地址(堆栈增长方向不同计算方法不同),再进行地址对齐。
若堆栈向下增长(STM32采用这种增长方式):

#if( portSTACK_GROWTH < 0 )
{
	/*计算栈顶地址*/
	pxTopOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );
	/*将地址作8字节对齐--&(~0x0007)*/
	pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) ); /*lint !e923 MISRA exception.  Avoiding casts between pointers and integers is not practical.  Size differences accounted for using portPOINTER_SIZE_TYPE type. */

	/* Check the alignment of the calculated top of stack is correct. */
	configASSERT( ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack & ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) == 0UL ) );
}

pxNewTCB->pxStack为任务块堆栈的起始地址,pxTopOfStack 指向当前任务块堆栈的栈顶。
在这里插入图片描述

若堆栈向上增长

/* 栈向上增长 */
#else /* portSTACK_GROWTH */
{
	/*指向任务栈顶*/
	pxTopOfStack = pxNewTCB->pxStack;
	/* Check the alignment of the stack buffer is correct. */
	configASSERT( ( ( ( portPOINTER_SIZE_TYPE ) pxNewTCB->pxStack & ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) == 0UL ) );

	/* 指向任务栈底 */
	pxNewTCB->pxEndOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );
}

pxTopOfStack 指向当前任务块堆栈的栈顶,pxNewTCB->pxEndOfStack指向任务块的栈底。
在这里插入图片描述
用for循环将输入的任务名赋值给TCB结构体的成员变量pcTaskName

/* 在TCB结构体中保存任务的名字 */
for( x = ( UBaseType_t ) 0; x < ( UBaseType_t ) configMAX_TASK_NAME_LEN; x++ )
{
	pxNewTCB->pcTaskName[ x ] = pcName[ x ];

	/* 自己定义的任务名字是不定长的,若定义的任务名短于configMAX_TASK_NAME_LEN,则赋值完(检测到0)就跳出该循环 */
	if( pcName[ x ] == 0x00 )
	{
		break;
	}
	else
	{
		mtCOVERAGE_TEST_MARKER();
	}
}

最后在任务名的结尾加上字符串结束标识符’\0’

pxNewTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';

检测任务优先级是否过大,若超过最高优先级,则置为最高优先级,任务的最高优先级为configMAX_PRIORITIES -1,如configMAX_PRIORITIES 为5时,优先级可取0,1,2,3,4

if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )
{
	uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U;
}
else
{
	mtCOVERAGE_TEST_MARKER();
}

将任务优先级赋值给TCB结构体

pxNewTCB->uxPriority = uxPriority;

初始化TCB结构体中的任务状态表、事件表

vListInitialiseItem( &( pxNewTCB->xStateListItem ) );
vListInitialiseItem( &( pxNewTCB->xEventListItem ) );

设置TCB控制块状态表的成员变量pvOwner,是属于pxNewTCB的

listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );

设置TCB控制块任务表成员变量xItemValue的值

listSET_LIST_ITEM_VALUE( &( pxNewTCB->xEventListItem ), ( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority );

设置TCB控制块任务表的成员变量pvOwner,是属于pxNewTCB的

listSET_LIST_ITEM_OWNER( &( pxNewTCB->xEventListItem ), pxNewTCB );

如果存在任务句柄(输入的任务句柄不为NULL),则将任务句柄指向任务控制块

	if( ( void * ) pxCreatedTask != NULL )
{
	/*	将任务句柄指向任务控制块TCB */
	*pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
}

1.4 添加新任务至就序链表prvAddNewTaskToReadyList

先进入临界区,防止中断打断就绪链表更新
taskENTER_CRITICAL为最强的临界保护,直接屏蔽了中断,使用时应尽量保证临界区较小一些
vTaskSuspendAll仅仅挂起了任务调度器,即关闭了任务调度器,防止任务之间的资源抢夺

taskENTER_CRITICAL();

当前任务数加一

uxCurrentNumberOfTasks++;

当前没有任务,或任务均被挂起,则将该任务设置为将执行的任务,并且如果是第一次创建任务,则需要初始化任务状态链表

	if( pxCurrentTCB == NULL )
	{
		/* 如果没有其它任务,或者其它任务均被挂起 - 将该任务设置为将执行的任务 */
		pxCurrentTCB = pxNewTCB;
		/*第一次创建任务*/
		if( uxCurrentNumberOfTasks == ( UBaseType_t ) 1 )
		{
			/* 第一次创建任务,初始化任务链表 */
			prvInitialiseTaskLists();
		}
		else
		{
			mtCOVERAGE_TEST_MARKER();
		}
	}

如果不是第一次创建任务,且任务调度器未执行,该任务为优先级最高的任务,则将其设置为当前任务

		if( xSchedulerRunning == pdFALSE )
		{
			if( pxCurrentTCB->uxPriority <= pxNewTCB->uxPriority )
			{
				pxCurrentTCB = pxNewTCB;
			}
			else
			{
				mtCOVERAGE_TEST_MARKER();
			}
		}

任务数加一,该任务数用作任务的编号

	uxTaskNumber++;
	#if ( configUSE_TRACE_FACILITY == 1 )
	{
		/* 初始化TCB任务控制块编号 */
		pxNewTCB->uxTCBNumber = uxTaskNumber;
	}
	#endif /* configUSE_TRACE_FACILITY */

将任务添加至就绪列表

prvAddTaskToReadyList( pxNewTCB )

prvAddTaskToReadyList( pxNewTCB )实际调用的函数是vListInsertEnd,即采用尾插法,在链表的尾部插入元素

void vListInsertEnd( List_t * const pxList, ListItem_t * const pxNewListItem )

pxList:列表项要插入的列表
pxNewListItem :要插入的列表项

若任务调度正在执行,创建的任务优先级高于正在执行的任务,则正在执行的任务“让步”于新任务

if( xSchedulerRunning != pdFALSE )
{
	/* 若创建的任务优先级高于正在执行的任务,则正在执行的任务“让步”于新任务 */
	if( pxCurrentTCB->uxPriority < pxNewTCB->uxPriority )
	{
		taskYIELD_IF_USING_PREEMPTION();
	}
	else
	{
		mtCOVERAGE_TEST_MARKER();
	}
}

1.5 初始化任务链表prvInitialiseTaskLists

在FreeRTOS中,一个任务有多种状态,每种状态对应一个链表,将任务置于不同的状态,实质上就是将任务添加至对应的状态链表。
我们首先来看一下链表初始化函数vListInitialise( List_t * const pxList )
pxIndex表示列表项的索引号,初始状态时,链表中只有xListEnd一个元素,因此pxIndex指向xListEnd

pxList->pxIndex = ( ListItem_t * ) &( pxList->xListEnd );	

在链表中,按照xItemValue升序排列的,xItemValue越小代表优先级越高
将 pxList->xListEnd.xItemValue设置为最大,保证xListEnd始终在链表的最后位置

pxList->xListEnd.xItemValue = portMAX_DELAY;

任务链表为双向链表,对其进行初始化

pxList->xListEnd.pxNext = ( ListItem_t * ) &( pxList->xListEnd );	
pxList->xListEnd.pxPrevious = ( ListItem_t * ) &( pxList->xListEnd );

uxNumberOfItems记录链表中元素的个数,刚初试化链表,元素个数为0

pxList->uxNumberOfItems = ( UBaseType_t ) 0U;

分析完链表初始化函数后,我们回到任务链表初始化函数prvInitialiseTaskLists
不同优先级的就绪任务,阻塞任务,挂起任务以及已经删除但为释放内存的任务都有对应的链表。
遍历初始化不同优先级的就绪任务链表

for( uxPriority = ( UBaseType_t ) 0U; uxPriority < ( UBaseType_t ) configMAX_PRIORITIES; uxPriority++ )
{
	vListInitialise( &( pxReadyTasksLists[ uxPriority ] ) );
}

初始化阻塞任务链表

vListInitialise( &xDelayedTaskList1 );
vListInitialise( &xDelayedTaskList2 );

初始化就绪任务过渡链表,此时调度器关闭,一些任务进入就绪状态,但是任务还未放入就绪链表中,等待任务调度器开始,进行一次新的调度

vListInitialise( &xPendingReadyList );

初始化任务删除链表

vListInitialise( &xTasksWaitingTermination );

初始化挂起任务链表

vListInitialise( &xSuspendedTaskList );

2、任务删除

同样,删除任务首先进入临界区。

taskENTER_CRITICAL();

如果输入句柄为NULL,则获取当前运行任务的句柄,否则pxTCB就为输入句柄

pxTCB = prvGetTCBFromHandle( xTaskToDelete );

prvGetTCBFromHandle实质如下:

( ( ( pxHandle ) == NULL ) ? ( TCB_t * ) pxCurrentTCB : ( TCB_t * ) ( pxHandle ) )

将任务从链表中删除,若删除后链表中的任务数为0,则清除相应就绪链表的就绪标志位

if( uxListRemove( &( pxTCB->xStateListItem ) ) == ( UBaseType_t ) 0 )
		{
			taskRESET_READY_PRIORITY( pxTCB->uxPriority );
		}

任务是否在等待某个事件,如果是,则将其放置于相应的链表

if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL )
		{
			( void ) uxListRemove( &( pxTCB->xEventListItem ) );
		}

如果删除的任务为正在运行的任务,将需要删除的任务插入xTasksWaitingTermination中,在空闲函数里面来释放内存

			/*如果删除的任务为正在运行的任务*/
		if( pxTCB == pxCurrentTCB )
		{
			/* 将需要删除的任务插入xTasksWaitingTermination中,在空闲函数里面来释放内存*/
			vListInsertEnd( &xTasksWaitingTermination, &( pxTCB->xStateListItem ) );

			/* uxDeletedTasksWaitingCleanUp为删除链表xTasksWaitingTermination中的任务数 */
			++uxDeletedTasksWaitingCleanUp;

			/* 删除任务钩子函数,用户自己实现 */
			portPRE_TASK_DELETE_HOOK( pxTCB, &xYieldPending );
		}

若删除的任务不是正在运行的任务,则直接删除

else
		{
		/*正在运行的任务数减1*/
			--uxCurrentNumberOfTasks;
		/*	删除TCB结构体,释放堆栈和任务控制块内存*/
			prvDeleteTCB( pxTCB );

			/* 重置下一个任务唤醒时间,避免下一个唤醒的任务为当前删除的任务*/
			prvResetNextTaskUnblockTime();
		}

退出临界区

taskEXIT_CRITICAL();

如果删除的是正在运行的任务那么就需要强制进行一次任务切换。

		if( xSchedulerRunning != pdFALSE )
	{
		if( pxTCB == pxCurrentTCB )
		{
			configASSERT( uxSchedulerSuspended == 0 );
			/*强制进行任务切换*/
			portYIELD_WITHIN_API();
		}
		else
		{
			mtCOVERAGE_TEST_MARKER();
		}
	}

2.1 链表项删除uxListRemove

首先获取需要删除的任务处于哪一个链表中

List_t * const pxList = ( List_t * ) pxItemToRemove->pvContainer;

将任务从链表中删除,即将任务前后两个任务连接起来。

	pxItemToRemove->pxNext->pxPrevious = pxItemToRemove->pxPrevious;
	pxItemToRemove->pxPrevious->pxNext = pxItemToRemove->pxNext;

如果pxIndex指向要被删除的任务,则将pxIndex指向要被删除任务的前一项

if( pxList->pxIndex == pxItemToRemove )
{
	pxList->pxIndex = pxItemToRemove->pxPrevious;
}

将被被删除对象的成员变量pvContainer清空

	pxItemToRemove->pvContainer = NULL;

删除后,链表中任务数减1

( pxList->uxNumberOfItems )--;

最后返回链表中的任务数目

return pxList->uxNumberOfItems;

3、延时函数vTaskDelay

延时函数首先判断延时时间是否大于0

if( xTicksToDelay > ( TickType_t ) 0U )

挂起任务调度器

vTaskSuspendAll();

挂起任务调度器实质就是将uxSchedulerSuspended加1,当uxSchedulerSuspended大于0时,即不会进行任务调度,当uxSchedulerSuspended为0时就会进行任务调度。

void vTaskSuspendAll( void )
{
	++uxSchedulerSuspended;
}

将需要延时的任务添加至延时列表

prvAddCurrentTaskToDelayedList( xTicksToDelay, pdFALSE );

重新恢复任务调度器,并用xAlreadyYielded获取其返回值,若为pdFALSE则未进行任务调度,需要在后续进行任务调度

xAlreadyYielded = xTaskResumeAll();

若未进行任务调度,在此处强制进行任务调度

	if( xAlreadyYielded == pdFALSE )
	{
		portYIELD_WITHIN_API();
	}

3.1 prvAddCurrentTaskToDelayedList

vTaskDelay函数的本质是调用prvAddCurrentTaskToDelayedList,将任务添加至对应的延时、阻塞链表,同样,首先将任务从就绪列表中移除,移除后并所移除的就绪列表中是否还有其余就绪的任务,若剩余就绪任务数为0,则清除该列表的就绪标志位

if( uxListRemove( &( pxCurrentTCB->xStateListItem ) ) == ( UBaseType_t ) 0 )
{
	/* The current task must be in a ready list, so there is no need to
	check, and the port reset macro can be called directly. */
	portRESET_READY_PRIORITY( pxCurrentTCB->uxPriority, uxTopReadyPriority );
}

这里我们只考虑允许阻塞的情况,即宏INCLUDE_vTaskSuspend=1时
如果延时时间为最大,且允许阻塞时,直接将任务添加至阻塞列表中去,xCanBlockIndefinitely 为pdTRUE即为允许阻塞

	if( ( xTicksToWait == portMAX_DELAY ) && ( xCanBlockIndefinitely != pdFALSE ) )
	{
		/* Add the task to the suspended task list instead of a delayed task
		list to ensure it is not woken by a timing event.  It will block
		indefinitely. */
		vListInsertEnd( &xSuspendedTaskList, &( pxCurrentTCB->xStateListItem ) );
	}

如果阻塞时间不为最大时,首先计算出唤醒任务的时间,xConstTickCount为执行任务prvAddCurrentTaskToDelayedList的时间,xTicksToWait为延时时间,xTimeToWake为唤醒时间

xTimeToWake = xConstTickCount + xTicksToWait;

将xTimeToWake写入任务列表的状态列表成员变量xItemValue中

listSET_LIST_ITEM_VALUE( &( pxCurrentTCB->xStateListItem ), xTimeToWake );

xTimeToWake为TickType_t类型,为uint_16型,当xTimeToWake<xConstTickCount时,即发生了溢出,则将任务添加至溢出链表pxOverflowDelayedTaskList中

if( xTimeToWake < xConstTickCount )
			{
				/* Wake time has overflowed.  Place this item in the overflow
				list. */
				vListInsert( pxOverflowDelayedTaskList, &( pxCurrentTCB->xStateListItem ) );
			}

如果没有发生溢出,则将任务添加至链表pxDelayedTaskList中

vListInsert( pxDelayedTaskList, &( pxCurrentTCB->xStateListItem ) )

全局变量xNextTaskUnblockTime保存着下一个任务的唤醒时间,如果xTimeToWake<xNextTaskUnblockTime时,即有任务需更快的被唤醒,则更新xNextTaskUnblockTime为xTimeToWake

			if( xTimeToWake < xNextTaskUnblockTime )
			{
				xNextTaskUnblockTime = xTimeToWake;
			}
  • 13
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
FreeRTOS是一个轻量级的开源实时操作系统,它适用于微处理器和微控制器,可以在多种平台上运行。FreeRTOS源码详解与应用开发涉及到了很多的知识点,需要深入了解和研究,在本文中,我将从以下几个方面来阐述FreeRTOS源码详解与应用开发。 一、FreeRTOS源码结构 FreeRTOS源码结构清晰明了,易于理解。其中,核心部分由5个文件组成,分别为tasks.c,queue.c,list.c,event_groups.c和timers.c。在这些文件中,包含了FreeRTOS的核心代码,是整个系统的基础。此外,FreeRTOS还包含了一些可选的模块,如定时器、跟踪和统计等,这些模块可以根据需要从源代码中选取。 FreeRTOS任务管理 FreeRTOS任务管理是其最主要的功能之一,任务的创建、切换、删除等都是通过任务管理来实现的。在FreeRTOS中,任务被抽象成任务控制块(Task Control Block, TCB),每个任务都有一个独一无的TCB,包含了任务的所有信息,如堆栈、状态、优先级等。FreeRTOS通过TCB来实现任务的切换和管理。 三、FreeRTOS内存管理 FreeRTOS内存管理主要涉及到堆栈和堆的管理。在FreeRTOS中,每个任务都有自己的堆栈,堆栈可以动态扩展,可以根据需要进行调整。此外,FreeRTOS还支持动态分配堆内存,可以通过API函数来分配和释放堆内存。 四、FreeRTOS时间管理 FreeRTOS时间管理主要涉及到定时器、延时和时间戳等功能。FreeRTOS支持多种类型的定时器,并支持定时器链表。延时可以通过vTaskDelay函数来实现,时间戳则可以通过xTaskGetTickCount函数来获取。 五、FreeRTOS中断管理 FreeRTOS中断管理涉及到任务的中断和系统的中断。在FreeRTOS中,任务可以设置自己的中断,也可以响应外部中断。系统的中断则是用来处理定时器中断、网络中断等。中断处理函数必须很快地完成任务,否则可能会对系统性能造成影响。 总之,FreeRTOS源码详解与应用开发是一个广泛而深入的领域,需要我们认真学习和研究。但是,掌握FreeRTOS的核心代码和功能后,我们可以轻松地在嵌入式系统中使用它,从而提高系统的可靠性和性能。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Sense_long

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

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

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

打赏作者

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

抵扣说明:

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

余额充值