手写RTOS

手写RTOS

芯片内核简介

Cortex-M3使用的是“向下生长的满栈的模型”,采用双堆栈机制。
在这里插入图片描述

进入异常

  1. 硬件将xPSR、PC、LR、R12和R0-R3自动压入当前堆栈,其他寄存器根据需要由ISR自行保存。
  2. 从中断向量表取入口地址。
    • SP: 入栈后保存最后的堆栈地址。
    • PC: 更新为中断服务入口地址。
    • LR: 更新为特殊的EXC_RETURN值
  3. 执行异常处理程序

退出异常

  1. 执行返回指令,如BX LR
  2. 恢复先前入栈的寄存器。出栈顺序与入栈时的相对应,堆栈指针的值也改回去。
  3. 从原中断发生位置继续往下运行。

PendSV异常

在PendSV异常中执行RTOS上下文切换(即任务切换)。
工作原理: 配置为最低优先级,上下文切换的请求将自动延迟到其他的ISR都完成后才处理,并且可被其他异常抢占。

基本任务切换实现

任务定义与切换原理

任务切换的本质:保存前一任务当前的运行状态,恢复后一任务之前的运行状态。

任务状态数据:

  • 代码、数据区:由编译器自动分配,各个任务相互独立,并不冲突。
  • 堆:不使用
  • 栈: 不同任务的栈要分隔开
  • 内核寄存器: 编译器会在某些时间将值保存到栈中,如函数调用, 异常处理。
  • 其他的状态数据

保存任务状态数据方法:
为每个任务配置独立的栈,用于保存该任务的所有状态数据。

定义堆栈类型

// Cortex-M3的堆栈单元大小为32位
typedef uint32_t tTaskStack;
 

定义任务类型

typedef struct _tTask{
		//tinyOS在运行该任务前,会从stack指向的位置处,读取栈中的环境参数恢复到cpu寄存器中,然后开始运行。
		// 在切换至其他任务时,会将当前cpu寄存器中的值保存到栈中,等待下一次运行该任务时恢复。
		// stack保存了最后保存环境参数的地址位置,用于后续恢复。
		uint32_t *stack;
}tTask;
 

声明两个任务

tTask tTask1;
tTask tTask2;

tTaskStack task1Env[1024];
tTaskStack task2Env[1024];

任务切换的实现

切换至初始任务

	taskTable[0] = &tTask1;
	taskTable[1] = &tTask2;

	nextTask = taskTable[0];
	tTaskRunFirst();
void tTaskRunFirst()
{
	// 这里设置了一个标记,PSP=0,用于与tTaskSwitck()区分,用于在PendSV中判断当前切换是TinyOS启动时切换至第一个任务,还是多任务已经跑起来后执行的切换。
	__set_PSP(0);
	MEM8(NVIC_SYSPRI2) = NVIC_PENDSV_PRI;
	MEM32(NVIC_INT_CTRL) = NVIC_PENDSVSET;
	// 这个函数没有返回值,一旦出发PendSV异常,将会在PendSV后进行任务切换,切换至第一个任务运行。
}

void tTaskSched()
{
	if(currentTask == taskTable[0])
	{
		nextTask = taskTable[1];
	}
	else
	{
		nextTask = taskTable[0];
	}
	tTaskSwitch();
}

void tTaskSwitch()
{
		MEM32(NVIC_INT_CTRL) = NVIC_PENDSVSET;
}

PendSV异常处理函数汇编实现

__asm void PendSV_Handler(void)
{
    IMPORT currentTask
    IMPORT nextTask

    MRS R0, PSP
    CBZ R0, PendSVHander_nosave
    
    STMDB R0!, {R4-R11}

    LDR R1, =currentTask
    LDR R1, [R1]
    STR R0, [R1]   //任务状态的保存

PendSVHander_nosave
    LDR R0, =currentTask
    LDR R1, =nextTask
    LDR R2, [R1]
    STR R2, [R0]

    LDR R0, [R2]
    LDMIA R0!, {R4-R11}

    MSR PSP, R0
    ORR LR, LR, #0X04
    BX LR

}

双任务时间片运行原理

上个小节中,任务的切换需要在每个任务函数中主动去调用,本节实现构建一个基于时间片去切换任务的系统。
涉及要点:

  • 定时器配置
  • 如何实现时间片切换
    在这里插入图片描述
    配置Systick
void tSetSysTickPeriod(uint32_t ms)
{
    SysTick->LOAD = ms*SystemCoreClock/1000 - 1;
    NVIC_SetPriority(SysTick_IRQn, (1<<__NVIC_PRIO_BITS)-1);
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
										SysTick_CTRL_TICKINT_Msk |
										SysTick_CTRL_ENABLE_Msk;
}

定时中断切换任务

void SysTick_Handler()
{
	
    tTaskSched();
}

双任务延时原理与空闲任务

上一节的实现中,当代码执行到delay()函数时,CPU会停留在那里去等待,非常浪费性能,本节提供一个任务延时接口,使得延时过程中,当前任务放弃CPU使用权,转而运行其他任务,提高CPU利用率。
在这里插入图片描述

每个任务采用软计时器的方式
在这里插入图片描述
为每个任务添加计时器

typedef struct _tTask{
	tTaskStask *stack;
	uint32_t delay_Ticks;
}tTask;

当所有任务都进入延时状态时,这时需要添加一个空闲任务

tTask tTaskIdle;
tTaskStask idleTaskEnv[1024];

void idleTaskEntry()
{
	for(;;)
	{
	}
}

在时钟节拍中断里递减计时器,并调用tTaskSched()切换任务

void tTaskSystemTickHandler()
{
	for(int i = 0; i < 2; i++)
	{
		if(tTaskTable[i]->delay_Ticks > 0)
			tTaskTable[i]->delay_Ticks--;
	}
	tTaskSched();
}

添加延时接口函数taskDelay()

void taskDelay(uint32_t delay)
{
	 currentTask->delayTicks = delay;
	 tTaskSched();
}

内核核心实现

临界区保护

中断嵌套时
在这里插入图片描述

中断控制寄存器功能描述
PRIMASK这是个一位寄存器,将它置1时,就关掉所有可屏蔽的异常,只剩下NMI和硬fault可以相应。他的缺省值是0,表示没有关中断。

进入临界区

uint32_t tTaskEnterCritical()
{
	uint32_t primask = __get_PRIMASK();
	__disable_irq();
	return primask;
}

退出临界区

void tTaskExitCritical(uint32_t status)
{
	__set_PRIMASK(status);
}

调度锁

  • 上锁时,禁止任务进行切换。无论何种情况。例如时间片用完,也不切换任务。
  • 解锁时,允许任务切换。

调度锁计数器初始化

void tTaskSchedInit()
{
	uint8_t schedLockCount = 0;
}

调度器上锁

void taskSchedDisable()
{
	uint32_t status = tTaskEnterCritical();
	if(schedLockCount < 255)
	{
		schedLockCount++;
	}
	tTaskExitCritical(status);
}

调度器解锁

void taskSchedEnable()
{
	uint32_t status = tTaskEnterCritical();
	if(schedLockCount > 0)
	{
		schedLockCount--;
		if(schedLockCount == 0)
		{
			tTaskSched();
		}
	}
	tTaskExitCritical(status);
}

禁止调度

void tTaskSched()
{
	uint32_t status = tTaskEnterCritical();
	if (schedLockCount > 0)
	{
		tTaskExitCritical(status);
		return ;
	}
	tTaskExitCritical(status);

}

思考,目前

  • 只有两个任务,怎样支持多任务
  • 任务占用cpu优先级相同,怎样支持多优先级

位图数据结构

位图是一组连续的标志位,每一位用来标识状态的有无
在这里插入图片描述
位图结构定义

typedef struct {
	uint32_t bitmap;
}tBitmap;

位图初始化

void tBitmapInit(tBitmap *bitmap)
{
	bitmap->bitmap = 0;
}

置1操作

void tBitmapSet(tBitmap *bitmap, uint32_t pos)
{
	bitmap->bitmap |= (1 << pos);
}

置0操作

void tBitmapSet(tBitmap *bitmap, uint32_t pos)
{
	bitmap->bitmap &= ~(1 << pos);
}

查找第一个置1的位置(从第0位开始)

uint32_t tBitmapGetFirstSet(tBitmap *bitmap)
{
	if(bitmap->bitmap & 0xff)
	{
		return quickFindTable[bitmap->bitmap &0xff];
	}
	else if(bitmap->bitmap & 0xff00)
	{
		return quickFindTable[bitmap->bitmap &0xff00] + 8;
	}
	else if(bitmap->bitmap & 0xff0000)
	{
		return quickFindTable[bitmap->bitmap &0xff0000] + 16;
	}
	else if(bitmap->bitmap & 0xff000000)
	{
		return quickFindTable[bitmap->bitmap &0xff000000] + 24;
	}
	else
	{
		return 32;
	}
}

多优先级任务

为任务指定优先级,优先级更高的任务,具备对CPU、事件和资源的优先处理权和占有权。
在这里插入图片描述

  • RTOS维护一个就绪表,每个表项对应一个任务,对应一种优先级。就绪表指明哪些优先级的任务等待占用CPU运行。
  • 为便于快速找到优先级更高的任务运行,使用了就绪位置标记就绪,快速查找

添加优先级字段

typedef struct _tTask{
	tTaskStack *stack;
	uint32_t delayTicks;
	uint32_t prio;
}tTask;

添加优先级字段位图表

tBitmap taskPrioBitmap;
tTask *taskTable[TINYOS_PRO_COUNT];

修改调度算法

void tTaskSched()
{
	tTask *tempTask;
	uint32_t status = tTaskEnterCritical();
	
	if (schedLockCount > 0)
	{
		tTaskExitCritical(status);
		return ;
	}

	tempTask = tTaskHighestReady();
	if(currentTask != tempTask)
	{
		nextTask = tempTask;
		tTaskSwitch(); 
	}
	tTaskExitCritical(status);

}

当前的实现方法中,通过一个优先级下,只能有一个任务。使用链表可以实现一个优先级下多个任务。

双向链表数据结构

在这里插入图片描述
已知父结构,访问特定节点
在这里插入图片描述

已知节点如何得到父结构体的地址?

首先在地址0处定义复制一个父结构体,节点的地址就是偏移量。
代码实现

#define tNodeParent(node, parent, name)   (parent*)((uint32_t)node - (uint32_t)&((parent*)0)->name)           )

在这里插入图片描述
定义节点

typedef struct _tNode{
	struct _tNode * pre;
	struct _tNode * next;
}tNoed;

定义链表

typedef struct _tList{
	tNode headNode;
	uint32_t nodeCount;
}tList;

链表操作

void tNodeInit(tNode *node)
{
    node->preNode = node;
    node->nextNode = node;
}

void tListInit(tList *list)
{
    list->headNode.preNode = &(list->headNode);
    list->headNode.nextNode = &(list->headNode); 
    list->nodeCount = 0;
}

uint32_t tListCount(tList *list)
{
    return list->nodeCount;
}

tNode* tListFirst(tList *list)
{
    tNode* node = (tNode *)0;
    if (list->nodeCount != 0)
    {
        return list->headNode.nextNode;
    }
    return node;
}

tNode* tListLast(tList *list)
{
    tNode* node = (tNode*)0;
    if (list->nodeCount != 0)
    {
        return list->headNode.preNode;
    }
    return node;
}

tNode* tListPre(tList* list, tNode* node)
{
    if (node->preNode == node)
    {
        return (tNode*)0;
    }
    else
    {
        return node->preNode;
    }
}

tNode* tListNext(tList* list, tNode* node)
{
    if (node->nextNode == node)
    {
        return (tNode*)0;
    }
    else
    {
        return node->nextNode;
    }   
}

void tListRemoveAll(tList* list)
{
    tNode* nextNode;
    nextNode = list->headNode.nextNode; 
    for (uint32_t i = 0; i < list->nodeCount; i++)
    {
        tNode* currentNode =nextNode;
        nextNode = nextNode->nextNode;

        currentNode->nextNode = currentNode;
        currentNode->preNode = currentNode;
    }
    list->headNode.nextNode = &(list->headNode);
    list->headNode.preNode = &(list->headNode);
    list->nodeCount = 0;
}

void tListAddFirst(tList* list, tNode* node)
{
    // node->preNode = &(list->headNode);
    // node->nextNode = list->headNode.nextNode;

    // list->headNode.nextNode->preNode = node;
    // list->headNode.nextNode = node;
    node->preNode = list->headNode.nextNode->preNode;
    node->nextNode = list->headNode.nextNode;

    list->headNode.nextNode->preNode = node;
    list->headNode.nextNode = node;

    list->nodeCount++;
}

void tListAddLast(tList* list, tNode* node)
{
    // node->nextNode = list->headNode.nextNode->preNode;
    node->preNode = &(list->headNode);
    node->preNode = list->headNode.nextNode;

    list->headNode.preNode = node;
    list->headNode.nextNode->nextNode = node;
    list->nodeCount++;
}
tNode* tListRemoveFirst(tList* list)
{
    tNode* node;

    if (list->nodeCount != 0)
    {
        node = list->headNode.nextNode;

        list->headNode.nextNode = node->nextNode;
        node->nextNode->preNode = &(list->headNode);

        list->nodeCount--;
    }
    return (tNode*)0;
}

void tListInsertAfter(tList* list, tNode* nodeAfter, tNode* nodeToInsert)
{
    nodeToInsert->preNode = nodeAfter;
    nodeToInsert->nextNode = nodeAfter->nextNode;

    nodeAfter->nextNode->preNode = nodeToInsert;
    nodeAfter->nextNode = nodeToInsert;

    list->nodeCount++;
}

void tListRemove(tList* list, tNode* node)
{
    node->preNode->nextNode = node->nextNode;
    node->nextNode->preNode = node->preNode;

    list->nodeCount--;
}

任务延时队列

RTOS维护一个就绪表,每个表项对应一个任务,对应一种优先级。就绪表指明哪些优先级的任务等待占用CPU运行。这种方式存在以下问题:

  • 每次时钟节拍中断都需要扫描所有任务,比较耗时
  • 不易支持同一优先级下多个任务
void tTaskSystemTickHandler()
{
	uint32_t status = tTaskEnterCritical();
	for(int i = 0; i < TINYOS_PRO_COUNT; i++)
	{
		if(tTaskTable[i]->delay_Ticks > 0)
			tTaskTable[i]->delay_Ticks--;
		else
			tBitmapSet(&taskProBitmap,i);
	}
	tTaskExitCritical(status);
	tTaskSched();
}

延时队列设计
将所有需要延时的任务单独放置在一个队列中,每次发生系统节拍,只需扫描该队列。
方式一:独立保存延时时间

  • 插入延时任务比较简单
    在这里插入图片描述
    方式二:递增的延时队列

  • 插入延时任务比较复杂
    在这里插入图片描述
    本系统采用方案一

延时队列的定义

tList tTaskDelayedList;

延时队列的插入

void tTimeTaskWait(tTask *task, uint32_t ticks)
{
	task->delayTicks = ticks;
	tListAddLast(&tTaskDelayedList, &(task->delayNode));
	task->state |= TINYOS_TASK_STATE_DELAYED;
}

时钟节拍扫描延时队列

void tTaskSystemTickHandler()
{
	uint32_t status = tTaskEnterCritical();
	for(node = tTaskDelayedList.headNode.next; node != &(tTaskDelayedList.headNode); node = node.next)
	{
		tTask *task =  (tTask *)tNodeParent(node, tTask, delayNode);
		if(--task->delayTicks == 0)
			tTimeTaskWakeUp(task);  //将任务从延时队列移除
			tTaskSchedRdy(task);     //将任务恢复为就绪态
	}
	tTaskExitCritical(status);
	tTaskSched();
}

同优先级时间片运行

构建一个允许多任务具备相同的优先级,且优先级相同的任务间按时间片占用CPU运行的系统。

在这里插入图片描述
在这里插入图片描述
配置优先级列表

//
tList taskTable[TINYOS_PRO_COUNT];

任务链接节点

typedef struct _tTask{
	tTaskStack *stack;
	tNode LinkNode;

	uint32_t delayTicks;
	tNode delayNode;
	uint32_t prio;
	uint32_t state;
	uint32_t slice;
}tTask;

修改获取最高优先级任务的方式

tTask *tTaskHighestReady()
{
	uint32_t highestPrio = tBitmapGetFirstSet(&taskPrioBitmap);
	tNode *node = tListFirst(&taskTable[highestPrio]);
	return (tTask *)tNodeParent(node, tTask, linkNode);
}

时钟节拍处理:增加时间片流转

void tTaskSystemTickHandler()
{
	if(--currentTask->slice == 0)
	{
		if(tListCount(&taskTable[currentTask->prio]));
		{
			tListRemoveFirst(&taskTable[currentTask->prio]);
			tListAddLast(&taskTable[currentTask->prio], &(currentTask->linkNode));
			currentTask->slice = TINYOS_SLICE_MAX;	
		}
	}
	
	tTaskExitCritical(status);
	tTaskSched();
}

任务管理模块实现

任务的挂起与唤醒

现有任务状态:

  • 未创建:只定义了任务代码,未调用tTaskInit()初始化
  • 就绪:任务已经创建完毕,且等待机会占用CPU运行
  • 运行:任务正在占用cpu运行代码
  • 延时:任务调用了tTaskDelay()
  • 挂起:任务暂停运行

添加挂起计数器

tNode delayNode;
uint32_t prio;
uint32_t state;
uint32_t slice;
uint32_t suspendCount;
}tTask

挂起函数

void tTaskSuspend(tTask *task)
{
	uint32_t status = tTaskEnterCritical();
	if(!(task->state & TINYOS_TASK_STATE_DELAYED))
	{
		if(++task->suspendCount <= 1)
		{
			task->state |= TINYOS_TASK_SUSPEND;
			tTaskSchedUnRdy(task);
			if(task == currentTask)
			{
				tTaskSched();
			}
		}
	}
	tTaskExitCritical(status);
}

唤醒函数

void tTaskWakeUp (tTask * task)
{
    // 进入临界区
    uint32_t status = tTaskEnterCritical();

     // 检查任务是否处于挂起状态
    if (task->state & TINYOS_TASK_STATE_SUSPEND)
    {
        // 递减挂起计数,如果为0了,则清除挂起标志,同时设置进入就绪状态
        if (--task->suspendCount == 0) 
        {
            // 清除挂起标志
            task->state &= ~TINYOS_TASK_STATE_SUSPEND;

            // 同时将任务放回就绪队列中
            tTaskSchedRdy(task);

            // 唤醒过程中,可能有更高优先级的任务就绪,执行一次任务调度
            tTaskSched();
        }
    }

    // 退出临界区
    tTaskExitCritical(status); 
}

任务的删除

删除工作之一:将任务从所在队列中移除
在这里插入图片描述
删除工作之二:释放/关闭占用的资源
在这里插入图片描述
在这里插入图片描述

安全删除方式之一:设置清理回调函数,在强制删除时调用
在这里插入图片描述
安全删除方式之二:设置删除请求标志,由任务自己决定何时删除
在这里插入图片描述
添加删除清理和请求删除标志位函数

//任务被删除时调用的清理函数
void (*clean) (void *param); 
//传递给清理函数的参数
void *cleanParam;
// 请求删除标志,非0表示请求删除
uint8_t requsetDeletFlag;
}tTask;

请求删除函数

void tTaskRequestDelet(tTask *task)
{
	uint32_t status = tTaskEnterCritical();
	// 设置清除删除标记
	task->requestDeletFlag = 1;
	tTaskExitCritical(status);
}

检查是否请求删除函数

uint8_t tTaskIsRequestedDelet(void)
{
	uint8_t delet;
	uint32_t status = tTaskEnterCritical();
	// 获取请求删除标记
	delet = currentTask->requsetDeletFlag;
	tTaskExitCritical(status);
	return delet;
}

强制删除函数

void tTaskForceDelete (tTask * task) 
{
    // 进入临界区
    uint32_t status = tTaskEnterCritical();

     // 如果任务处于延时状态,则从延时队列中删除
    if (task->state & TINYOS_TASK_STATE_DELAYED) 
    {
        tTimeTaskRemove(task);
    }
		// 如果任务不处于挂起状态,那么就是就绪态,从就绪表中删除
    else if (!(task->state & TINYOS_TASK_STATE_SUSPEND))
    {
        tTaskSchedRemove(task);
    }

    // 删除时,如果有设置清理函数,则调用清理函数
    if (task->clean) 
    {
        task->clean(task->cleanParam);
    }

    // 如果删除的是自己,那么需要切换至另一个任务,所以执行一次任务调度
    if (currentTask == task) 
    {
        tTaskSched();
    }

    // 退出临界区
    tTaskExitCritical(status); 
}

任务的状态查询

状态结构的定义

typedef struct _tTaskInfo{
	uint32_t delayTicks;
	uint32_t prio;
	uint32_t state;
	uint32_t slice;
	uint32_t suspendCount;
}tTaskInfo;

状态信息获取

void tTaskGetInfo(tTask *task, tTaskInfo *info)
{
	uint32_t status = tTaskEnterCritical();
	info->delayTicks = task->delayTicks;
	info->prio = task->prio;
	info->state = task->state;
	info->slice = task->slice;
	info->suspendCount = task->suspendCount;
	tTaskExitCritical(status);
}

事件控制块实现

事件控制块的原理与创建

问题一:如何同步两个任务的运行
问题二:如何处理多个任务共享资源的冲突问题
问题三:如何在多个任务间传递消息通信
问题四:如何在中断ISR与任务之间传递多个事件标志
在这里插入图片描述
在这里插入图片描述

  • 任务在事件控制块上等待,暂停运行
  • 事件发生、通知事件控制块
  • 事件控制块通知等待任务列表中的任务

事件控制块需实现两大功能:

  1. 任务进入事件控制块等待
  2. 发送事件给控制块恢复任务运行

定义事件控制块

typedef enum _tEventType{
	tEventTypeUnknow = 0,
}tEventType;

typedef struct_tEvent{
	tEventType type;
	
	tList waitList;
}tEvent;

注明任务等待的事件参数

//任务正在等待的事件类型
tEvent *waitEvent;
//等待事件的消息存储位置
void *eventMsg;
//等待事件的结果
uint32_t waitEventResult;
}tTask;

事件控制块的初始化

void tEventInit(tEvent *event, tEventType type)
{
	event->type = type;
	tListInit(&event->waitList);
}

事件的等待与通知

现有任务状态切换图
在这里插入图片描述
事件控制块的等待与通知:任务进入事件控制块等待队列中暂停运行,事件发送时通知任务从队列移除继续运行。

  • 等待事件:任务正在等待某个事件发生

事件控制块等待

void tEventWait(tEvent *event, tTask *task, void *msg, uint32_t state, uint32_t timeout)
{
	uint32_t status = tTaskEnterCritical();
	task->state |= state;
	task->waitEvent = event;
	task->eventMsg = msg;

	task->waitEventResult = tErrorNoError;

	tTaskSchedUnRdy(task);
	tListAddLast(&event->waitList, &task->linkNode);

	if(timeout)
	{
		tTimeTaskWait(task, timeout);
	}
	tTaskExitCritical(status);
}

时钟节拍处理中添加等待超时处理

void tTaskSystemTickHandler(void)
{
	tNode *node;
	uint32_t status = tTaskEnterCritical();
	for (node = tTaskDelayedList.headNode.nextNode; node != &(tTaskDelayedList.headNode); node = node->nextNode)
    {
        tTask * task = tNodeParent(node, tTask, delayNode);
        if (--task->delayTicks == 0) 
        {

			if(task->waitEvent)
			{
				tEventRemoveTask(task, (void *)0, tErrorTimeout);
			}
            // 将任务从延时队列中移除
            tTimeTaskWakeUp(task);
			
            // 将任务恢复到就绪状态
            tTaskSchedRdy(task);            
        }
    }

    // 检查下当前任务的时间片是否已经到了
    if (--currentTask->slice == 0) 
    {
        // 如果当前任务中还有其它任务的话,那么切换到下一个任务
        // 方法是将当前任务从队列的头部移除,插入到尾部
        // 这样后面执行tTaskSched()时就会从头部取出新的任务取出新的任务作为当前任务运行
        if (tListCount(&taskTable[currentTask->prio]) > 0) 
        {            
            tListRemoveFirst(&taskTable[currentTask->prio]);
            tListAddLast(&taskTable[currentTask->prio], &(currentTask->linkNode));
       
            // 重置计数器
            currentTask->slice = TINYOS_SLICE_MAX;
        }
    }
 
     // 退出临界区
    tTaskExitCritical(status); 

    // 这个过程中可能有任务延时完毕(delayTicks = 0),进行一次调度。
    tTaskSched();
}

事件控制块的清空与状态查询

计数信号量实现

计数信号量的原理与创建

计数信号量的

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值