FreeRTOS任务管理

1. 任务状态理论讲解

定时器职中断周期此处的1000Hz表示的是没次间隔1毫秒就记一次数(在FreeConfig.h)文件中进行配置

cpp 复制代码
#define configTICK_RATE_HZ			( ( TickType_t ) 1000 )

判断是否需要任务切换在FreeRTOS里面每次间隔1毫秒切换一次(程序辅助理解图如下所示)

FreeRTOS中三种状态的切换(分别是就绪态,阻塞态,暂停态),三种状态的理解图如下说所示

2. 任务状态实验

对应程序:08_freertos_example_task_status

  • 任务切换的基础:tick中断

  • 有哪些任务状态?状态切换图

  • 怎么管理不同状态的任务:放在不同链表里

  • 阻塞状态(Blocked)举例:vTaskDelay函数

  • 暂停状态(Suspended)举例:vTaskSuspend/vTaskResume

任务状态理解图展示

程序测试过程中遇到的问题

cpp 复制代码
Load "F:\\STM32Study\\FreeRTOS_WDS\\1-6freertos_example_createtaskstatic\\FreeRTOS\\Demo\\CORTEX_STM32F103_Keil\\RTOSDemo.axf" 
BS \\RTOSDemo\main.c\200, 1
WS 1, `ch
LA `task1flagrun
___^
*** error 34: undefined identifier
LA `task2flagrun
___^
*** error 34: undefined identifier
LA `task3flagrun
___^
*** error 34: undefined identifier
*** error 65: access violation at 0x4002100C : no 'write' permission
*** error 65: access violation at 0x40021010 : no 'write' permission

解决问题参考文章

Keil Debug------*** error 65: access violation at 0x20005000 : no 'write' permission_*** error 65: access violation at 0x4002100c : no -CSDN博客文章浏览阅读840次,点赞2次,收藏5次。当遇到"*** error 65: access violation at 0x20005000 : no 'write' permission"类似错误时,这通常表示程序试图写入一个没有写入权限的内存地址。这个错误可能有多种原因,以下是一些常见的解决方法:1. 检查内存保护设置:请确保您的程序没有试图写入受保护的内存区域。某些嵌入式系统可能使用内存保护机制,如存储器保护单元(MPU)或写保护寄存器。请检查您的系统文档或参考手册,了解如何正确配置内存保护设置。_*** error 65: access violation at 0x4002100c : no 'write' permissionhttps://blog.csdn.net/weixin_44406127/article/details/134159125

具体修改方式如下所示

测试代码

cpp 复制代码
// 定义一个全局标记为用于表示任务是否在运行
static int task1flagrun = 0;
static int task2flagrun = 0;
static int task3flagrun = 0;


TaskHandle_t xHandleTask1;   // 任务1的句柄
TaskHandle_t xHandleTask2;   // 任务2的句柄
TaskHandle_t xHandleTask3;   // 任务3的句柄

void Task1Function(void * pram){
	  // 获取TickCount的函数
	  TickType_t tStart = xTaskGetTickCount();
	  TickType_t t;
	  // 添加标记位
	  int flag  = 0;
    while(1){
			t = xTaskGetTickCount();
			
			task1flagrun = 1;
			task2flagrun = 0;
			task3flagrun = 0;
		  printf("1"); 
			if(!flag && (t > tStart + 10)){
			   vTaskSuspend(xHandleTask3);   // 命令任务3进入暂停状态
				 flag = 1;
			}
			if(t > tStart + 20){
			   vTaskResume(xHandleTask3);    // 调用Resume进入就绪状态
			}
      	
		}
}

void Task2Function(void * pram){
	  int i = 0;
    while(1){
			task1flagrun = 0;
			task2flagrun = 1;
			task3flagrun = 0;
		  printf("2");
			
			// 让任务2进入到阻塞状态
			vTaskDelay(10);  // 这个是系统的延迟函数,主动进入阻塞状态,
			                 // 等待某个时刻的到来,这个时刻就是当前时间加上10个tick
			
			
		}
}

void Task3Function(void * pram){

         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("3");
				 }
}


void TaskGenericFunction(void * pram){
         int val = (int) pram;
         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("%d",val);
				 }
}

StackType_t xTask3Stack[100];
StaticTask_t xTask3TCB;
// 创建空闲的任务(创建静态任务的时候要编写一个函数让CPU在空闲的状态下也不能闲着)
StackType_t xIdleTaskStack[100];
StaticTask_t xIdleTaskTCB;

// 提供空闲栈的大小
void vApplicationGetIdleTaskMemory(
	StaticTask_t ** ppxIdleTaskTCBBuffer,
  StackType_t ** ppxIdleTaskStackBuffer,
  uint32_t * pulIdleTaskStackSize
){
     *ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
	   *ppxIdleTaskStackBuffer = xIdleTaskStack;
	   *pulIdleTaskStackSize = 100;
}



int main( void )
{

	
	
#ifdef DEBUG
  debug();
#endif

	prvSetupHardware();

	printf("Hello, world!\r\n");
  xTaskCreate(Task1Function,"Task1",100,NULL,1,&xHandleTask1);
  xTaskCreate(Task2Function,"Task2",100,NULL,1,NULL);
	xHandleTask3 = xTaskCreateStatic(Task3Function,"task3",100,NULL,1,xTask3Stack,&xTask3TCB);
  
	
	
	
	vTaskStartScheduler();

	/* Will only get here if there was not enough heap space to create the
	idle task. */
	return 0;
}

测试程序PWM波形图

3. vTaskDelay和vTaskDelayUntil原理用法

对应程序:09_freertos_example_delay

有两个Delay函数:

  • vTaskDelay:至少等待指定个数的Tick Interrupt才能变为就绪状态

  • vTaskDelayUntil

    • 老版本,没有返回值

    • 等待到指定的绝对时刻,才能变为就绪态。

  • xTaskDelayUntil

    • 新版本,返回pdTRUE表示确实延迟了,返回pdFALSE表示没有发生延迟(因为延迟的时间点早就过了)

    • 等待到指定的绝对时刻,才能变为就绪态。

遇到这个问题:遇到不恰当的争论

这个问题的原因是在keil结束断点调试的时候没有吧断点取消掉,然后就会一直报这个错误,并且KEIL无法关闭,调试完程序后一定要吧keil关闭掉

代码测试过程中遇到的问题

解决问题的博客:解决调试时候出现的"Encountered an improper argument"错误-CSDN博客文章浏览阅读2.6w次,点赞23次,收藏30次。今天分享一个我们在调试时候出现的一个错误,同时这个错误让我的工程也崩了好几十次。_encountered an improper argumenthttps://blog.csdn.net/OMGMac/article/details/126614887

测试程序的代码

cpp 复制代码
// 定义一个全局标记为用于表示任务是否在运行
static int task1flagrun = 0;
static int task2flagrun = 0;
static int task3flagrun = 0;

static int rands[] = {10,3,6,8,9,99,66};


TaskHandle_t xHandleTask1;   // 任务1的句柄
TaskHandle_t xHandleTask2;   // 任务2的句柄
TaskHandle_t xHandleTask3;   // 任务3的句柄

void Task1Function(void * pram){
	  // 获取TickCount的函数
	  TickType_t tStart = xTaskGetTickCount();
	  int i = 0;
	  int j = 0;
	  
    while(1){
			task1flagrun = 1;
			task2flagrun = 0;
			task3flagrun = 0;
			// 延时函数执行的时间是不固定的
			for(i = 0; i <rands[j]; i++){
			     printf("1"); 	
			}
		 
			j++;
			if(j == 7){
			   j = 0;
			}
      vTaskDelay(20);	
		}
}

void Task2Function(void * pram){
	  int i = 0;
    while(1){
			task1flagrun = 0;
			task2flagrun = 1;
			task3flagrun = 0;
		  printf("2");
			
			
			
			
		}
}

void Task3Function(void * pram){

         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("3");
				 }
}


void TaskGenericFunction(void * pram){
         int val = (int) pram;
         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("%d",val);
				 }
}

StackType_t xTask3Stack[100];
StaticTask_t xTask3TCB;
// 创建空闲的任务(创建静态任务的时候要编写一个函数让CPU在空闲的状态下也不能闲着)
StackType_t xIdleTaskStack[100];
StaticTask_t xIdleTaskTCB;

// 提供空闲栈的大小
void vApplicationGetIdleTaskMemory(
	StaticTask_t ** ppxIdleTaskTCBBuffer,
  StackType_t ** ppxIdleTaskStackBuffer,
  uint32_t * pulIdleTaskStackSize
){
     *ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
	   *ppxIdleTaskStackBuffer = xIdleTaskStack;
	   *pulIdleTaskStackSize = 100;
}



int main( void )
{

#ifdef DEBUG
  debug();
#endif

	prvSetupHardware();

	printf("Hello, world!\r\n");
  xTaskCreate(Task1Function,"Task1",100,NULL,2,&xHandleTask1);
  xTaskCreate(Task2Function,"Task2",100,NULL,1,NULL);
	xHandleTask3 = xTaskCreateStatic(Task3Function,"task3",100,NULL,1,xTask3Stack,&xTask3TCB);
  
	
	
	
	vTaskStartScheduler();

	/* Will only get here if there was not enough heap space to create the
	idle task. */
	return 0;
}

编写代码后程序的执行结果展示:PWM波形结果展示vTaskDelay()这个函数可以保证休眠的时间是一样的但是无法保证程序执行的时刻间隔是一样的

vTaskDelayUntil函数的含义

测试代码

cpp 复制代码
// 定义一个全局标记为用于表示任务是否在运行
static int task1flagrun = 0;
static int task2flagrun = 0;
static int task3flagrun = 0;

static int rands[] = {10,3,6,8,9,99,66};


TaskHandle_t xHandleTask1;   // 任务1的句柄
TaskHandle_t xHandleTask2;   // 任务2的句柄
TaskHandle_t xHandleTask3;   // 任务3的句柄

void Task1Function(void * pram){
	  // 获取TickCount的函数,表示将启动的时间记录下来
	  TickType_t tStart = xTaskGetTickCount();
	  int i = 0;
	  int j = 0;
	  
    while(1){
			task1flagrun = 1;
			task2flagrun = 0;
			task3flagrun = 0;
			// 延时函数执行的时间是不固定的
			for(i = 0; i <rands[j]; i++){
			     printf("1"); 	
			}
		 
			j++;
			if(j == 7){
			   j = 0;
			}
#if 0 
    vTaskDelay(20);	
#else
    vTaskDelayUntil(&tStart,20);
#endif
			
      
		}
}

void Task2Function(void * pram){
	  int i = 0;
    while(1){
			task1flagrun = 0;
			task2flagrun = 1;
			task3flagrun = 0;
		  printf("2");
			
			
			
			
		}
}

void Task3Function(void * pram){

         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("3");
				 }
}


void TaskGenericFunction(void * pram){
         int val = (int) pram;
         while(1){
					  task1flagrun = 0;
						task2flagrun = 0;
						task3flagrun = 1;
				    printf("%d",val);
				 }
}

StackType_t xTask3Stack[100];
StaticTask_t xTask3TCB;
// 创建空闲的任务(创建静态任务的时候要编写一个函数让CPU在空闲的状态下也不能闲着)
StackType_t xIdleTaskStack[100];
StaticTask_t xIdleTaskTCB;

// 提供空闲栈的大小
void vApplicationGetIdleTaskMemory(
	StaticTask_t ** ppxIdleTaskTCBBuffer,
  StackType_t ** ppxIdleTaskStackBuffer,
  uint32_t * pulIdleTaskStackSize
){
     *ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
	   *ppxIdleTaskStackBuffer = xIdleTaskStack;
	   *pulIdleTaskStackSize = 100;
}



int main( void )
{

#ifdef DEBUG
  debug();
#endif

	prvSetupHardware();

	printf("Hello, world!\r\n");
  xTaskCreate(Task1Function,"Task1",100,NULL,2,&xHandleTask1);
  xTaskCreate(Task2Function,"Task2",100,NULL,1,NULL);
	xHandleTask3 = xTaskCreateStatic(Task3Function,"task3",100,NULL,1,xTask3Stack,&xTask3TCB);
  
	
	
	
	vTaskStartScheduler();

	/* Will only get here if there was not enough heap space to create the
	idle task. */
	return 0;
}

PWM波形展示

4. 空闲任务及其钩子函数

对应程序:10_freertos_example_idletask,在05_freertos_example_createtask基础上修改

  • 任务后的清理工作在哪执行?分两类:

    • 自杀的任务:在空闲任务中完成清理工作,比如释放内存(都自杀了,怎么清理自己的尸体? 由别人来做)

    • 非自杀的任务:在vTaskDelete内部完成清理工作(凶手执行清理工作)

  • 空闲任务何时才能执行?

  • 空闲任务只能处于这2个状态之一:Running、Ready

  • 空闲任务钩子函数

    • 执行一些低优先级的、后台的、需要连续执行的函数

    • 测量系统的空闲时间:空闲任务能被执行就意味着所有的高优先级任务都停止了,所以测量空闲任 务占据的时间,就可以算出处理器占用率。

    • 让系统进入省电模式:空闲任务能被执行就意味着没有重要的事情要做,当然可以进入省电模式 了。

    • 绝对不能导致任务进入Blocked、Suspended状态

    • 如果你会使用 vTaskDelete() 来删除任务,那么钩子函数要非常高效地执行。如果空闲任务移植 卡在钩子函数里的话,它就无法释放内存。

空闲任务和钩子函数

定时宏:实现函数(这个钩子函数有一定的限制就是不能让空闲的任务进入空闲的状态或者是阻塞的状态要让空闲的任务执行一些清理的状态,因此空闲的任务要么处于运行状态,要么处于就绪状态永远不能处于阻塞状态,钩子函数执行的速度要越快越好)

实现代码

cpp 复制代码
void Task2Function(void * param);

// 这个是任务的标志位
static int task1flagrun = 0;
static int task2flagrun = 0;
static int taskidleflagrun = 0;

// 在主函数中创建任务1,在任务1里面创建任务2,并在任务1中删除任务2
void Task1Function(void * param)
{
	TaskHandle_t xHandleTask2;
	BaseType_t xReturn;
	
	while (1)
	{
		// 在任务1里面设置对应的变量
		task1flagrun = 1;
		task2flagrun = 0;
		taskidleflagrun = 0;
		
		printf("1");
		// 保存这个函数的返回值
		xReturn = xTaskCreate(Task2Function, "Task2", 1024, NULL, 2, &xHandleTask2);
		// 判断如果等于pass的话表示返回成功
		if (xReturn != pdPASS)
			printf("xTaskCreate err\r\n");
		//vTaskDelete(xHandleTask2);
			
	}
}

void Task2Function(void * param)
{
	while (1)
	{
		// 在任务2里面设置自己对应的变量
		task1flagrun = 0;
		task2flagrun = 1;
		taskidleflagrun = 0;
		printf("2");   // 观察变量的高低电平
		//vTaskDelay(2);
		vTaskDelete(NULL);
	}
}

// 这个是任务调度函数:也就是钩子函数
void vApplicationIdleHook( void )
{
	task1flagrun = 0;
	task2flagrun = 0;
	taskidleflagrun = 1;	
	printf("0");
}


/*-----------------------------------------------------------*/

int main( void )
{
	TaskHandle_t xHandleTask1;
		
#ifdef DEBUG
  debug();
#endif

	prvSetupHardware();

	printf("Hello, world!\r\n");
  // 设置任务1的优先级将任务1的优先级设置为0
	xTaskCreate(Task1Function, "Task1", 100, NULL, 0, &xHandleTask1);

	/* Start the scheduler. */
	vTaskStartScheduler();

	/* Will only get here if there was not enough heap space to create the
	idle task. */
	return 0;
}

实现函数(钩子函数不能放在死循环中不然空闲任务就不能执行其他的操作比如清理工作)

5. 任务调度算法

正在运行的任务,被称为"正在使用处理器",它处于运行状态。在单处理器系统中,任何时间里只能有一个任务处于运行状态。

非运行状态的任务,它处于这3种状态之一:

  • 阻塞(Blocked)

  • 暂停(Suspended)

  • 就绪(Ready)

就绪态的任务,可以被调度器挑选出来切换为运行状态,调度器永远都是挑选最高优先级的就绪态任务并让它进入运行状态。

阻塞状态的任务,它在等待"事件",当事件发生时任务就会进入就绪状态。

事件分为两类:

  • 时间相关的事件

    • 所谓时间相关的事件,就是设置超时时间:在指定时间内阻塞,时间到了就进入就绪状态。

    • 使用时间相关的事件,可以实现周期性的功能、可以实现超时功能。

  • 同步事件

    • 同步事件就是:某个任务在等待某些信息,别的任务或者中断服务程序会给它发送信息。

    • 怎么"发送信息"?方法很多

      • 任务通知(task notification)

      • 队列(queue)

      • 事件组(event group)

      • 信号量(semaphoe)

      • 互斥量(mutex)等

      • 这些方法用来发送同步信息,比如表示某个外设得到了数据。

cpp 复制代码
static void prvSetupHardware( void );

static volatile int flagIdleTaskrun = 0;  // 空闲任务运行时flagIdleTaskrun=1
static volatile int flagTask1run = 0;     // 任务1运行时flagTask1run=1
static volatile int flagTask2run = 0;     // 任务2运行时flagTask2run=1
static volatile int flagTask3run = 0;     // 任务3运行时flagTask3run=1

/*-----------------------------------------------------------*/

void vTask1( void *pvParameters )
{
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 1;
		flagTask2run = 0;
		flagTask3run = 0;
		
		/* 打印任务的信息 */
		printf("T1\r\n");				
	}
}

void vTask2( void *pvParameters )
{	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 0;
		flagTask2run = 1;
		flagTask3run = 0;
		
		/* 打印任务的信息 */
		printf("T2\r\n");				
	}
}

void vTask3( void *pvParameters )
{	
	const TickType_t xDelay5ms = pdMS_TO_TICKS( 5UL );		
	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 0;
		flagTask2run = 0;
		flagTask3run = 1;
		
		/* 打印任务的信息 */
		printf("T3\r\n");				

		// 如果不休眠的话, 其他任务无法得到执行
		vTaskDelay( xDelay5ms );
	}
}

void vApplicationIdleHook(void)
{
	flagIdleTaskrun = 1;
	flagTask1run = 0;
	flagTask2run = 0;
	flagTask3run = 0;	
	
	/* 故意加入打印让flagIdleTaskrun变为1的时间维持长一点 */
	//printf("Id\r\n");				
}

int main( void )
{
	
	prvSetupHardware();
	
	//动态创建任务1优先级是0
	xTaskCreate(vTask1, "Task 1", 1000, NULL, 0, NULL);
	//动态创建任务2优先级是0
	xTaskCreate(vTask2, "Task 2", 1000, NULL, 0, NULL);
	//动态创建任务3优先级是2
	xTaskCreate(vTask3, "Task 3", 1000, NULL, 2, NULL);

	/* 启动调度器 */
	vTaskStartScheduler();

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

此处的高优先级的先执行

以下是任务调度相关部分的代码

cpp 复制代码
/* Demo app includes. */
static void prvSetupHardware( void );

static volatile int flagIdleTaskrun = 0;  // 空闲任务运行时flagIdleTaskrun=1
static volatile int flagTask1run = 0;     // 任务1运行时flagTask1run=1
static volatile int flagTask2run = 0;     // 任务2运行时flagTask2run=1
static volatile int flagTask3run = 0;     // 任务3运行时flagTask3run=1

/*-----------------------------------------------------------*/

void vTask1( void *pvParameters )
{
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 1;
		flagTask2run = 0;
		flagTask3run = 0;
		
		/* 打印任务的信息 */
		printf("T1\r\n");				
	}
}

void vTask2( void *pvParameters )
{	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 0;
		flagTask2run = 1;
		flagTask3run = 0;
		
		/* 打印任务的信息 */
		printf("T2\r\n");				
	}
}

void vTask3( void *pvParameters )
{	
	const TickType_t xDelay5ms = pdMS_TO_TICKS( 5UL );		
	
	/* 任务函数的主体一般都是无限循环 */
	for( ;; )
	{
		flagIdleTaskrun = 0;
		flagTask1run = 0;
		flagTask2run = 0;
		flagTask3run = 1;
		
		/* 打印任务的信息 */
		printf("T3\r\n");				

		// 如果不休眠的话, 其他任务无法得到执行
		vTaskDelay( xDelay5ms );
	}
}

void vApplicationIdleHook(void)
{
	flagIdleTaskrun = 1;
	flagTask1run = 0;
	flagTask2run = 0;
	flagTask3run = 0;	
	
	/* 故意加入打印让flagIdleTaskrun变为1的时间维持长一点 */
	//printf("Id\r\n");				
}

int main( void )
{
	
	prvSetupHardware();
	
	//动态创建任务1优先级是0
	xTaskCreate(vTask1, "Task 1", 1000, NULL, 0, NULL);
	//动态创建任务2优先级是0
	xTaskCreate(vTask2, "Task 2", 1000, NULL, 0, NULL);
	//动态创建任务3优先级是2
	xTaskCreate(vTask3, "Task 3", 1000, NULL, 2, NULL);

	/* 启动调度器 */
	vTaskStartScheduler();

	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

这种调度策略表示的是不允许抢占,(使用这种方式编写代码最好是在做完一些事情之后放弃占用CPU的资源)

#define configUSE_PREEMPTION 1 是否支持抢占式调度 #define configUSE_TIME_SLICING 1 时间片轮转(默认的情况下应该是支持时间片轮转的)
这个是是否支持时间片轮转的的任务函数(PWM)波形

#define configIDLE_SHOULD_YIELD 1 这个表示的是空闲的任务应该空闲礼让他人,将GPU资源让给用户任务

这个是是否空闲任务礼让其他的CPU任务的PWM波形

这个是礼让(空闲的任务礼让其他的任务)

这个是不礼让(空闲的任务不礼让,也其他的任务一样抢占CPU的资源)

5.2 调度策略

  • 是否抢占?

  • #define configUSE_PREEMPTION 1 是否支持抢占式调度

  • 允许抢占时,是否允许时间片轮转?

  • #define configUSE_TIME_SLICING 1 时间片轮转(默认的情况下应该是支持时间片轮转的)

  • 允许抢占、允许时间片轮转时,空闲任务是否让步?

  • #define configIDLE_SHOULD_YIELD 1 这个表示的是空闲的任务应该空闲礼让他人,将GPU资源让给用户任务

相关推荐
m0_739312876 分钟前
【STM32】项目实战——OV7725/OV2604摄像头颜色识别检测(开源)
stm32·单片机·嵌入式硬件
嵌入式小章16 分钟前
基于STM32的实时时钟(RTC)教学
stm32·嵌入式硬件·实时音视频
TeYiToKu25 分钟前
笔记整理—linux驱动开发部分(9)framebuffer驱动框架
linux·c语言·arm开发·驱动开发·笔记·嵌入式硬件·arm
基极向上的三极管1 小时前
【AD】3-4 在原理图中放置元件
嵌入式硬件
徐嵌1 小时前
STM32项目---水质水位检测
stm32·单片机·嵌入式硬件
徐嵌2 小时前
STM32项目---畜牧定位器
c语言·stm32·单片机·物联网·iot
lantiandianzi2 小时前
基于单片机的老人生活安全监测系统
单片机·嵌入式硬件·生活
东胜物联2 小时前
探寻5G工业网关市场,5G工业网关品牌解析
人工智能·嵌入式硬件·5g
stm32发烧友2 小时前
基于STM32的智能家居环境监测系统设计
stm32·嵌入式硬件·智能家居
hairenjing112310 小时前
使用 Mac 数据恢复从 iPhoto 图库中恢复照片
windows·stm32·嵌入式硬件·macos·word