【RTOS学习】任务创建 | 任务启动 | 任务切换 | 任务暂停和恢复 | 任务阻塞和唤醒 | 临界资源保护

🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!

图

前面认识了FreeRTOS中的链表和堆的管理后,接下来再看看FreeRTOS和任务相关的内容。

🌏任务创建

我们知道,在FreeRTOS中可以存在很多任务,每一个任务都有很多的属性,这些属性构成一个TCB结构体,专门用来描述一个任务。

tu
如上图所示是TCB结构体的定义,它包含的主要成员变量有:

  • volatile StackType_t * pxTopOfStack:当前任务栈的栈顶位置,该位置指向栈中最后入栈的元素。
  • ListItem_t xStateListItem:这是一个用来管理任务状态的链表项。
  • ListItem_t xEventListItem:这是一个用来管理任务事件的链表项。
  • UBaseType_t uxPriority:用来表示当前任务的优先级。
  • StackType_t * pxStack:当前任务栈的最低位置,表示栈的最大容量。
  • char pcTaskName[ configMAX_TASK_NAME_LEN ]:用来存放任务名称。

图
如上图所示,在创建任务之前,都会创建一个TaskHandle_t类型的任务句柄,该句柄本质上就是TCB*,是TCB结构体指针的类型重命名。

在调用xTaskCreate创建任务的本质就是在该函数中填充这个TCB结构体,在创建任务时,主要做这几件事:

  • xTaskCreate创建动态任务:
    • 分配TCB结构体
    • 分配栈
    • 伪造现场:构造栈的内容
    • 把TCB放入就绪链表
  • xTaskCreateStatic创建静态任务:
    • 伪造现场:构造栈的内容
    • 把TCB放入就绪链表

动态创建和静态创建相比,静态创建少了为任务分配TCB结构体和栈这两步,因为静态创建时,TCB结构体不在堆上,栈也是由用户指定的。

🧭TCB和栈

动态创建:

图
如上图代码所示,适用xTaskCreate动态创建任务时,在函数内部会调用pvPortMalloc函数在堆区上申请存放TCB结构体变量和任务所需要栈的空间。

而且还让TCB结构体中的pxStack成员指向栈空间的最低地址(这块内存的起始地址,也是栈的最大存储位置)。

静态创建:

tu

如上图代码所示,适用xTaskCreateStatic静态创建任务时,并没有从堆区上申请TCB和栈所用的空间,而是直接将调用函数时传入的TCB结构体变量pxTaskBuffer和栈puxStackBuffer赋值给pxNewTCB

🧭伪造现场

静态创建和动态创建都有伪造现场,而且在这一点上的做法是一致的:

tu
如上图代码所示,在完成TCB变量和栈的创建以后,在xTaskCreate任务创建函数中调用prvInitialiseNewTask来初始化TCB。

tu
如上图所示prvInitialiseNewTask函数,在该函数中进行TCB的初始化,主要操作有三步:

  1. 计算栈顶pxTopOfStack

由于栈是一个连续的数组,所以pxNewTCB->pxStack[ ulStackDepth - ( uint32_t ) 1 ]得到的就是栈的最大地址处,也就是栈顶。除此之外,还要对栈顶进行对齐处理。

  1. 处理优先级

如果传入的优先级大于可以设置的最大优先级configMAX_PRIORITIES,就将优先级设置为configMAX_PRIORITIES - 1。还有一些任务名字的处理等内容比较简单,本喵就不讲解了。

  1. 初始化栈

会调用pxPortInitialiseStack函数来初始化栈,也就是进行现场伪造:

tu
如上图代码所示,在函数中进行现场伪造,portINITIAL_XPSR就是寄存器xPSR的值,( StackType_t ) pxCode + portINSTRUCTION_SIZE就是任务函数的地址,给LR赋值prvTaskExitError,表示错误返回执行的函数地址,一般不会执行该函数。pvParameters是创建任务时传给任务函数的那个参数,放在R0中。

  • 无所谓什么值的寄存器,在伪造现场时压根没有处理它,只是在移动栈顶。
  • 最后返回的栈顶位置存放的是伪造的R4寄存器值。

TU
如上图所示便是伪造完现场后的结果。


🧭链表操作

图

如上图所示,在task.c中有一个static List_t pxReadyTasksLists[ configMAX_PRIORITIES ]类型数组,该数组有configMAX_PRIORITIES 个元素,也就是最大优先级是多少,就有多少个元素。

  • 每个元素的类型都是一个List_t链表头。
  • 每一个优先级对应一个就绪队列。

TU
如上图所示,前面的五个链表头List_t就是就绪队列数组中的五个元素,每个链表头都代表这一个队列,而链表头所在位置的下标就是该队列中所有任务的优先级。

以优先级为1为例,可以看到,三个TCB结构体通过它们的成员变量xStateListItem链表项,将自己链接到了该队列中。通过链表项中的pOwner可以找到链表项所属的TCB变量。

  • 链表项xStateListItem就像是一个铭牌,每个TCB都有一个铭牌,该铭牌位于就绪链表中。

tu
如上图所示,在xTaskCreate中创建任务时,还要进行链表操作,调用prvAddNewTaskToReadyList将创建的新TCB链入到对应的就绪链表中。

图
如上图代码所示,在插入链表之前,要设置一些链表项中的值:

  1. 初始化当前TCB中的链表项
  2. 设置链表项中的pOwner,使得通过链表项可以找到当前TCB。
  3. 设置链表项中的Value,当进行排序时会用到该值。

tu
如上图代码所示,设置好链表项中的值以后,调用prvAddTaskToReadyList将包含链表项的TCB插入到就绪队链表中,在该函数中再调用listINSERT_END将该TCB插入到对应数组下标链表头所维护的链表中。

图
如上图所示listINSERT_END宏函数,在该函数中进行尾插,并且让链表项中的pxContainer指向链表头,将链表头中链表项个数加一。

  • 尾插的过程中要保证公平性

此时一个任务就创建好了。

🌏任务启动

任务创建好以后,需要调用vTaskStartScheduler函数来启动任务,也就是开启调度器。

🧭创建空闲任务和定时器任务

  1. 创建空闲任务

在FreeRTOS中,除了我们创建的任务外,还有一个空闲任务,该任务的优先级是最低的,当所有用户任务都不在运行时,就会运行空闲任务,该任务进行回收用户任务资源等操作。

tu
如上图所示vTaskStartScheduler,在该函数中,如果支持静态创建就创建一个静态的空闲任务,如果不支持就动态创建一个空闲任务。

  1. 创建定时器任务

tu
如上图所示,如果配置了使用定时器的话,在启动调度器的时候还会创建一个定时器任务。

图

如上图所示,此时优先级为0的就绪链表中就会有一个空闲任务Idle_Task

🧭启动调度器

  1. 设置PendSVSysTick中断为最低优先级

tu
如上图,在创建好空闲任务和定时器任务后,会调用xPortStartScheduler函数来启动调度器。

tu

如上图所示,在xPortStartScheduler函数中,会设置NVIC中优先级寄存器,让PendSVSysTick中断为最低优先级。

图
如上图所示,此时中断向量表中的PendSVSysTick中断就被设置成了最低优先级,优先级相同,无法抢占

  1. 使能SysTick中断

在设置完优先级以后会调用vPortSetupTimerInterrupt函数来使能SysTick中断:

tu
如上图代码所示,在vPortSetupTimerInterrupt函数中配置SysTick时钟频率,并且使能SysTick定时器。

portNVIC_SYSTICK_LOAD_REG = ( configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL,这里设置重装载值时,为什么会减1呢?

图
如上图所示,假设要3个CLK产生一次中断,由于SysyTick是向下计数的,所以刚开始启动时,从2减到0需要2个CLK,接下来需要重新装载计数值。

装置计数值会耗费一个CLK的时间,所以当计数值重新变成2时已经消耗1个CLK,然后再计数两个CLK,一共就3个CLK了,如此往复。

对于72MHZ的CLK来说,如果想让1ms产生一次中断,装置值就需要设置成72000000/1000 - 1

  1. 触发SVC异常,启动第一个任务。

在调用完vPortSetupTimerInterrupt函数启动了SysTick定时器以后,会再调用prvStartFirstTask来启动第一个任务:

tu
如上图代码所示,vPortSetupTimerInterrupt是一个汇编函数,在里面进行了四步操作:

  1. 获取默认的栈顶

图
如上图所示,从向量表的第一项中获得默认栈的栈顶__initial_sp

  1. 将获取到的栈赋值给MSP
  2. 打开CPU中断,运行处理所有中断。
  3. 触发SVC异常。

使用汇编指令SVC 0来触发SVC异常,第一次任务运行前的的现场恢复是在SVC异常中进行的:

tu
如上图vPortSVCHandlerSVC异常处理函数,这也是一个汇编函数,在该函数中恢复创建任务时伪造的现场:

  1. 获取当前TCB中的栈顶

在FreeRTOS中存在一个pxCurrentTCB全局变量,该变量指向的是正在执行任务的TCB,在创建任务时,该变量指向最后创建的最高优先级的任务。
tu
如上图所示,在TCB结构体中,第一个成员变量就是pxTopOfStack栈顶指针,所以用汇编指令读取TCB结构体中的起始4字节得到就是该任务的栈顶。

  1. 软件恢复R4~R11
  2. 将栈顶赋值给PSP

为了任务处理更加高效,任务的栈使用PSP寄存器来维护,但是此时SP仍然使用的是MSP,因为任务还没有启动。

  1. 让CPU能处理所有中断
  2. 修改LR中的特殊值

由于这是SVC异常处理函数,所以LR寄存器中的值是一个特殊值,该值用来触发硬件恢复,这里要修改一下这个特殊值:

tu
如上图,要确保特殊值中的bit3是1,表示异常返回后是线程模式,还要确保bit2是1,表示异常返回后使用的是线程栈PSP

  1. 触发硬件恢复

此时软件恢复已经完成,使用BX R14触发硬件恢复,自此创建任务时伪造的现场全部从当前任务pxCurrentTCB的栈中恢复到CPU的寄存器里了。

而且程序跳转到了前任务pxCurrentTCB函数处开始指向。

  • 第一次启动任务是通过SVC异常启动的。

🌏任务切换

此时已经有任务在运行了,当产生SysTick中断的时候就会切换任务:

  1. SysTick中断函数

tu

如上图所示xPortSysTickHandler中断函数,在进入该函数时,禁止CPU处理优先级中断(允许使用系统调用的中断),然后软件触发PendSV中断,在PendSV中断函数中发生任务切换。

  • 由于SysTick中断和PendSV中断的优先级相同,所以在SysTick中触发PendSV中断时无法抢占执行。
  • 等允许优先级中断产生后,退出SysTick中断后接着执行PendSV中断函数。
  1. PendSV中断函数

图

如上图所示xPortPendSVHandlerPendSV中断处理函数,这是一个汇编函数,在该函数中完成现场恢复:

  1. 获取当前任务pxCurrentTCB的PSP栈顶
  2. 软件保存R4~R11寄存器中的值到当前任务pxCurrentTCB的栈中。

在保存完寄存器的值以后,将当前任务的栈顶保存到栈中存放R4的位置。

  1. pxCurrentTCB的地址和LR中的特殊值保存到MSP中。

由于这是在PendSV中断函数中,所以中断函数使用的栈必然是MSP。因为在切换恢复新任务的现场时会用到R3LR寄存器。

  1. 调用vTaskSwitchContext挑选下一个任务

tu
在该函数中再调用taskSELECT_HIGHEST_PRIORITY_TASK从所有就绪链表中挑选出优先级最高的任务。

tu
如上图所示,在taskSELECT_HIGHEST_PRIORITY_TASK函数中,遍历所有链表,从优先级最高的链表开始遍历,找到不为空的链表,然后调用listGET_OWNER_OF_NEXT_ENTRY函数获得链表中要执行的新任务。

  • 遍历链表时,并不是直接从系统允许的最高优先级开始遍历,而是从现有任务的最高优先级链表开始向低优先级链表遍历。

tu
如上图所示代码中,在一进入listGET_OWNER_OF_NEXT_ENTRY函数中,就让pxIndex迭代到下一个链表项,如果迭代后的链表项指向了链表头,那么继续迭代一次,指向第一个链表项。

最后将pxIndex指向的新TCB赋值给pxCurrentTCB

  1. 从MSP中获取pxCurrentTCBLR的值。

挑选完下一个要执行的任务后,从MSP中读取pxCurrentTCBLR,由于保存的时候,保存的是pxCurrentTCB的地址到MSP中,在挑选任务时改变的是pxCurrentTCB本身。

所以可以从MSP中读取到的pxCurrentTCB的地址找到pxCurrentTCB本身。LR这个特殊值也要从MSP中恢复出来。

  1. 软件恢复R4~R11

读取新任务pxCurrentTCB中的头4个字节,得到的是新任务的栈顶pxTopOfStack,然后从新任务的栈中进行软件恢复。

  1. 触发硬件恢复。

此时任务就完成了切换,可以看到,任务的切换并不是在SysTick中断中完成的,而是在SysTick中触发的PendSV中完成的。

而第一次启动任务时,是在vTaskStartScheduler中启动调度器时,触发SVC异常,在异常处理函数中完成第一次现场切换的。

为什么第一次启动和后面的任务切换不都在SysTick中断中完成呢?这样更方便啊,本喵之前自己模拟的任务切换就是这样完成的。

  • 发生任务切换的场景并不都是时间片轮转到了。

当任务阻塞时会主动发起任务调度把自己切换下去,如果在SysTick中断完成的话,就无法主动切换任务,而在PendSV中断中完成的话,可以直接触发PendSV中断来完成任务切换。

  • 任务切换的过程为:保护原任务的现场 -> 挑选要执行的新任务 -> 恢复新任务的现场。

🌏任务的暂停和恢复

图
如上图所示,存在一个xSuspendedTaskList链表,用来管理处于暂停任务的状态,该链表中的TCB的存放没有顺序,因为不需要排序,唤醒时是指定唤醒的。

🧭暂停

tu
如上图任务状态转换图,让一个任务变成暂停状态有三种情况:

  1. 暂停就绪链表中的任务
  2. 暂停自己
  3. 暂停阻塞链表中的任务

图
如上图所示vTaskSuspend函数,在该函数中,先调用prvGetTCBFromHandle获得要移除的任务句柄,这是一个宏。然后调用uxListRemove函数将任务的TCB从它当前的链表中移除。

tu
如上图uxListRemove函数,在该函数中,先获取要移除TCB所在的链表头pxContainer,然后从链表中移除。

如果移除的TCB是当前正在操作的链表,则让pxIndex指向被移除TCB的前一个TCB,这种情况是自己暂停自己。

之后让移除的TCB失忆,让其pxContainer = NULL,然后再将链表头中记录链表项的uxNUmberOfItems减一。

tu
如上图所示代码,继续vTaskSuspend函数讲解,如果要移除的任务处于某个事件链表中,则也要移除。将被移除的任务TCB尾插到暂停链表xSuspendedTaskList中。

图
如上图所示,如果被暂停的是正在执行的任务,也就是自己暂停自己,那么就主动发起一次调度,去执行就绪链表中的下一个任务。

🧭恢复

图

如上图vTaskResume函数所示,在函数内部,如果要唤醒的任务是处于暂停状态,那么就调用uxListRemove函数从暂停链表中移除,然后再将该任务插入到就绪链表中。

  • 插入就绪链表中的操作,和创建任务时插入的操作一样。

还要判断一下,唤醒任务的优先级如果大于等于当前正在执行的任务,那么就立刻发起一次调度,去执行这个被唤醒的任务。

🌏任务的阻塞和唤醒

tu
如上图所示,存在两个延时链表pxDelayedTaskListpxOverflowDelayedTaskList,这两个链表都是用来管理因调用vTaskDelay函数而处于阻塞状态的任务的。

🧭阻塞

ti
如上图所示,只有正在处于运行状态的任务才能进入阻塞状态,最常见的阻塞就是调用vTaskDelay延时函数:

图
如上图所示vTaskDelay函数,在该函数中,先调用prvAddCurrentTaskToDelayedList函数将要阻塞的任务插入到pxDelayedTaskList链表中,然后再调用portYIELD_WITHIN_API主动发起一次调度。

tu
如上图所示prvAddCurrentTaskToDelayedList部分函数,主要进行了三步操作:

  1. 将调用vTaskDelay的当前任务从就绪链表中移除。
  2. 如果要阻塞的任务是无限阻塞,则放入暂停链表。
  3. 如果阻塞一段时间,则计算唤醒时间xTimeToWake,并设置到链表项中。

在延时链表中,所有任务的TCB按照链表项中的唤醒时间升序排列,越接近链表头的TCB,唤醒时间就越靠前。所以只需要判断链表中第一个TCB的超时时间是否达到就可以了。

图
如上图所示后续代码,要判断一下超时时间是否溢出了,如果溢出则放入pxOverflowDelayedTaskList链表,没有超时则放入pxDelayedTaskList链表。

每产生一次SysTick中断,全局变量xTickCount都会加一,该变量代表着系统时间,但是这是一个unsigned int类型的变量。

在计算唤醒时间xTimeToWake = xConstTickCount + xTicksToWait后,如果溢出了,则得到的时间会小于当前时间,此时就不能再插入到pxDelayedTaskList链表中,而是要插入专门管理溢出的延时链表pxOverflowDelayedTaskList中。

tu
如上图,再更新一下唤醒时间xNextTaskUnblockTime,要保证该变量是最小值

🧭唤醒

唤醒时间到了又是如何做的呢?谁去判断,谁去唤醒呢?

tu
如上图xPortSysTickHandler中断函数所示,在每产生一次SysTick中断以后,就会调用一次xTaskIncrementTick函数,在这个函数中进行判断是否唤醒阻塞的任务,以及进行唤醒操作。

图
如上图xTaskIncrementTick部分代码所示,每产生一次SysTick中断就会给系统时间加一,当计数值发生溢出时,调用taskSWITCH_DELAYED_LISTS交换两个延时链表的链表头,使用原本的溢出延时链表。

tu
如上图代码所示,当系统时间xConstTickCount大于下一个任务的唤醒时间xNextTaskUnblockTime时,在一个死循环for中进行操作。

在循环中,调用listGET_OWNER_OF_HEAD_ENTRY函数获得延时链表中要被唤醒的任务:

#define listGET_OWNER_OF_HEAD_ENTRY( pxList )   ( ( &( ( pxList )->xListEnd ) )->pxNext->pvOwner )

这是一个宏函数,直接获取延时链表中第一个任务TCB即可,因为是按照唤醒时间排序的。

之所以在循环中进行,是为了将延时链表中延时相同时间的任务一起唤醒,当xConstTickCount < xItemValue时,说明此时所有要唤醒的任务都唤醒了,跳出循环。

图
如上图所示,在循环中每挑出一个要唤醒的任务时,都要将其从阻塞链表中移除,并且将其插入到就绪链表中。

放入到就绪链表中后,要判断一下唤醒的任务优先级是否大于等于正在运行的任务优先级,如果大于等于则将xSwitchRequired设置为真,当退出SysTick中断后就会发起调度,让唤醒的这个高优先级任务去运行。

  • 阻塞任务的唤醒和判断是由SysTick中断完成的。

🌏临界资源保护

保护临界资源的方法原则:谁可能跟我竞争,就防患于未然禁止谁。

  • 我是任务A,任务B可能跟我竞争,那就"先关闭调度器,再访问临界资源,最后开启调度器"
  • 我是任务A,中断函数可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"
  • 我是任务A,任务B或中断都可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"
  • 我是中断A,中断B可能跟我竞争,那就"先关闭中断,再访问临界资源,最后开中断"

所以说保护临界有关调度器关中断两种方式。

🧭关中断

tu
如上图所示,在进入临界区时,调用taskENTER_CRITICAL来关闭中断。

TU
如上图所示,关闭中断时最终会调用portDISABLE_INTERRUPTS函数来关闭中断,该函数是一个汇编函数,会禁止优先级小于ulNewBASEPRI的所有中断。

在关闭中断以后,会让uxCriticalNesting变量加加,这是一个全局变量,只要该值不为0,说说明中断处于关闭状态。

tu
关闭中断时不能关闭所有中断,只关闭优先级低于ulNewBASEPRI的中断,这些中断也被称为允许使用系统调用FromISR的中断,优先级比这些高的中断是更加紧急的中断,是不能禁止的。

  • 在关闭中断期间,SysTick中断和PendSV中断无法产生,就不会发生任务切换,就没有人来竞争。

中断关闭以后,就可以正式访问临界区了,在访问完临界区以后,要调用prvResetNextTaskUnblockTime函数来更新延时链表中的唤醒时间。

最后再调用taskEXIT_CRITICAL开启中断:

tu

如上图代码所示,在开启中断时,会调用vPortExitCritical函数,在该函数中会先对uxCriticalNesting减一,直到为0后才调用portENABLE_INTERRUPTS函数恢复中断。

  • uxCriticalNesting为0时才能恢复中断,因为禁止中断的地方可能不止一处。

🧭关闭调度器

关闭调度器使用的是vTaskSuspendAll函数:

tu
如上图,在vTaskSuspendAll函数中,仅仅是将全局变量uxSchedulerSuspended加一。秘密就在xTaskIncrementTick函数中。

tu
如上图所示,在SysTick中断函数中会调用xTaskIncrementTick函数,在该函数中,会判断uxSchedulerSuspended的值,如果该值为0,说明没有关闭调度器,就会进行后续的操作,增加系统时间,唤醒阻塞任务等等。

但是调用vTaskSuspendAll关闭了调度器以后uxSchedulerSuspended值就不是0了,就不会执行原本的操作了,而且此时的返回值xSwitchRequired = pdFALSE

当该函数返回以后,SysTick中断函数中也不会触发PendSV中断,也不会发生任务的切换。

  • uxSchedulerSuspended不为0,调度器就处于关闭状态,就不会发生任务切换。

打开调度器使用的是xTaskResumeAll函数:

tu
如上图代码所示,唤醒任务时,先对uxSchedulerSuspended全局变量进行减1,只有当这个变量为0时,才会重新打开调度器。

恢复xPendingReadyList链表中的任务到就绪链表中:
tu
如上图代码所示,在打开调度器时,先会遍历一个xPendingReadyList链表,将该链表中的所有任务都放入到就绪链表中,并且根据优先级决定是否立刻发起调度。


xPendingReadyList链表又是干什么的呢?在关闭调度器的期间,只是不能发生任务切换,但是中断还是可以产生的。

假设现在有一个空队列,原本队列的xEventList链表中有很多任务在等待,在调度器关闭期间,使用FromISR后缀的系统调用向队列中写入了数据,此时本应该会唤醒该队列中等待的任务去读取数据的。

但是此时由于调度器是关闭的,将等待数据的任务放入到就绪链表中也没有意义,因为无法立刻发起调度,所有就先将这些原本等待的任务放入到了xPendingReadyList链表中。


处理阻塞链表中的任务:

tu
如上图所示,首先更新阻塞链表中的唤醒时间,由于在关闭调度器期间,SysTick中断仍然可以发生,系统时间仍然在流逝,当阻塞链表中的最小唤醒时间和系统时间相等时也无法唤醒阻塞链表中的任务。

所以在关闭调度器期间,真正的系统时间不增加,而是增加一个假的时间xPendedCounts

tu
如上图所示,在调度器关闭期间,SysTick中断仍然会产生,xTaskIncrementTick函数也仍然会调用,但是此时在该函数中并不进行阻塞任务的唤醒操作,也不增加系统时间xTickCount,而是仅增加一个假的系统时间xPendedCounts

所以在打开调度器的时候,变量xPendedCounts表示调度器的关闭时长,所以在do循环中不断调用xTaskIncrementTick函数来模拟系统时间的增加。每循环一次xPendedCounts减一。

在模拟过程中会很快的将xPendedCounts假系统时间消耗完毕,并且会进行阻塞任务的唤醒操作。

  • 使用关闭中断的方式来保护临界资源的代价有点大,所以尽量使用闭关调度器的方式来保护临界资源。

🌏总结

分析了FreeRTOS源码中的任务创建,任务启动,任务切换,任务暂停和恢复,任务阻塞和唤醒以及临界资源的保护。要深刻体会到不同类型链表的作用,认识到不同任务的本质就是处于不同类型的链表中。

  • 53
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 27
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

一只大喵咪1201

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

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

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

打赏作者

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

抵扣说明:

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

余额充值