FreeRTOS学习笔记——创建任务

主机环境:Windows

开发环境:MDK4.7.2

FreeRTOS版本:FreeRTOS8.1.2

目标环境:STM32F030C8T6

FreeRTOS中一个很重要的结构就是TCB任务控制块了,来实现对任务的管理,TCB的结构定义在tasks.c文件中

typedef struct tskTaskControlBlock
{
	volatile StackType_t	*pxTopOfStack;	

	#if ( portUSING_MPU_WRAPPERS == 1 )
		xMPU_SETTINGS	xMPUSettings;	
	#endif

	ListItem_t			xGenericListItem;	
	ListItem_t			xEventListItem;		
	UBaseType_t			uxPriority;			
	StackType_t			*pxStack;		
	char				pcTaskName[ configMAX_TASK_NAME_LEN ];

	#if ( portSTACK_GROWTH > 0 )
		StackType_t		*pxEndOfStack;		
	#endif

	#if ( portCRITICAL_NESTING_IN_TCB == 1 )
		UBaseType_t 	uxCriticalNesting; 
	#endif

	#if ( configUSE_TRACE_FACILITY == 1 )
		UBaseType_t		uxTCBNumber;	
		UBaseType_t  	uxTaskNumber;		
	#endif

	#if ( configUSE_MUTEXES == 1 )
		UBaseType_t 	uxBasePriority;		
		UBaseType_t 	uxMutexesHeld;
	#endif

	#if ( configUSE_APPLICATION_TASK_TAG == 1 )
		TaskHookFunction_t pxTaskTag;
	#endif

	#if ( configGENERATE_RUN_TIME_STATS == 1 )
		uint32_t		ulRunTimeCounter;	
	#endif

	#if ( configUSE_NEWLIB_REENTRANT == 1 )
		/* Allocate a Newlib reent structure that is specific to this task.
		Note Newlib support has been included by popular demand, but is not
		used by the FreeRTOS maintainers themselves.  FreeRTOS is not
		responsible for resulting newlib operation.  User must be familiar with
		newlib and must provide system-wide implementations of the necessary
		stubs. Be warned that (at the time of writing) the current newlib design
		implements a system-wide malloc() that must be provided with locks. */
		struct 	_reent xNewLib_reent;
	#endif

} tskTCB;
由于FreeRTOS是用户可配置的,一些不需要的功能我们可以不添加以便节省资源,在STM32F0C8T6中是没有MPU(内存保护单元)的,因此这块代码完全不必去了解,另外一些应用程序调试、跟踪以及程序运行状态的一些参数我们也可以暂时不去管理,其中最基本的几个属性有栈顶指针pxTopOfStack、状态项xGenericListItem、事件项xEventListItem、任务优先级uxPriority、用户栈空间起始地址pxStack以及任务名称pcTaskName,其中任务长度是有限制的,configMAX_TASK_NAME_LEN有用户在FreeRTOSConfig.h文件中配置,根据MCU的栈增长方向可能需要栈底指针属性。

FreeRTOS的任务创建是以宏定义形式调用的形式如下

xTaskCreate( pvTaskCode, pcName, usStackDepth, pvParameters, uxPriority, pxCreatedTask )
实际中是调用的下面函数

xTaskGenericCreate( ( pvTaskCode ), ( pcName ), ( usStackDepth ), ( pvParameters ), ( uxPriority ), ( pxCreatedTask ), ( NULL ), ( NULL ) )
在tasks.c中还有一些需要用的变量如下

/* Other file private variables. --------------------------------*/
PRIVILEGED_DATA static volatile UBaseType_t uxCurrentNumberOfTasks 	= ( UBaseType_t ) 0U;
PRIVILEGED_DATA static volatile TickType_t xTickCount 				= ( TickType_t ) 0U;
PRIVILEGED_DATA static volatile UBaseType_t uxTopReadyPriority 		= tskIDLE_PRIORITY;
PRIVILEGED_DATA static volatile BaseType_t xSchedulerRunning 		= pdFALSE;
PRIVILEGED_DATA static volatile UBaseType_t uxPendedTicks 			= ( UBaseType_t ) 0U;
PRIVILEGED_DATA static volatile BaseType_t xYieldPending 			= pdFALSE;
PRIVILEGED_DATA static volatile BaseType_t xNumOfOverflows 			= ( BaseType_t ) 0;
PRIVILEGED_DATA static UBaseType_t uxTaskNumber 					= ( UBaseType_t ) 0U;
PRIVILEGED_DATA static volatile TickType_t xNextTaskUnblockTime		= portMAX_DELAY;
任务创建代码如下

BaseType_t xTaskGenericCreate( TaskFunction_t pxTaskCode, const char * const pcName, const uint16_t usStackDepth, 
	void * const pvParameters, UBaseType_t uxPriority, TaskHandle_t * const pxCreatedTask, StackType_t * const puxStackBuffer, 
	const MemoryRegion_t * const xRegions )
{
BaseType_t xReturn;
TCB_t * pxNewTCB;

	configASSERT( pxTaskCode );
	configASSERT( ( ( uxPriority & ( ~portPRIVILEGE_BIT ) ) < configMAX_PRIORITIES ) );

	/* Allocate the memory required by the TCB and stack for the new task,
	checking that the allocation was successful. */
	pxNewTCB = prvAllocateTCBAndStack( usStackDepth, puxStackBuffer );

	if( pxNewTCB != NULL )
	{
		StackType_t *pxTopOfStack;

		#if( portUSING_MPU_WRAPPERS == 1 )
			/* Should the task be created in privileged mode? */
			BaseType_t xRunPrivileged;
			if( ( uxPriority & portPRIVILEGE_BIT ) != 0U )
			{
				xRunPrivileged = pdTRUE;
			}
			else
			{
				xRunPrivileged = pdFALSE;
			}
			uxPriority &= ~portPRIVILEGE_BIT;
		#endif /* portUSING_MPU_WRAPPERS == 1 */

		/* Calculate the top of stack address.  This depends on whether the
		stack grows from high memory to low (as per the 80x86) or vice versa.
		portSTACK_GROWTH is used to make the result positive or negative as
		required by the port. */
		#if( portSTACK_GROWTH < 0 )
		{
			pxTopOfStack = pxNewTCB->pxStack + ( usStackDepth - ( uint16_t ) 1 );
			pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ( portPOINTER_SIZE_TYPE ) ~portBYTE_ALIGNMENT_MASK  ) );

			/* Check the alignment of the calculated top of stack is correct. */
			configASSERT( ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack & ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) == 0UL ) );
		}
		#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 ) );

			/* If we want to use stack checking on architectures that use
			a positive stack growth direction then we also need to store the
			other extreme of the stack space. */
			pxNewTCB->pxEndOfStack = pxNewTCB->pxStack + ( usStackDepth - 1 );
		}
		#endif /* portSTACK_GROWTH */

		/* Setup the newly allocated TCB with the initial state of the task. */
		prvInitialiseTCBVariables( pxNewTCB, pcName, uxPriority, xRegions, usStackDepth );

		/* Initialize the TCB stack to look as if the task was already running,
		but had been interrupted by the scheduler.  The return address is set
		to the start of the task function. Once the stack has been initialised
		the	top of stack variable is updated. */
		#if( portUSING_MPU_WRAPPERS == 1 )
		{
			pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters, xRunPrivileged );
		}
		#else /* portUSING_MPU_WRAPPERS */
		{
			pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );
		}
		#endif /* portUSING_MPU_WRAPPERS */

		if( ( void * ) pxCreatedTask != NULL )
		{
			/* Pass the TCB out - in an anonymous way.  The calling function/
			task can use this as a handle to delete the task later if
			required.*/
			*pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
		}
		else
		{
			mtCOVERAGE_TEST_MARKER();
		}

		/* Ensure interrupts don't access the task lists while they are being
		updated. */
		taskENTER_CRITICAL();
		{
			uxCurrentNumberOfTasks++;
			if( pxCurrentTCB == NULL )
			{
				/* There are no other tasks, or all the other tasks are in
				the suspended state - make this the current task. */
				pxCurrentTCB =  pxNewTCB;

				if( uxCurrentNumberOfTasks == ( UBaseType_t ) 1 )
				{
					/* This is the first task to be created so do the preliminary
					initialisation required.  We will not recover if this call
					fails, but we will report the failure. */
					prvInitialiseTaskLists();
				}
				else
				{
					mtCOVERAGE_TEST_MARKER();
				}
			}
			else
			{
				/* If the scheduler is not already running, make this task the
				current task if it is the highest priority task to be created
				so far. */
				if( xSchedulerRunning == pdFALSE )
				{
					if( pxCurrentTCB->uxPriority <= uxPriority )
					{
						pxCurrentTCB = pxNewTCB;
					}
					else
					{
						mtCOVERAGE_TEST_MARKER();
					}
				}
				else
				{
					mtCOVERAGE_TEST_MARKER();
				}
			}

			uxTaskNumber++;

			#if ( configUSE_TRACE_FACILITY == 1 )
			{
				/* Add a counter into the TCB for tracing only. */
				pxNewTCB->uxTCBNumber = uxTaskNumber;
			}
			#endif /* configUSE_TRACE_FACILITY */
			traceTASK_CREATE( pxNewTCB );

			prvAddTaskToReadyList( pxNewTCB );

			xReturn = pdPASS;
			portSETUP_TCB( pxNewTCB );
		}
		taskEXIT_CRITICAL();
	}
	else
	{
		xReturn = errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
		traceTASK_CREATE_FAILED();
	}

	if( xReturn == pdPASS )
	{
		if( xSchedulerRunning != pdFALSE )
		{
			/* If the created task is of a higher priority than the current task
			then it should run now. */
			if( pxCurrentTCB->uxPriority < uxPriority )
			{
				taskYIELD_IF_USING_PREEMPTION();
			}
			else
			{
				mtCOVERAGE_TEST_MARKER();
			}
		}
		else
		{
			mtCOVERAGE_TEST_MARKER();
		}
	}

	return xReturn;
}
创建任务函数的第一步是创建该任务的TCB指针通过调用prvAllocateTCBAndStack()函数来实现

该函数实现如下

static TCB_t *prvAllocateTCBAndStack( const uint16_t usStackDepth, StackType_t * const puxStackBuffer )
{
TCB_t *pxNewTCB;

	/* Allocate space for the TCB.  Where the memory comes from depends on
	the implementation of the port malloc function. */
	pxNewTCB = ( TCB_t * ) pvPortMalloc( sizeof( TCB_t ) );

	if( pxNewTCB != NULL )
	{
		/* Allocate space for the stack used by the task being created.
		The base of the stack memory stored in the TCB so the task can
		be deleted later if required. */
		pxNewTCB->pxStack = ( StackType_t * ) pvPortMallocAligned( ( ( ( size_t ) usStackDepth ) * sizeof( StackType_t ) ), puxStackBuffer ); 

		if( pxNewTCB->pxStack == NULL )
		{
			/* Could not allocate the stack.  Delete the allocated TCB. */
			vPortFree( pxNewTCB );
			pxNewTCB = NULL;
		}
		else
		{
			/* Avoid dependency on memset() if it is not required. */
			#if( ( configCHECK_FOR_STACK_OVERFLOW > 1 ) || ( configUSE_TRACE_FACILITY == 1 ) || ( INCLUDE_uxTaskGetStackHighWaterMark == 1 ) )
			{
				/* Just to help debugging. */
				( void ) memset( pxNewTCB->pxStack, ( int ) tskSTACK_FILL_BYTE, ( size_t ) usStackDepth * sizeof( StackType_t ) );
			}
			#endif /* ( ( configCHECK_FOR_STACK_OVERFLOW > 1 ) || ( ( configUSE_TRACE_FACILITY == 1 ) || ( INCLUDE_uxTaskGetStackHighWaterMark == 1 ) ) ) */
		}
	}

	return pxNewTCB;
}
该函数很简单,申请一块TCB空间,如果申请成功之后则根据用户指定的栈深度来申请栈空间,如果栈空间申请成功则将该TCB指针返回上一级。栈空间申请的实现与FreeRTOS的内存管理有关,在portable/MemMang文件夹下有5个文件来实现内存分配管理,这里就先不详细了解了,后面再去深入了解吧


在TCB指针申请成功之后,第二步是初始化栈顶指针,一般而言栈的增长方向是向下的,即往地址小的方向增长,随着对栈的操作,栈顶指针不断地变化,pxStack是栈的起始地址,根据用户指定的栈深度来得到栈顶指针的地址,栈的结构如下


同时根据portBYTE_ALIGNMENT_MASK宏来指定栈顶指针的字节对齐方式,STM32是32位MCU,因此栈顶指针是4字节对齐的。

有关TCB的空间申请完毕之后就要填充TCB里面的一些变量属性了,由prvInitialiseTCBVariables函数来实现,该函数实现代码如下

static void prvInitialiseTCBVariables( TCB_t * const pxTCB, const char * const pcName, UBaseType_t uxPriority, 
	const MemoryRegion_t * const xRegions, const uint16_t usStackDepth ) 
{
UBaseType_t x;

	/* Store the task name in the TCB. */
	for( x = ( UBaseType_t ) 0; x < ( UBaseType_t ) configMAX_TASK_NAME_LEN; x++ )
	{
		pxTCB->pcTaskName[ x ] = pcName[ x ];

		if( pcName[ x ] == 0x00 )
		{
			break;
		}
		else
		{
			mtCOVERAGE_TEST_MARKER();
		}
	}

	/* Ensure the name string is terminated in the case that the string length
	was greater or equal to configMAX_TASK_NAME_LEN. */
	pxTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';

	/* This is used as an array index so must ensure it's not too large.  First
	remove the privilege bit if one is present. */
	if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )
	{
		uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U;
	}
	else
	{
		mtCOVERAGE_TEST_MARKER();
	}

	pxTCB->uxPriority = uxPriority;
	#if ( configUSE_MUTEXES == 1 )
	{
		pxTCB->uxBasePriority = uxPriority;
		pxTCB->uxMutexesHeld = 0;
	}
	#endif /* configUSE_MUTEXES */

	vListInitialiseItem( &( pxTCB->xGenericListItem ) );
	vListInitialiseItem( &( pxTCB->xEventListItem ) );

	/* Set the pxTCB as a link back from the ListItem_t.  This is so we can get
	back to	the containing TCB from a generic item in a list. */
	listSET_LIST_ITEM_OWNER( &( pxTCB->xGenericListItem ), pxTCB );

	/* Event lists are always in priority order. */
	listSET_LIST_ITEM_VALUE( &( pxTCB->xEventListItem ), ( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority );
	listSET_LIST_ITEM_OWNER( &( pxTCB->xEventListItem ), pxTCB );

	#if ( portCRITICAL_NESTING_IN_TCB == 1 )
	{
		pxTCB->uxCriticalNesting = ( UBaseType_t ) 0U;
	}
	#endif /* portCRITICAL_NESTING_IN_TCB */

	#if ( configUSE_APPLICATION_TASK_TAG == 1 )
	{
		pxTCB->pxTaskTag = NULL;
	}
	#endif /* configUSE_APPLICATION_TASK_TAG */

	#if ( configGENERATE_RUN_TIME_STATS == 1 )
	{
		pxTCB->ulRunTimeCounter = 0UL;
	}
	#endif /* configGENERATE_RUN_TIME_STATS */

	#if ( portUSING_MPU_WRAPPERS == 1 )
	{
		vPortStoreTaskMPUSettings( &( pxTCB->xMPUSettings ), xRegions, pxTCB->pxStack, usStackDepth );
	}
	#else /* portUSING_MPU_WRAPPERS */
	{
		( void ) xRegions;
		( void ) usStackDepth;
	}
	#endif /* portUSING_MPU_WRAPPERS */

	#if ( configUSE_NEWLIB_REENTRANT == 1 )
	{
		/* Initialise this task's Newlib reent structure. */
		_REENT_INIT_PTR( ( &( pxTCB->xNewLib_reent ) ) );
	}
	#endif /* configUSE_NEWLIB_REENTRANT */
}
是比较简单的一个函数,首先是采用字节拷贝方式来存储任务名称,记录任务的优先级且增加了优先级保护代码,初始化了状态项和事件项,同时设置了状态项和事件项的所有者为该任务的TCB指针,有关事件项赋值语句

listSET_LIST_ITEM_VALUE( &( pxTCB->xEventListItem ), ( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority );
还不了解为啥要用configMAX_PRIORITIES-uxPriority的值来存储,后面想到了再看吧。回到创建任务的函数中接着是初始化栈空间

StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
	/* Simulate the stack frame as it would be created by a context switch
	interrupt. */
	pxTopOfStack--; /* Offset added to account for the way the MCU uses the stack on entry/exit of interrupts. */
	*pxTopOfStack = portINITIAL_XPSR;	/* xPSR */
	pxTopOfStack--;
	*pxTopOfStack = ( StackType_t ) pxCode;	/* PC */
	pxTopOfStack--;
	*pxTopOfStack = ( StackType_t ) prvTaskExitError;	/* LR */
	pxTopOfStack -= 5;	/* R12, R3, R2 and R1. */
	*pxTopOfStack = ( StackType_t ) pvParameters;	/* R0 */
	pxTopOfStack -= 8; /* R11..R4. */

	return pxTopOfStack;
}

即栈空间中高地址主要是存储通用寄存器的值,低地址空间用来存储用户数据,如下


如果用户需要用到任务句柄,在创建任务函数中将该任务的TCB指针赋给了任务句柄pxCreatedTask,之后进入临界区中,进入和退出临界区代码如下

void vPortEnterCritical( void )
{
    portDISABLE_INTERRUPTS();
    uxCriticalNesting++;//临界区嵌套标记加1
	__dsb( portSY_FULL_READ_WRITE );
	__isb( portSY_FULL_READ_WRITE );
}
/*-----------------------------------------------------------*/

void vPortExitCritical( void )
{
	configASSERT( uxCriticalNesting );
    uxCriticalNesting--;//临界区嵌套标记减1
    if( uxCriticalNesting == 0 )
    {
        portENABLE_INTERRUPTS();
    }
}

uxCriticalNesting变量是临界区嵌套标记,声明如下

static UBaseType_t uxCriticalNesting = 0xaaaaaaaa;
在进入临界区时关闭中断,在退出临界区时判断是否有临界区嵌套,如果没有则打开中断,在临界区代码中更新当前任务数uxCurrentNumberOfTasks,如果当前TCB指针pxCurrentTCB为空,则将pxCurrentTCB指针指向新建任务的TCB,如果新建的任务是第一个任务则还需要初始化任务链表,进入prvInitialiseTaskLists()函数

static void prvInitialiseTaskLists( void )
{
UBaseType_t uxPriority;

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

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

	#if ( INCLUDE_vTaskDelete == 1 )
	{
		vListInitialise( &xTasksWaitingTermination );
	}
	#endif /* INCLUDE_vTaskDelete */

	#if ( INCLUDE_vTaskSuspend == 1 )
	{
		vListInitialise( &xSuspendedTaskList );
	}
	#endif /* INCLUDE_vTaskSuspend */

	/* Start with pxDelayedTaskList using list1 and the pxOverflowDelayedTaskList
	using list2. */
	pxDelayedTaskList = &xDelayedTaskList1;
	pxOverflowDelayedTaskList = &xDelayedTaskList2;
}
首先初始化的是就绪任务链表,初始化每一个优先级的就绪链表,此外还初始化两个延时链表以及挂起的就绪链表,并将两个延时链表指针指向了已初始化的两个延时链表。

如果当前TCB指针pxCurrentTCB不为空且调度器还没有运行则根据当前任务优先级判断是否需要更新pxCurrentTCB指针,pxCurrentTCB指针总是指向优先级最高的任务,更新任务数uxTaskNumber,完成之后将该任务插入相应的就绪链表中等待调度器调用,

#define prvAddTaskToReadyList( pxTCB )																\
	traceMOVED_TASK_TO_READY_STATE( pxTCB )															\
	taskRECORD_READY_PRIORITY( ( pxTCB )->uxPriority );												\
	vListInsertEnd( &( pxReadyTasksLists[ ( pxTCB )->uxPriority ] ), &( ( pxTCB )->xGenericListItem )

这里有个疑问好想xGenericListItem还没有包含有效值xItemValue

退出临界区代码后如果所有资源已准备就绪即xReturn=pdPass时,如果掉调度处于运行中则根据优先级判断是否需要执行一次任务切换,该代码最终调用vPortYield()函数,如下

void vPortYield( void )
{
	/* Set a PendSV to request a context switch. */
	*( portNVIC_INT_CTRL ) = portNVIC_PENDSVSET;

	/* Barriers are normally not required but do ensure the code is completely
	within the specified behaviour for the architecture. */
	__dsb( portSY_FULL_READ_WRITE );
	__isb( portSY_FULL_READ_WRITE );
}

进入PENDSV中断执行任务切换代码,

#define portNVIC_INT_CTRL			( ( volatile uint32_t *) 0xe000ed04 )
该寄存器的说明在ARMv6架构参考手册中有说明

#define portNVIC_PENDSVSET			0x10000000


将PENDSVSET置1进入PENDSV中断执行任务切换代码,但是我们创建任务的代码还没有执行完毕,没有返回,调度器就去执行新建任务的代码了,总觉得应该创建任务代码返回后再切换才对,写一个示例代码验证

static const char *taskName2="Task2\r\n";
void vTask1(void *pvParamters);
void vTask2(void *pvParamters);
void vTask1(void *pvParamters)
{
	char *TskName;
	TskName = (char *)pvParamters;
	if(xTaskCreate(vTask2,"Task2\r\n",128,(void *)taskName2,2,NULL)== pdPASS)
	{
		uart_puts("create task2 success\r\n");
	}
	while(1)
	{
		uart_puts(TskName);

		vTaskDelay(1000/portTICK_RATE_MS);
		
	}
}
void vTask2(void *pvParamters)
{
	char *TskName;
	TskName = (char *)pvParamters;
	while(1)
	{
		uart_puts(TskName);

		vTaskDelay(1000/portTICK_RATE_MS);
		
	}
}

int main(void)
{
	static const char *taskName1="Task1\r\n";
	

	uart_init (SET_UART (BAUD_115200,PAR_NONE));  //初始化串口

	xTaskCreate(vTask1,"Task1",128,(void *)taskName1,1,NULL);
	/*启动调度器,任务开始执行*/
	vTaskStartScheduler();
	while(1);
	return 0;
}

在主函数中创建TASK1,在TASK1的代码中创建TASK2,且TASK2的优先级要高于TASK1并接收创建TASK2的返回值,验证结果如下


运行结果果然与代码一致,创建完TASK2后,调度器发现TASK2的优先级要高于TASK1,执行了任务切换去执行TASK2的代码,当TASK2挂起时,TASK1获取了CPU继续执行,获取到了创建TASK2的返回值并输出了“create task2 success”。创建任务的代码就先分析到这,后面有新发现的话再补充吧。。。

阅读更多
个人分类: FreeRTOS 单片机
上一篇FreeRTOS学习笔记——链表
下一篇FreeRTOS学习笔记——任务延时
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭