大多数初级mcu工程师所使用的的程序框架多为前后台或者轮询方式,当整个系统中有多个任务时,且有高低优先级的划分,这个时候如果使用前后台或者轮询方式会使高优先级的任务不能及时的响应。所以RTOS是每个mcu工程师技术成长过程中必须掌握的一份技能,作为RTOS初学者, 我们刚开始接触freertos时,面对较多的代码都会有一种懵的感觉,感觉要拿下它实属不易。本文作者也是刚刚接触freertos不久,深感学习过程中的艰难,故自己手动编程实现一个简易的freertos实时操作系统。整个工程代码量很少,实现了任务创建、堆栈分配、任务启动、任务切换、任务抢占等功能,我们学习freertos就会知道,这些都是rtos核心,其他的大多数代码都是为这些功能的辅助或者在此基础上的扩展。我们了解了rtos的本质后,再去学习官方的rtos,会有拨开云雾见天日感觉。总之希望自己的学习总结能对众多初次接触rtos的小伙伴有所帮助。
1、链表实现
链表是RTOS学习必须掌握的技能,作为mcu工程师,虽然平时工作中很少用到复杂的数据结构算法,大家大学时应该多多少少都学习过简单的数据结构,freertos中的列表就是数据结构中的循环链表。
列表的声明如下:

struct xList;
/*define Full function list*/
typedef struct List_Item
{
  uint32_t ItemValue;               /*<!该列表元素在整个链表中的位置*/
  struct List_Item* pxNext;         /*<!指向下一个列表元素*/
  struct List_Item* pxPrevious;     /*<!指向上一个列表元素*/
  void* pvOwner;                    /*<!指向该列表元素所包含的任务*/
  struct xList* pxContainer;        /*<!指向该列表元素所包含哪一个列表头结点*/
}list_item;
/*define smart function list*/
typedef struct MinList_Item
{
  uint32_t ItemValue;               /*<!该列表元素在整个链表中的位置*/
  struct List_Item* pxNext;         /*<!指向下一个列表元素*/
  struct List_Item* pxPrevious;     /*<!指向上一个列表元素*/
}minlist_item;
/*define list struct*/
typedef struct xList
{
  uint32_t list_number;	            /*<!列表中目前总共的列表数*/
  list_item* pxIndex;               /*<!所指向的列表元素*/
  minlist_item xListEnd;           /*<!列表的尾结点*/
}xlist;

这里的 minlist_item 其实就是数据结构中的头结点,无具体内容,在遍历整个链表时使用。因为是循环链表,这里的头结点即尾结点。

列表的函数定义如下:

/*!
    \brief      xList end insert new list function
    \param[in]  xlist* const pxList  
		 \arg       xlist 
    \param[in]  xlist* const pxList  
		 \arg       insert new list
    \param[out] none
    \retval     none
*/
void vListInsertEnd(xlist* const pxList,list_item* const pxNewListItem)
{
	list_item* const pxIndex=pxList->pxIndex;
	/*list end insert new list*/
	pxNewListItem->pxNext=pxIndex;
	pxNewListItem->pxPrevious=pxIndex->pxPrevious;
	pxIndex->pxPrevious->pxNext=pxNewListItem;
	pxIndex->pxPrevious=pxNewListItem;
	pxNewListItem->pxContainer=pxList;/*pxList contain new list*/
	pxList->list_number+=1;/* pxList number add 1*/
}

/*!
    \brief      list insert new list in any location
    \param[in]  xlist* const pxList  
		 \arg       xlist 
    \param[in]  xlist* const pxList  
		 \arg       insert new list
    \param[out] none
    \retval     none
*/
void vListInsert(xlist* const pxList,list_item* const pxNewListItem)
{
	uint32_t Location_Value=pxNewListItem->ItemValue;/*insert list location*/
	list_item* pxIterator;
	/*find new list insert location*/
	if(0xffffffff==Location_Value){
		 pxIterator=pxList->xListEnd.pxPrevious;
	}else{
		 pxIterator=(list_item*)&(pxList->xListEnd);
		 while(pxIterator->pxNext->ItemValue<Location_Value){
        pxIterator=pxIterator->pxNext;
		 }		
	}
	/*insert new list*/
	pxNewListItem->pxNext=pxIterator->pxNext;
	pxNewListItem->pxNext->pxPrevious=pxNewListItem;
	pxNewListItem->pxPrevious=pxIterator;
	pxIterator->pxNext=pxNewListItem;
	pxNewListItem->pxContainer=pxList;/*pxList contain new list*/
	pxList->list_number+=1;/* pxList number add 1*/
}

/*!
    \brief      list remove a list in any location
    \param[in]  list_item* const pxItemToRemove  
		 \arg       remove list
    \param[out] remove list ItemValue
    \retval     pxItemToRemove->ItemValue
*/
uint32_t uxListRemove(list_item* const pxItemToRemove)
{
	/*The list item knows which list it is in*/
	xlist* const pxList=pxItemToRemove->pxContainer;
	/*delete list item*/
	pxItemToRemove->pxPrevious->pxNext=pxItemToRemove->pxNext;
	pxItemToRemove->pxNext->pxPrevious=pxItemToRemove->pxPrevious;
	/* Make sure the index is left pointing to a valid item. */
	if(pxList->pxIndex==pxItemToRemove){
		 pxList->pxIndex=pxItemToRemove->pxPrevious;	
	}
	pxItemToRemove->pxContainer=NULL;/*contain list item is NULL*/
	pxList->list_number-=1;/* pxList number reduce 1*/
	return pxItemToRemove->ItemValue;/*return remove list ItemValue*/
}

这部分主要是列表的尾部插入、任意位置插入、将列表元素从包含的列表删除等。因为这块主要是数据结构方面的知识,就不做过多的讲解。

2、任务创建、堆栈分配、启动、切换、抢占实现
任务结构体声明

/*define task control struct*/
typedef struct TaskControl{
	uint32_t* pxTopOfStack;/*该任务堆栈的栈顶位置*/
	list_item xStateListItem;/*任务的状态列表,表示此刻任务所处的状态:就绪、挂起、阻塞*/ 
	list_item xEventListItem;/*任务的事件列表,用于任务等待某一个信号量,解除阻塞*/
	uint32_t  uxPriority;/*该任务的优先级*/
    uint32_t* pxStack;/*栈的起始位置,用于任务堆栈溢出检测*/ 	
	uint32_t  TaskNumber;/*该任务是第几个被创建*/
	uint32_t  uxBasePriority;/*用于任务优先级被临时提高时,保存任务原有的优先级*/
	uint32_t  ulRunTimeCounter;/*用于记录任务任务运行的时间*/
}Task;

官方RTOS源码任务的声明代码量很多,本文做了一定的删减,但是像任务堆栈溢出检测、任务阻塞(绝对延时和相对延时)、信号量等还没有实现,只是实现了任务创建、堆栈分配、启动、切换、抢占等,打算后期手动实现,做相关实验后会更新博客。
任务创建

/*!
    \brief      任务初始化
    \param[in]  pxTaskCode  
		 \arg       该任务执行的函数地址
		\param[in]  usStackDepth  
		 \arg       任务堆栈的深度
		\param[in]  uxPriority  
		 \arg       任务的优先级
    \param[in]  pxTCB  
		 \arg       任务结构体指针
    \param[out] none
    \retval     none
*/
void Creat_Task(TaskFunction_t pxTaskCode,const uint32_t usStackDepth,uint32_t uxPriority,Task* pxTCB,char* stack_address)
{
	pxTCB->uxPriority=uxPriority;/*任务优先级赋值*/
	pxTCB->uxBasePriority=uxPriority;/*优先级翻转或继承时,保存任务原有的优先级*/
	vListInitialiseItem(&(pxTCB->xStateListItem));/*任务状态列表的初始化*/
	vListInitialiseItem(&(pxTCB->xEventListItem));/*任务事件列表的初始化*/
	pxTCB->xEventListItem.pvOwner=(void*)pxTCB;/*任务事件列表包含的任务指向本任务*/
	pxTCB->xEventListItem.ItemValue=uxPriority;/*状态链表保存任务优先级*/
	pxTCB->xStateListItem.pvOwner=(void*)pxTCB;/*任务状态列表包含的任务指向本任务*/
	pxTCB->pxTopOfStack=(uint32_t*)(stack_address+(usStackDepth * sizeof(uint32_t)));/*计算栈顶的位置*/
	pxTCB->pxStack=(uint32_t*)stack_address;/*栈顶的起始地址*/
    pxTCB->pxTopOfStack=TaskInitialStack(pxTCB->pxTopOfStack,pxTaskCode);/*任务栈的初始化*/
	AddNewTaskToReadyList(pxTCB);/*添加本任务到就绪链表中*/
}

任务堆栈的初始化

/*!
    \brief      任务初始化
    \param[in]  pxTopOfStack  
		 \arg       任务的栈顶位置
		\param[in]  pxTaskCode
		 \arg       任务执行的函数指针
    \param[out] none
    \retval     初始化后栈顶的位置
*/
uint32_t* TaskInitialStack(uint32_t* pxTopOfStack,TaskFunction_t pxTaskCode)
{
	pxTopOfStack--;
	*pxTopOfStack=portINITIAL_XPSR; /* xPSR */
	pxTopOfStack--;
	*pxTopOfStack=(uint32_t)pxTaskCode;/* PC */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* LR */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R12 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R3 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R2 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R1 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R0 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R11 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R10 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R9 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R8 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R7 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R6 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R5 */
	pxTopOfStack--;
	*pxTopOfStack=0;                   /* R4 */
	return pxTopOfStack;
}

freertos 实时操作系统的本质并不是多个任务并行执行,每个时刻其实只有一个任务在执行,其每个任务都有自己的堆栈空间。在任务执行时,从堆栈空间出栈,将保存的数据赋值给PC、LR、R0~R3、R4 ~ R11等,PC从该任务中断处继续执行。在任务切换时,将当前PC、LR、R0 ~R3、R4 ~R11等值入栈,保存到该任务的堆栈中,方便下次任务执行时出栈使用。
portINITIAL_XPSR是表示使用ARM指令集还是Thumb指令集,包括堆栈初始化的顺序,我们可以参考cortex-M3手册中断异常章节。R4 ~R11需要我们自己手动入栈或出栈,R0 ~R3、PC、LR、xPSR这些在进入退出异常中断时,由硬件自行入栈或出栈。这块内容大家要结合cortex-M3内核手册理解,因为很多都是内核相关定死的,说白了我们的堆栈初始化就是伪造一个现场,既然是伪造,那肯定的按照人家的规矩来啦。 portINITIAL_XPSR 是一个宏定义,它的值为0x01000000,查阅内核手册,我们可以得知其24bit置1表明使用thumb指令。任务堆栈初始化,初次PC指针指向的地址肯定为任务函数的地址,当任务被中断时,则是保存任务函数中断时执行的地址。
任务加入当前就绪列表

/*!
    \brief      添加任务到就绪链表
    \param[in]  pxTCB 
		 \arg       任务的结构体指针
    \param[out] none
    \retval     none
*/
void AddNewTaskToReadyList(Task* pxTCB)
{
	uint32_t uxPriority;
	uxCurrentNumberOfTasks+=1;/*当前就绪任务数加1*/
	/*There are no other tasks*/
	if(NULL==pxCurrentTCB){
		 pxCurrentTCB=pxTCB;/* 没有任务被创建,该任务是第一个被创建*/
		 MAX_PRIORITIES=pxTCB->uxPriority;
     if(1==uxCurrentNumberOfTasks){
       /*第一个任务被创建、就绪列表数组元素初始化、阻塞延时列表、挂起列表初始化*/
			 for(uxPriority=0;uxPriority<configMAX_PRIORITIES;uxPriority++){
				 vListInitialise(&pxReadyTasksLists[uxPriority]);
			 }
			 vListInitialise(&xDelayedTaskList);
			 vListInitialise(&xSuspendedTaskList);
     }
     uxTaskNumber+=1;/* 第几个任务被创建*/	
     pxTCB->TaskNumber=uxTaskNumber;	
     vListInsertEnd(&pxReadyTasksLists[pxTCB->uxPriority],&pxTCB->xStateListItem);/*将该任务添加到对应优先级的就绪链表中*/	 
	}else{
	     /* 该任务不是第一个被创建,当前任务调度未开始 */
		 if(0==xSchedulerRunning){
		   /*new task priority more than runing task */
		   if(pxCurrentTCB->uxPriority<=pxTCB->uxPriority){
			    pxCurrentTCB=pxTCB;
			    MAX_PRIORITIES=pxTCB->uxPriority;/*MAX_PRIORITIES记录当前*/
		   }else{
        
           }				 
         }
		 uxTaskNumber+=1;
		 pxTCB->TaskNumber=uxTaskNumber;
	   /*task statelist insert readylist same priority list end*/
		 vListInsertEnd(&pxReadyTasksLists[pxTCB->uxPriority],&pxTCB->xStateListItem);
		 /* 当任务在执行过程中被创建*/
		 if(xSchedulerRunning!=0){
		   if(pxCurrentTCB->uxPriority<=pxTCB->uxPriority){
			   //pxCurrentTCB=pxTCB; /* 此处代码不被屏蔽,就会出现很奇怪的问题,当初调试时,费了很长时间,后面会更新博客具体讲解*/
				 MAX_PRIORITIES=pxTCB->uxPriority;
				 /*task switch*/
				 *( portNVIC_INT_CTRL ) = portNVIC_PENDSVSET;/*在任务执行过程中更高优先级的任务被创建,此时就需要强制执行一次任务切换*/
		     }
        }		 
	}
}

任务的启动

/*!
    \brief      task start
    \param[in]  none 
		 \arg       none
    \param[out] none
    \retval     none
*/
void Task_Start(void)
{
	systick_config();/*systick 中断初始化,10ms中断一次*/
	NVIC_SetPriority(PendSV_IRQn, 0x07U);/*挂起中断、和systick中断的优先级设为最低*/
	xSchedulerRunning=1;/*任务启动标志位置1 */
	/*start first task*/
	vStartFirstTask();/*执行任务启动函数*/
}

通过GD官方手册可知,NVIC异常有3bit,所以中断优先级为0~7,我们这里将systick和Pendsv中断设为7最低优先级的目的是为了防止systick和Pendsv挂起中断打断外设中断,造成外设中断处理被延时。这种情况是万万不可的,外设的中断优先级一定是最高的。通过了解freertos任务启动过程,我们可以知道通过触发一个SVC任务调度中断,这部分的代码我们需要用汇编去实现。

vStartFirstTask PROC  
	            ldr R0, =0xE000ED08  ;查阅Cortex-M3手册可知0xE000ED08为保存中断向量表地址寄存器
	            ldr r0, [r0]  ;将中断向量表起始地址保存在RO中     
	            ldr r0, [r0]  ; 向量表起始地址保存的是栈顶指针,取栈顶指针 
	            msr msp,  r0  ; 将栈顶指针的内容赋值给msp
	            cpsie i ;使能全局中断
	            cpsie f
	            dsb ;指令集隔离
	            isb
	            svc 0  ;触发一个SVC中断
	            nop
	            nop	
			    ENDP
SVC_Handler    PROC
               EXPORT  SVC_Handler                    
			   IMPORT  pxCurrentTCB ;表示该变量是全局变量,不在本文件中定义
               ldr r3, =pxCurrentTCB ;将存储该指针变量的地址赋值给R3
			   ldr r1,  [r3] ;取变量指针值赋值给R1
			   ldr r0,  [r1] ;任务变量的起始地址存储的是该任务栈顶指针
			   ldmia r0!, {r4 - r11} ;将任务栈中的r4-r11出栈赋值给r4-r11
			   msr psp, r0 ;将此时的栈顶指针赋值给用户堆栈指针,用于退出异常时,硬件出栈恢复R0~R3、PC、LR、xPSR的值
			   isb
			   mov r0, #0
			   msr basepri, r0;屏蔽优先级大于0的中断,因为cortex-m3中0的优先级最大,其实就是不屏蔽任何中断
			   orr r14, #0xd ;将LR链接地址的值改为0xFFFFFFFd
			   bx r14 ;返回线程模式,并使用线程堆栈指针
               ENDP

以上的内容还是要结合Cortex-M3手册来学习理解。当我们Debug模式进入异常handler时,查看内核LR寄存器,可以看到此时LR的值为0xFFFFFFF9,通过查阅内核手册可知,此时SP指针为MSP主堆栈,那理解 orr r14, #0xd 这句代码的含义就不难了。这段汇编代码执行完后,就会执行当前最高优先级的任务。
Systick中断函数:

#define portNVIC_INT_CTRL					( ( volatile uint32_t * ) 0xe000ed04 ) /*interrupt control state register*/
#define portNVIC_PENDSVSET					( 0x10000000 ) /*trigger PendSv interrupt bit 28 */
/*!
    \brief      SysTick Handler
    \param[in]  none 
		 \arg       none
    \param[out] none
    \retval     none
*/
void SysTick_Handler(void)
{
  if(xSchedulerRunning!=0){
	  /* Pend a context switch. */
	  *( portNVIC_INT_CTRL ) = portNVIC_PENDSVSET;
	}
}

查阅内核手册,要触发一个PendSv中断,只需将中断状态控制寄存器的bit28置1即可,中断状态寄存器的地址为0xe000ed04,本程序是每隔10ms执行一次任务切换。
PendSv挂起中断

在这里插入代码片PendSV_Handler\
                PROC
                EXPORT  PendSV_Handler                    [WEAK]
                IMPORT  pxCurrentTCB
				IMPORT  vTaskSwitchContext ;声明vTaskSwitchContext为一个全局函数,不在本文件中定义
				mrs r0, psp ;将线程堆栈指针赋值给R0,此时的R0~R3、PC、LR、xPSR的值已经由硬件自动入栈到该任务的堆栈空间
				isb
				ldr r3, =pxCurrentTCB
				ldr r2, [r3];将当前运行任务的指针赋值给R2
				stmdb r0!, {r4 - r11};将R11-R4的值入栈到该任务堆栈空间
				str r0, [r2];保存当前任务的栈顶指针
				stmdb sp!, {r3,r14};将R14、R3的值入栈
				mov r0, #7
				msr basepri, r0;因为后面要进行一个任务切换,所以将优先级大于PendSv的中断都屏蔽
				dsb
				isb
				bl vTaskSwitchContext;跳转到任务切换C函数去执行
				mov r0, #0
				msr basepri, r0;打开所有中断
				ldmia sp!, {r3,r14};将R3、R14的值出栈
				ldr r1, [r3];取pxCurrentTCB变量指针值赋值给R1
				ldr r0, [r1];任务变量的起始地址存储的是该任务栈顶指针
				ldmia r0!, {r4 - r11};将任务栈中的r4-r11出栈赋值给r4-r11
				msr psp, r0;将此时的栈顶指针赋值给用户堆栈指针,用于退出异常时,硬件出栈恢复R0~R3、PC、LR、xPSR的值
				isb
				bx r14;跳转到当前运行任务上次中断的地址出继续执行
				nop 
                ENDP

stmdb sp!, {r3,r14};将R14、R3的值入栈,这句代码的原因是此时我们要去跳转到C代码vTaskSwitchContext函数去执行代码,这个过程可能会改变LR的值,R3保存的是pxCurrentTCB变量指针,所以这两个都得入栈。这块我们得去查阅内核手册C函数和汇编相互调用的规则方面的一些知识,在此就不多讲了。
任务切换函数

/*!
    \brief      task switch
    \param[in]  none 
		 \arg       none
    \param[out] none
    \retval     none
*/
void vTaskSwitchContext(void)
{
	uint32_t Max_Priority=MAX_PRIORITIES;
	/*find max priority readlist */
  while(0==pxReadyTasksLists[Max_Priority].list_number){
      Max_Priority-=1;
  }		
	MAX_PRIORITIES=Max_Priority;
	//printf("Max_Priority is %d\n",Max_Priority);
	pxReadyTasksLists[Max_Priority].pxIndex=pxReadyTasksLists[Max_Priority].pxIndex->pxNext;
	if((void*)pxReadyTasksLists[Max_Priority].pxIndex==(void*)&(pxReadyTasksLists[Max_Priority].xListEnd)){
		 pxReadyTasksLists[Max_Priority].pxIndex=pxReadyTasksLists[Max_Priority].pxIndex->pxNext;
  }
	pxCurrentTCB=pxReadyTasksLists[Max_Priority].pxIndex->pvOwner;
}

这部分代码主要实现的是在就绪列表数组中找出当前优先级最大的链表,然后按时间片轮流执行相同优先级的任务。

3、实验验证
以上我们的主体代码已经全部讲完,下面就是实验验证过程,代码如下

#include "gd32f1x0.h"
#include <stdio.h>
#include "main.h"
#include "gd32f1x0_eval.h"
#include "task.h"

__align(4) static char Task_Stack_A[512]={0};
__align(4) static char Task_Stack_B[512]={0};
__align(4) static char Task_Stack_C[512]={0};
void Task_A(void);
void Task_B(void);
void Task_C(void);
__align(4) Task TCB_A,TCB_B,TCB_C;
/*!
    \brief      main function
    \param[in]  none
    \param[out] none
    \retval     none
*/
int main(void)
{
   gd_eval_com_init(EVAL_COM1); 
   Creat_Task(Task_A,128,2,&TCB_A,Task_Stack_A);
	 Creat_Task(Task_B,128,2,&TCB_B,Task_Stack_B);
	 //Creat_Task(Task_C,128,3,&TCB_C,Task_Stack_C);
	 Task_Start();
   //while (1);
}

void Task_A(void)
{
  while(1){
	  printf("taskA is running!!!\n");
	}
}

void Task_B(void)
{
  uint16_t num=0;
	while(1){
	  printf("taskB is running!!!\n");
	  num++;
		if(num==10){
         Creat_Task(Task_C,128,3,&TCB_C,Task_Stack_C);		
	 }
	}
}

void Task_C(void)
{
  while(1){
	  printf("taskC is running!!!\n");
	}
}
/* retarget the C library printf function to the USART */
int fputc(int ch, FILE *f)
{
    usart_data_transmit(EVAL_COM1, (uint8_t)ch);
    while(RESET == usart_flag_get(EVAL_COM1, USART_FLAG_TBE));
    return ch;
}

以上测试代码主要创建了3个任务TCB_A,TCB_B,TCB_C,任务A和任务B的优先级相同,任务C的优先级高于任务A、B,任务C是任务B在执行10次后被创建。任务A、B、C的堆栈空间我们是用最简单的数组分配来定义,不像RTOS代码中有静态分配和动态分配两种。程序运行结果我们可以在串口助手上看到刚开始是任务A和任务B轮流运行,当B运行10次后,任务C被创建,因为任务C的优先级是高于任务A和任务B的,所以后面我们可以看到只有任务C一个在执行。我们从freertos代码实现的原理可知,高优先级的任务永远不可能被低优先级的任务打断,那我们在实际的项目应用中,不可能所有的任务的优先级都设为最高,那怎么办呢?这个时候就得引入任务阻塞延时概念了,让高优先级的任务进入阻塞,此时是将高优先级的任务从就绪列表中移除,移入到阻塞延时列表中,那么此时低优先的任务不就可以执行了么。当延时时间到,又将高优先级的任务从阻塞延时列表中移除再次加入就绪列表,那么高优先级的任务不就可以再次运行了。所谓风水轮流转,不能因为你级别高,就一直霸占资源吧,哈哈!(开个玩笑)。

以上所讲的源代码我都放到https://download.csdn.net/download/qq_31446727/85103590地址了,大家可以去下载学习,因为本人也刚刚接触FreeRtos不久,有些地方理解可能不到位,如果哪里理解不对,可以在博文下面留言,大家共同学习进步!!!

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐