FreeRTOS专题三:任务的定义、切换与调度

在一个裸机系统中,如果有全局变量,有子函数调用,有中断发生。他们统统放在一个叫栈的地方,栈是单片机 RAM 里面一段连续的内存空间,栈的大小一般在启动文件或者链接脚本里面指定,最后由 C库函数_main 进行初始化。

但是,在多任务系统中,每个任务都是独立的,互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,但它们都存在于 RAM中。

我们来定义两个任务栈(静态):

#define TASK1_STACK_SIZE                    20
StackType_t Task1Stack[TASK1_STACK_SIZE];

#define TASK2_STACK_SIZE                    20
StackType_t Task2Stack[TASK2_STACK_SIZE];

然后定义两个任务(无限循环的函数):

void Task1_Entry( void *p_arg )
{
	for( ;; )
	{
		flag1 = 1;
		delay( 100 );		
		flag1 = 0;
		delay( 100 );
	}
}

/* 任务2 */
void Task2_Entry( void *p_arg )
{
	for( ;; )
	{
		flag2 = 1;
		delay( 100 );		
		flag2 = 0;
		delay( 100 );
	}
}

栈空间和任务函数都定义好了,怎么把这两个关联起来?在RTOS中,任务的执行由系统调度。系统为每个任务都定义了任务控制块,里面存有任务的各种信息(栈顶、任务节点、栈底和任务名称)。系统对任务的全部操作,均由任务控制块完成。

typedef struct tskTaskControlBlock
{
	volatile StackType_t    *pxTopOfStack;    /* 栈顶(栈的最大地址 = 栈底 + 栈空间 - 1) */

	ListItem_t			    xStateListItem;   /* 任务节点 */
    
    StackType_t             *pxStack;         /* 任务栈起始地址,也就是栈底(栈的最小地址) */
	                                          /* 任务名称,字符串形式 */
	char                    pcTaskName[ configMAX_TASK_NAME_LEN ];  
} tskTCB;
typedef tskTCB TCB_t;

我们来定义两个任务控制块:

TCB_t Task1TCB;
TCB_t Task2TCB;

接下来,我们怎么将任务栈,任务函数和任务控制块关联起来?这个工作由任务创建函数来完成。

TaskHandle_t xTaskCreateStatic(	TaskFunction_t pxTaskCode,           /* 任务入口(任务函数名) */
	    const char * const pcName,           /* 任务名称,字符串形式 */
		const uint32_t ulStackDepth,         /* 任务栈大小,单位为字(用于计算栈顶) */
		void * const pvParameters,           /* 任务形参(通常为NULL) */
		StackType_t * const puxStackBuffer,  /* 任务栈起始地址(定义的全局数组) */
		TCB_t * const pxTaskBuffer )         /* 任务控制块指针 */
{
	TCB_t *pxNewTCB;		// 新建一个任务控制块指针
	// 创建一个void *类型的指针,这种指针只保存地址,到使用时再可以随意的进行强制类型转换成其他类型的指针
	TaskHandle_t xReturn;			// 任务句柄

	if( ( pxTaskBuffer != NULL ) && ( puxStackBuffer != NULL ) )
	{		
		// STM32的栈,是由栈顶向下生长
		pxNewTCB = ( TCB_t * ) pxTaskBuffer; 	// 把任务控制块指针指向传入参数的任务控制块
		pxNewTCB->pxStack = ( StackType_t * ) puxStackBuffer;	// 获得任务控制块的栈底地址(栈底设置完毕)

		/* 创建新的任务 */
		prvInitialiseNewTask( pxTaskCode,        /* 任务入口 */
                              pcName,            /* 任务名称,字符串形式 */
                              ulStackDepth,      /* 任务栈大小,单位为字 */ 
                              pvParameters,      /* 任务形参 */
                              &xReturn,          /* 任务句柄 */ 
                              pxNewTCB);         /* 任务栈起始地址 */      

	}
	else
	{
		xReturn = NULL;
	}

	/* 返回任务句柄,如果任务创建成功,此时xReturn应该指向任务控制块 */
    return xReturn;
}

这个函数中,由很多地方值得注意。首先,需要我们传入任务栈起始地址(就是我们定义的任务栈数组地址),然后呢,还需要传入一个任务控制块。任务控制块结构体的第三个元素,就是任务栈指针(起始地址)。这个函数,只完成了一个功能,就是把任务栈和控制块的地址关联,接下来调用任务初始化函数(我提前猜测,它应该会完成控制块中的其他3个参数)。

static void prvInitialiseNewTask( 	TaskFunction_t pxTaskCode,              /* 任务入口 */
			const char * const pcName,              /* 任务名称,字符串形式 */
			const uint32_t ulStackDepth,            /* 任务栈大小,单位为字 */
			void * const pvParameters,              /* 任务形参 */
			TaskHandle_t * const pxCreatedTask,     /* 任务句柄 */
			TCB_t *pxNewTCB )                       /* 任务控制块指针 */

{
	StackType_t *pxTopOfStack;
	UBaseType_t x;	
	
	/* 获取栈顶地址,入栈时,程序计数器PC指针由栈顶往下生长 */
	pxTopOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );
	//pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
	/* 向下做8字节对齐 */
	pxTopOfStack = ( StackType_t * ) ( ( ( uint32_t ) pxTopOfStack ) & ( ~( ( uint32_t ) 0x0007 ) ) );	

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

		if( pcName[ x ] == 0x00 )
		{
			break;
		}
	}
	/* 任务名字的长度不能超过configMAX_TASK_NAME_LEN */
	pxNewTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';	// 名字设置完毕

    /* 初始化TCB中的xStateListItem节点 */
    vListInitialiseItem( &( pxNewTCB->xStateListItem ) );
    /* 设置xStateListItem节点的拥有者 */
	listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );	// 任务节点设置完毕
    
    
    /* 初始化任务栈,就是入栈 */		// 这是最后一项,设置栈顶地址,
//此时入栈完成(保存了任务函数),psp指针可以保存R4寄存器的值
	pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );   


	/* 让任务句柄指向任务控制块 */
	// 此时,句柄中保存的是已经初始化ok的任务控制块的地址,
//该任务控制块已经将定义的任务栈和任务控制块关联完成
    if( ( void * ) pxCreatedTask != NULL )
	{		
		*pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
	}
}

这个函数,一看就很复杂。主要分成两部分来分析。

第一部分,函数要求传入任务控制块、栈名称、栈大小。由于在上一个函数,我们已经把栈底地址传到了控制块内,所以,首先就是根据栈底地址和栈大小计算出任务栈顶地址(8字节对齐),传入到TCB中;然后,把名字也传入到TCB中。最后,控制块内还有一个列表节点,初始化该节点并把设置拥有者该任务控制块。那么,控制块的4个参数就全部赋值完毕了。

但是,似乎还少了点什么?仔细想想,截至目前,我们所做的工作是,把定义的栈空间、栈大小、字符串给到了控制块,但是,好像并没有怎么把控制块和任务函数联系起来啊,然后就是第二步,这个函数所做的工作。

这个函数,传入了栈顶地址、任务函数地址和任务形参(先忽略),我们可以想到的就是,如果我们在任务栈中保存任务函数地址(入栈),那么一旦这个栈出栈,就会去到保存的任务函数来执行,这样就实现了关联。

 /* 初始化任务栈,就是入栈 */		
// 这是最后一项,设置栈顶地址,此时入栈完成(保存了任务函数),psp指针可以保存R4寄存器的值
	pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );   

ARM_CM3权威指南中关于中断 / 异常的说明:

分析源码:

/*
调用函数pxPortInitialiseStack()后,相当于执行了一次系统节拍时钟中断:将一些重要寄存器入栈。
虽然任务还没开始执行,也并没有中断发生,但看上去就像寄存器已经被入栈了,并且部分堆栈值被修改成了我们需要的已知值。
对于不同的硬件架构,入栈的寄存器也不相同,所以我们看到这个函数是由移植层提供的。对于Cortex-M3架构,
需要依次入栈xPSR、PC、LR、R12、R3~R0、R11~R4,假设堆栈是向下生长的,初始化后的堆栈如图3-1所示。
在图3-1中我们看到寄存器xPSR被初始为0x01000000,其中bit24被置1,表示使用Thumb指令;
寄存器PC被初始化为任务函数指针vTask_A,这样当某次任务切换后,任务A获得CPU控制权,
任务函数vTask_A被出栈到PC寄存器,之后会执行任务A的代码
*/
// 这个初始化函数,就是把传入的任务函数地址,保存在PC指针内,然后把栈顶指针指向R4寄存器
// 入栈操作,把传入的任务函数保存在任务栈空间,然后把栈顶指针指向R4(最低位寄存器),便于出栈
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
    /* 异常发生时,自动加载到CPU寄存器的内容 */
	// 异常发生时,把CPU寄存器中的值保存到栈内,从栈顶依次往下递减
	pxTopOfStack--;
	// xPSR寄存器是状态字寄存器,在ARM_CM3中,位24必须为1
	*pxTopOfStack = portINITIAL_XPSR;	                                    /* xPSR的bit24必须置1 */
	pxTopOfStack--;
	// 保存PC指针的值,记录CPU当前正在执行的程序
	*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;	/* PC,即任务入口函数 */
	pxTopOfStack--;
	*pxTopOfStack = ( StackType_t ) prvTaskExitError;	                    /* LR,函数返回地址 */
	// 这一句,直接地址-5,就是跳到R0寄存器,而R12, R3, R2 R1 默认初始化为0
	pxTopOfStack -= 5;	/* R12, R3, R2 and R1 默认初始化为0 */
	*pxTopOfStack = ( StackType_t ) pvParameters;	                        /* R0,任务形参 */
    
    /* 异常发生时,手动加载到CPU寄存器的内容 */   
		// 这一步直接减8,此时栈顶指针指向了空闲堆栈(在栈前面几个字节中保存了一些状态)
	pxTopOfStack -= 8;	/* R11, R10, R9, R8, R7, R6, R5 and R4默认初始化为0 */

	/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
    return pxTopOfStack;
}

我们以图来方便理解:

一开始,栈顶指针-1,然后栈保存xPSR,然后再-1,这里就非常重要(实际上是把传入的任务函数,保存在对应的任务栈内,出栈时执行):

// 保存PC指针的值,记录CPU当前正在执行的程序
	// 这里实际上是把传入的任务函数,保存在对应的任务栈内,出栈时执行
	*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;	
/* PC,即任务入口函数 */

最后,把栈顶指针-8,指向R4(本质上,是出栈时从这个指针开始,把对应的值加载到R4...寄存器中)。

这样,整个任务的创建就完成了。我们做了很多事情,把零散定义的任务栈、任务函数、任务控制块都关联了起来。任务控制块中,保存了任务栈和任务函数的全部信息,而任务栈内,又保存了任务函数的地址(出栈时执行函数)。最后,返回一个void *类型的句柄,指向任务控制块。

任务定义好了,接下来就是把任务插入到就绪列表中,这个太简单了,就是把任务控制块中的列表项插入到列表中。

/* 将任务添加到就绪列表 */                                 
    vListInsertEnd( &( pxReadyTasksLists[1] ), &( ((TCB_t *)(&Task1TCB))->xStateListItem ) );

接下来,就是最难最难的部分,实现调度器:

先要定义一个控制块,记录当前正在执行的任务:

void vTaskStartScheduler( void )
{
    /* 手动指定第一个运行的任务 */
    pxCurrentTCB = &Task1TCB;
    
    /* 启动调度器 */
    if( xPortStartScheduler() != pdFALSE )
    {
        /* 调度器启动成功,则不会返回,即不会来到这里 */
    }
}

然后看启动调度器的函数,我们先合理猜测一下:在之前创建任务时,我们已经把任务函数保存在了任务栈内,那么调度过程,应该会有一个从当前任务栈内,把栈保存的值出栈加载到CPU内,然后执行任务函数,预计会有这么一个过程:

启动调度器的函数,起始就是启动第一个任务(出栈):

BaseType_t xPortStartScheduler( void )
{
    /* 配置PendSV 和 SysTick 的中断优先级为最低 */
	portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
	portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;

	/* 启动第一个任务,不再返回 */
	prvStartFirstTask();

	/* 不应该运行到这里 */
	return 0;
}

然后来看,启动第一个任务的函数,应该会启动一个中断,然后在中断服务函数中出栈,这样就能顺利执行一个任务函数:

/*
 * 参考资料《STM32F10xxx Cortex-M3 programming manual》4.4.3,百度搜索“PM0056”即可找到这个文档
 * 在Cortex-M中,内核外设SCB的地址范围为:0xE000ED00-0xE000ED3F
 * 0xE000ED008为SCB外设中SCB_VTOR这个寄存器的地址,里面存放的是向量表的起始地址,即MSP的地址
 */
__asm void prvStartFirstTask( void )
{
	PRESERVE8

	/* 在Cortex-M中,0xE000ED08是SCB_VTOR这个寄存器的地址,
       里面存放的是向量表的起始地址,即MSP的地址 */
	// 在中断向量表中,0地址存放的是栈顶地址(而不是栈顶地址为0),偏移0X04存放复位程序的地址,依次往下
	ldr r0, =0xE000ED08		// 把这个立即数加载到R0
	ldr r0, [r0]		// 把R0地址指向的内容(SCB_VTOR寄存器值 = 0)保存在R0,此时R0的值为0,就是0地址
	ldr r0, [r0]		// 把0地址指向的内容(栈顶指针),加载到R0

	/* 设置主堆栈指针msp的值 */
	msr msp, r0			// 把R0的值存储到主堆栈指针MSP
    
	/* 使能全局中断 */
	cpsie i			// 开中断和异常
	cpsie f
	dsb
	isb
	
    /* 调用SVC去启动第一个任务 */
	svc 0  // 开启SVC中断(向量表中偏移地址为2C,系统服务调用),接下来执行SVC中断服务函数
	nop
	nop
}

果然,MSP是主堆栈指针,然后去到中断服务函数:

这一段汇编代码,就是找到当前执行的任务控制块,然后加载栈顶指针(此时还是初始化时的状态 -8,指向R4),然后连续手动加载8个字节到CPU内部寄存器,此时栈顶指针指向了自动加载寄存器的第一个,我们把栈指针更新到psp(因为前面已经使用了msp),然后开中断,结束中断服务函数(自动从栈中加载CPU寄存器,psp也不断更新,一直到任务栈栈顶)

__asm void vPortSVCHandler( void )
{
    extern pxCurrentTCB;
    
    PRESERVE8

	// 把pxCurrentTCB控制块的地址加载到R3
	ldr	r3, =pxCurrentTCB	/* 加载pxCurrentTCB的地址到r3 */
	// 把R3指向的内容(pxCurrentTCB)加载到R1
	ldr r1, [r3]			/* 加载pxCurrentTCB到r1 */
	// 把R1指向的内容加载到R0,TCB控制块的第一个元素就是栈顶指针
	// 在任务初始化后,栈顶指针指向R4寄存器(前面自动保存8个,然后自减8)
	ldr r0, [r1]			/* 加载pxCurrentTCB指向的值到r0,目前r0的值等于第一个任务堆栈的栈顶 */
	// 类似于出栈,把栈中的内容加载到R4 ~ R11寄存器
	ldmia r0!, {r4-r11}		/* 以r0为基地址,将栈里面的内容加载到r4~r11寄存器,同时r0会递增 */
	
	msr psp, r0				/* 将r0的值,即任务的栈指针更新到psp */
	// 此时,栈指针指向第一个自动加载值寄存器(这样可以实现栈中剩下内容自动加载到CPU)
	
	isb
	mov r0, #0              /* 设置r0的值为0 */
	msr	basepri, r0         /* 设置basepri寄存器的值为0,即所有的中断都没有被屏蔽 */
	orr r14, #0xd           /* 当从SVC中断服务退出前,通过向r14寄存器最后4位按位或上0x0D,
                               使得硬件在退出时使用进程堆栈指针PSP完成出栈操作并返回后进入线程模式、返回Thumb状态 */
    
	bx r14                  /* 异常返回,这个时候栈中的剩下内容将会自动加载到CPU寄存器:
                               xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
                               同时PSP的值也将更新,即指向任务栈的栈顶 */
}

这样,就实现了一个任务的启动。

任务调度

任务调度起始就是实现在多个任务中进行切换。怎么切换?我先猜想一下,在此之前,系统已经启动了第一个任务,而每个人物都是独立的无限循环,理论上来说,它没有办法跳到另一个任务中。那我们怎么办?可以有一个办法,在第一个任务(已经启动)的结尾语句,来触发一个PendSV中断,这样,就会从任务的执行跳转到PendSV的中断服务函数,而在中断服务函数内,我们先把当前正在执行的任务函数1入栈保存(暂停运行,保存现场),然后调用任务切换函数(主要是获取下一个要执行的任务控制块),获取到下一个任务的堆栈指针,然后出栈(这样,就切换到了第二个任务)。同样的,在第二个任务末尾也应该有一个来触发一个PendSV中断,以便跳转到第一个任务。这样,就实现了任务的来回切换。

那个,我们来修改任务函数(添加PendSV中断启动语句):

/* 任务1 */
void Task1_Entry( void *p_arg )
{
	for( ;; )
	{
		flag1 = 1;
		delay( 100 );		
		flag1 = 0;
		delay( 100 );
		
		/* 任务切换,这里是手动切换 */
        taskYIELD();
	}
}

/* 任务2 */
void Task2_Entry( void *p_arg )
{
	for( ;; )
	{
		flag2 = 1;
		delay( 100 );		
		flag2 = 0;
		delay( 100 );
		
		/* 任务切换,这里是手动切换 */
        taskYIELD();
	}
}

TaskYIELD()这个函数,就是简单地触发PendSV中断(设置中断位),很简单:

#define portYIELD()																\
{																				\
	/* 触发PendSV,产生上下文切换 */								                \
	portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;								\
	__dsb( portSY_FULL_READ_WRITE );											\
	__isb( portSY_FULL_READ_WRITE );											\
}

然后,任务1运行到结尾处,就会跳转到PendSV的中断服务函数(实现任务切换):

__asm void xPortPendSVHandler( void )
{
	extern pxCurrentTCB;
	extern vTaskSwitchContext;

	PRESERVE8

    /* 当进入PendSVC Handler时,上一个任务运行的环境即:
       xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
       这些CPU寄存器的值会自动保存到任务的栈中,剩下的r4~r11需要手动保存 */
    /* 获取任务栈指针到r0 */
	mrs r0, psp
	isb
	
	// 这里,pxCurrentTCB还没有切换,因为是在当前任务函数的中断服务内,这里所做的,就是入栈
	// 之前说到,一旦触发异常,CPU就会自动的保存8个寄存器值到栈内,执行到这里时,已经保存了前8个
	ldr	r3, =pxCurrentTCB		/* 加载pxCurrentTCB的地址到r3 */
	// 这里,R2寄存器保存的是当前任务控制块
	ldr	r2, [r3]                /* 加载pxCurrentTCB到r2 */
	// 以 r0 作为基址 指针先递减,再操作,将 CPU寄存器 r4~r11的值存储到任务栈,同时更新 r0的值
	stmdb r0!, {r4-r11}			/* 将CPU寄存器r4~r11的值存储到r0指向的地址 */
	/*
		将寄存器 R0 的值写入到寄存器 R2 所保存的地址中去,也就是将新的栈顶保存在任务
		控制块的第一个字段中。此时的寄存器 R0 保存着最新的堆栈栈顶指针值,所以要将这个最新
		的栈顶指针写入到当前任务的任务控制块第一个字段,而经过(2)和(3)已经获取到了任务控制块,
		并将任务控制块的首地址写如到了寄存器 R2 中。
		将 r0的值存储到 r2指向的内容,r2等于 pxCurrentTCB。具体为将r0 的值存储到上一个任务的栈顶指针 pxTopOfStack.
	*/
	str r0, [r2]                /* 将任务栈的新的栈顶指针存储到当前任务TCB的第一个成员,即栈顶指针 */				
  // 这里,把R3和R14临时压入堆栈,为什么?因为前面有一句ldr	r3, =pxCurrentTCB		/* 加载pxCurrentTCB的地址到r3 */
	stmdb sp!, {r3, r14}        /* 将R3和R14临时压入堆栈,因为即将调用函数vTaskSwitchContext,
                                  调用函数时,返回地址自动保存到R14中,所以一旦调用发生,R14的值会被覆盖,因此需要入栈保护;
                                  R3保存的当前激活的任务TCB指针(pxCurrentTCB)地址,函数调用后会用到,因此也要入栈保护 */
	mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY    /* 进入临界段 */
	msr basepri, r0
	dsb
	isb
	// 这里是调用任务切换函数,会把新的任务控制块赋值给pxCurrentTCB,此时pxCurrentTCB的值已经被修改
	// 而R3寄存器指向了pxCurrentTCB,所以,再次加载R3寄存器时,就会读取到新的任务控制块
	bl vTaskSwitchContext       /* 调用函数vTaskSwitchContext,寻找新的任务运行,通过使变量pxCurrentTCB指向新的任务来实现任务切换 */ 
	mov r0, #0                  /* 退出临界段 */
	msr basepri, r0
	ldmia sp!, {r3, r14}        /* 恢复r3和r14 */

	/* 加载 r3 指向的内容到 r1。r3 存放的是 pxCurrentTCB 的地址,即
		让 r1 等于 pxCurrentTCB。pxCurrentTCB 在上面的 vTaskSwitchContext 函数中被更新,指
		向了下一个将要运行的任务的 TCB。*/
	ldr r1, [r3]
	// 这里的栈顶,是初始化或入栈的栈顶(也就是真正的栈顶 - 自动保存的8个,再 - 8)
	ldr r0, [r1] 				/* 当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/
	ldmia r0!, {r4-r11}			/* 出栈 */
	msr psp, r0
	isb
	// 出栈,自动恢复
	bx r14                      /* 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、
                                   使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,
                                   然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,
                                   当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
	nop
}

这个中断服务函数,主要是实现两个功能。

第一,把当前运行的控制块地址保存在R3寄存器中,然后入栈保存现场,再把R3和R14压栈。其中,R3一直指向当前运行的控制块,R14保存异常返回地址。

第二,调用任务切换函数,把当前控制块切到下一个任务。然后恢复R3,此时R3指向了新的任务,并从R3开始出栈,知道执行程序。最后,从r14记录的异常地址返回,结束中断服务程序。此时,就切换到了任务2。

仿真结果:

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值