当前位置: 首页 > news >正文

FreeRTOS入门知识(初识RTOS任务调度)(三)

文章目录

  • 摘要
  • 一、浅识FreeRTOS快速入门课程
    • 1.2、FerrRTOS 与裸机区别
      • 1.2.7 任务状态
        • 时间片
        • 任务的状态分析
        • 任务嵌套理解
        • FreeRTOS中延时函数分析(用于阻塞?不拘泥于阻塞)
      • 1.2.8 空闲任务和钩子函数
      • 1.2.9 任务调度算法



摘要

持续更新中

一、浅识FreeRTOS快速入门课程

1.2、FerrRTOS 与裸机区别

1.2.7 任务状态

时间片
int main( void )
{#ifdef DEBUGdebug();
#endifprvSetupHardware();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);/* Start the scheduler. */vTaskStartScheduler();/* Will only get here if there was not enough heap space to create theidle task. */return 0;
}

在实际运行中,是以滴答定时器为该系统的时间基准,并且利用调度器进行相关执行。

在上述例子子,最先执行的是任务3,然后是任务1、任务2、任务3,切换的原因是按照调度器的思路执行,什么时候切换是按照时钟中断进行的。并且还需要判断是否切换任务,并且需要记住的是FreeRTOS每个任务运行的最基本时间是1ms。关于如何使用滴答定时器,在前面文章也有详细分析如何配置。

FreeRTOS的时间片长度通常是由系统时钟节拍决定的,也就是我们常说的Tick,在默认情况下,一个时间片就是1个Tick,也就是1ms。这个地方我觉得应该是规定,这是因为在FreeRTOS中这个时间好像是约定俗成的,就是在我们配置的时候要配置为1ms。(configTICK_RATE_HZ=1000 时,时间片为 ​1ms

时间片调度机制(同优先级任务)​,

当多个任务优先级相同,并且启用了时间片调度,那么当一个任务运行满一个时间片的时候(1ms),即时该任务没有执行完,也会被强制切换,至于这是为什么?这是FreeRTOS的本身机制,就是要这样,就是要和裸机的执行逻辑要求区别才有意义,以一种新的架构去执行不同任务。
例如:两个优先级相同的任务 Task1 和 Task2 会轮流执行各 1ms,无论任务是否完成逻辑。

触发任务切换的其他条件:

即使时间片未耗尽,任务也可能被切换:

  • 主动放弃 CPU​:
    任务调用 taskYIELD()vTaskDelay() 等函数时,会立即触发切换(即使时间片未用完)。

  • 高优先级任务就绪​:
    若有更高优先级任务进入就绪态,当前任务会立即被抢占,无论是否用完时间片 。

  • 任务进入阻塞状态​:
    若任务因等待信号量、队列或延时操作(如 vTaskDelay())进入阻塞态,会立刻切换至其他任务。

时间片未耗尽时不被切换的场景:

在该批次优先级下仅有一个任务,也就是说没有任务可以切换,那么不就是只能就继续执行。

没有使用时间片调度。

在FreeRTOS中
认时间片是固定的1个系统节拍,仅支持同优先级任务轮转,并且无法自定义单个任务的时间片,也就说一个时间片只能是1个系统节拍。

ThreadX
支持在创建任务时显式指定时间片长度。例如,可为关键任务分配更长的时间片(如5ms),提升其单次执行时长。

注意这两个的区别:
FreeRTOS只能统一调整所有同优先级任务的时间片长度。FreeRTOS ​不允许为不同任务分配独立的时间片长度

但是ThreadX可单独设置单个任务时间片。

任务的状态分析

运行就是runing状态,一个时刻只能运行一个任务, 那么其他任务就需要给一个状态,这个非运行状态还需要进行一点细分,这是因为不运行状态,有可能是一直不能运行,还有可能是准备好了,所以还需要进行细分:

ready:已经准备好了,随时运行,属于是准备好了就绪状态。随时运行,但是还没有轮到我。

blocked:阻塞,就是卡住了。以例子为讲解,就是任务等到某些事情发生才能继续执行,这个等待某些事情可以是一些标志位等,有点像任务暂停?但是又跟暂停不一样,这个是只要条件到了,就可以再次触发回到就绪状态,就是随时可以执行。阻塞态(Blocked):被动等待事件任务因等待外部事件时间条件而主动进入阻塞态,等待信号量、消息队列、事件标志(同步事件),调用延时函数(如 vTaskDelay())等待时间到期(定时事件)。在等待事件的过程中,这个任务就处于阻塞状态(Blocked)。

阻塞状态是事件驱动型:任务因等待外部条件暂停,内核自动监控并恢复,适用于需要同步或定时的场景。

suspend:挂起,就是先不执行。这个是属于暂停状态,主动休息状态,或者是被命令休息。就是我不干了。挂起态(Suspended):主动暂停任务, 任务被移入挂起列表,完全脱离调度器管理,内核不在检查其状态。只能通过vTaskResume()xTaskResumeFromISR() 手动恢复,且恢复后直接进入就绪状态。场景:日志记录、调试时临时冻结任务。

挂起态是人工干预​:任务被显式暂停且不受内核管理,需手动恢复,适合长期暂停或调试。 是主动休息或者是被动休息。

![[Pasted image 20250809104249.png]]

通过这个状态跳转图其实可以看到很多东西,能理解很多FreeRTOS的开发思路。能明白为什么会调用API,以及如何调用API。

在任务处于Runing状态的时候,我们可以操作将部分任务给挂起,从这个状态图中,只要能连接到挂起状态的状态都可以被挂起,甚至我可以让我自己暂停。

同理处于挂起状态的任务,必须要主动进行回复,也就是我可以在某些任务中嵌套恢复的API vTaskResume(),这是因为要想让挂起的任务动作,只能通过这个API恢复,只有这一条路。

并且想要让任务执行Runing,必须要先处于Ready状态,否则都是无稽之谈。

而处于阻塞状态的 任务必须依靠事件进行跳转 到Ready,还有就是每一个状态,都是通过链表进行管理的,至于什么事链表,在之前文章已经详细分析。
数据结构—链表结构体、指针深入理解(三)_void inserhead(node *node,elemtype value){ node *p-CSDN博客

换句话说,我们在开发过程中,可以随便的在不同任务中调用相关控制函数,从而实现了复杂性。复杂的原因就是这里,并且任务之间可以控制任务就是因为使用了每一个任务的句柄。

TaskHandle_t xHandleTask1;
TaskHandle_t xHandleTask3;

如果没有这个句柄,外部的任务根本无法控制。

那这个地方其实就又引入一个新的问题,句柄是全局变量,那么频繁的使用这些全局句柄是否会带来影响?或者说应该怎么优化?

这个地方跟裸机开发的思维还是一样的, 1、使用static关键字,模块化编程思维。此外还有一些FreeRTOS中特有的一些思路如:
1、替代全局句柄的通信机制,​任务通知(Task Notifications)​​ 使用任务通知代替句柄操作,无需全局句柄即可唤醒或传递数据。

2、句柄存储优化,将句柄存入专用结构体,结合互斥锁保护。

3、动态查询替代静态句柄,​按任务名查询句柄​。

  • 避免滥用全局句柄​:优先使用任务通知、事件组等内核机制替代直接句柄操作。

  • 必要时的保护​:若必须全局使用,需通过互斥锁(xSemaphoreCreateMutex())或临界区(taskENTER_CRITICAL())保护。

关于这些内容,后续会详细分析。一定要深入的理解FreeRTOS,才能在开发过程中解决很多问题,遇到Bug才不会慌张,不然你无法定位到Bug,只能在应用层解决一些问题,并不能解决深入的问题。 都是以结果为导向的解决问题。

任务嵌套理解

![[Pasted image 20250809145707.png]]

void Task1Function(void * param)
{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);flag = 1;  /* 标志位 */}if(t > tStart + 20){vTaskResume(xHandleTask3);}}
}void Task2Function(void * param)
{while (1){task1flagrun = 0;task2flagrun = 1;task3flagrun = 0;printf("2");vTaskDelay(10);   /* 阻塞状态。 */}
}void Task3Function(void * param)
{while (1){task1flagrun = 0;task2flagrun = 0; task3flagrun = 1;printf("3");}
}

通过此次实验可以明显看出,Task1可以灵活处理Task3,可以让他挂起也可以让他恢复。同时自己可以控制自己让自己处于阻塞,例如使用延时函数,但是延时函数结束,阻塞状态就结束。那么是否可以让别人控制是否阻塞?

肯定可以,但是目前还没有掌握这种思路,因为:任务 A 控制任务 B 的阻塞状态主要通过任务间通信与同步机制实现,接下里理解了这种机制,自然也就明白了如何实现控制。理论上来说,任何任务都可以控制任何任务的任何状态。

通过这个实验,只是对任务的相互控制有一个简单的认识。

真正复杂的是在业务逻辑之间的嵌套,以及如何设计实现任务之间的循环嵌套。

FreeRTOS中延时函数分析(用于阻塞?不拘泥于阻塞)
void vTask1( void *pvParameters )
{const TickType_t xDelay50ms = pdMS_TO_TICKS( 50UL );TickType_t xLastWakeTime;int i;/* 获得当前的Tick Count */xLastWakeTime = xTaskGetTickCount();for( ;; ){flag = 1;/* 故意加入多个循环,让程序运行时间长一点 */for (i = 0; i <5; i++)printf( "Task 1 is running\r\n" );#if 1		vTaskDelay(xDelay50ms);
#else		vTaskDelayUntil(&xLastWakeTime, xDelay50ms);
#endif		}
}

事件驱动型和中断驱动型在裸机中也是存在的,但是在RTOS中,只不过多了几个枷锁,或者说执行的更加规范,

任务的执行一定是按照优先级和调度策略的。这是逻辑中不一样的,裸机中事件来了就能直接驱动。在FreeRTOS中,​中断和事件的到来确实是唤醒任务的契机,并且唤醒只是让他处于Ready状态。但它们不能直接决定任务是否立即执行任务的实际执行由调度器根据优先级和调度策略综合决策。

以两个任务再次理解FreeRTOS内核。


void vTask1( void *pvParameters )
{const TickType_t xDelay50ms = pdMS_TO_TICKS( 50UL );TickType_t xLastWakeTime;int i;	/* 获得当前的Tick Count */xLastWakeTime = xTaskGetTickCount();			for( ;; ){flag = 1;		/* 故意加入多个循环,让程序运行时间长一点 */for (i = 0; i <5; i++)printf( "Task 1 is running\r\n" );
#if 1		vTaskDelay(xDelay50ms);
#else		vTaskDelayUntil(&xLastWakeTime, xDelay50ms);
#endif		}
}void vTask2( void *pvParameters )
{for( ;; ){flag = 0;printf( "Task 2 is running\r\n" );}
}int main( void )
{prvSetupHardware();/* Task1的优先级更高, Task1先执行 */xTaskCreate( vTask1, "Task 1", 1000, NULL, 2, NULL );xTaskCreate( vTask2, "Task 2", 1000, NULL, 1, NULL );/* 启动调度器 */vTaskStartScheduler();/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */return 0;
}

在上述两个任务创建函数中,我们可以明确的看出,这两个任务函数都是死循环,这也是FreeRTOS中的核心,区别于裸机开发。并且FreeRTOS调度器是无法处理任务“正常返回”的情况,因为任务的上下文(栈和TCB)会处于未定义状态。那怎么实现任务轮询呐?依靠的是调度器,这不正是我们使用FreeRTOS的原因,正是因为这个原因,我们才借助于系统开发。

如果我们必须终止任务,那么也可以就需要显示删除自身,例如初始化任务,避免死循环,一直初始化。
删除自身:

 vTaskDelete(NULL);  // 删除当前任务,传递 NULL 表示删除自身

但是还需要明白vTask1中延时函数:

vTaskDelay(xDelay50ms);

vTaskDelayUntil(&xLastWakeTime, xDelay50ms);

void vTask1( void *pvParameters )
{const TickType_t xDelay50ms = pdMS_TO_TICKS( 50UL );TickType_t xLastWakeTime;int i;	/* 获得当前的Tick Count */xLastWakeTime = xTaskGetTickCount();			for( ;; ){flag = 1;		/* 故意加入多个循环,让程序运行时间长一点 */for (i = 0; i <5; i++)printf( "Task 1 is running\r\n" );
#if 1		vTaskDelay(xDelay50ms);
#else		vTaskDelayUntil(&xLastWakeTime, xDelay50ms);
#endif		}
}

在任务1中,执行到延时函数vTaskDelay(xDelay50ms);任务右执行状态,被调整为挂起,从就绪列表给移除,避免被调度器选中执行,并且加入延时列表,这是因为内核需要计算唤醒时间,并将任务按唤醒时间排序插入延时列表(xDelayedTaskListpxOverflowDelayedTaskList)。恢复调度与切换​,调用 xTaskResumeAll() 恢复调度器,并触发任务切换(portYIELD_WITHIN_API()),让出 CPU 给其他就绪任务。并且该转换状态是已经实现了,不需要开发者在关注。

阻塞态 → 就绪态

延时到期后,内核将任务从延时列表移除,并插入就绪列表,状态自动变为就绪(Ready)。

就绪态 → 运行态

调度器根据优先级自动分配 CPU:若任务优先级最高,则立即抢占当前任务;否则等待调度点(如时间片结束)。

任务是不会退出,但是会在调度器中切换,该栈空间还是会存在的。函数调用栈被完整保留在任务私有堆栈中,等待唤醒后恢复。相当于任务只是暂停。

并且恢复以后唤醒后任务vTaskDelay 的下一行代码继续执行​(即 for(;;) 循环的末尾 }),而非重新开始函数或退出循环。

并且vTaskDelay(xDelay20ms);内部会对该任务函数进行上述描述的处理。

vTaskDelay(xDelay20ms);延时函数的作用是:

从进入该延时函数到退出该延时函数是固定的,

在这里插入图片描述

也就是说只要在本次任务执行完成,进入该延时函数,那么就必须要固定的绝对的时间才能退出来,也就是固定时间阻塞。

vTaskDelayUntil(&xLastWakeTime, xDelay20ms);延时函数的作用是:

如果我想让该任务周期性执行,固定时间执行。那么就需要使用上述的延时函数。

在这里插入图片描述

并且只要在任务1执行范围内,我们调用该函数vTaskDelayUntil(&xLastWakeTime, xDelay20ms);就可以,因为这个延时函数只是固定了终点的时间和上一次的终点时间,也就是说只要在这两个时间点之间执行就行,相当于是告诉调度器,我上一次是什么时候,而我下一次应该是什么时候。固定间隔执行。

在这里插入图片描述

通过上述的逻辑分析仪也能看出来时间。

并且这个函数vTaskDelayUntil(&xLastWakeTime, xDelay20ms);记录的是每次的唤醒时间,因此每次都是这一次唤醒时间和下一次的唤醒时间都是20ms。

在这里插入图片描述

从这三个图片中可以明显看出,不管Task1的执行时间是多少,任务1就是每隔20ms时间执行一次。并且一定是这一次的唤醒时间和下一次的唤醒时间。

vTaskDelayUntil(&xLastWakeTime, xDelay20ms); xLastWakeTime会自动更新的。

并且还有一些隐藏的细节:

在这里插入图片描述

就是在任务1执行完成以后,关于任务2和任务3的启动顺序是不一样的。

目前尝试分析并没有分析出来什么原因。暂时先放在这里。

**==优先级任务相同的情况下,是交替执行。

1.2.8 空闲任务和钩子函数

在创建任务是有返回值的,如果创建成功就会返回dpPASS

失败:errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY(失败原因只有内存不足)

当我们直接创建两个任务的时候,一个是高优先级,一个是低优先级,那么如果没有其他附加条件,高优先级会一直执行,而低优先级不会执行。这是因为FreeRTOS中,任务调度式基于优先级的抢占式调度,高优先的任务总是优先获取CPU使用权,低优先级任务的执行依赖于高优先级任务主动释放CPU资源,所以如果高优先级的不主动释放CPU资源,那么低优先级的任务是无法执行的。

低优先级任务的三种核心执行场景:

1、高优先级任务进入阻塞态(Blocked State)​

当高优先级任务调用阻塞式函数(如 vTaskDelay()xQueueReceive() 等)时,会主动让出 CPU 使用权,进入阻塞态等待事件(如延时结束、信号量释放)。此时调度器会从就绪态任务中选择最高优先级的任务执行。若当前无更高优先级任务就绪,低优先级任务即可获得 CPU 时间

存在一种情况,低优先级任务内部创建了一个高优先级任务,然后高优先级任务内部又没有阻塞,那么从此以后就再也不会执行低优先级任务了。

2、高优先级任务主动挂起(Suspended)或删除自身

若高优先级任务调用 vTaskSuspend() 挂起自身或 vTaskDelete() 删除自身,其不再参与调度。调度器会从剩余就绪任务中选择最高优先级任务(可能是低优先级任务)运行。相当于内核不在追踪该高优先级任务,内核直接放弃这个任务了,所以低优先级任务就有机会了。

3、所有高优先级任务均未就绪时(空闲窗口)​

当所有用户任务均阻塞或挂起时,FreeRTOS 会运行优先级为 0 的空闲任务​(Idle Task)。此时若低优先级任务处于就绪态(如延时结束),调度器会将其从就绪态切换至运行态。

  • 1.无阻塞则无执行

    • 若高优先级任务始终未阻塞(如死循环中无延时或同步操作),低优先级任务永远无法执行。这是抢占式调度的核心特性。
  • 2.​状态转换与调度触发

    • 低优先级任务需处于就绪态​(Ready)才能被调度。若其因等待事件而阻塞,需等待条件满足(如延时结束)后重回就绪链表,才可能被选中。
  • 3.​中断的影响

    • 中断服务程序(ISR)可抢占任何任务(包括高优先级任务)。若中断中释放了信号量或消息,可能唤醒阻塞的低优先级任务,但该任务仍需等待高优先级任务释放 CPU 后才能运行。
场景触发条件调度行为
高优先级任务阻塞调用 vTaskDelay()、等待队列/信号量等调度器选择就绪链表中最高优先级任务(可能是低优先级)运行
高优先级任务挂起或删除调用 vTaskSuspend()vTaskDelete()低优先级任务作为当前最高优先级就绪任务被选中
所有高优先级任务未就绪系统进入空闲状态空闲任务运行,低优先级任务若就绪则可能被调度
void Task2Function(void * param)
{while (1){task1flagrun = 0;task2flagrun = 1;taskidleflagrun = 0;printf("2");vTaskDelay(2);vTaskDelete(NULL);}
}

对于该任务自杀以后,需要空闲任务去释放内存,不然是没有任务会进行清理的,就会导致内存用完,报错。

但是在这种情况下:

void Task1Function(void * param)
{TaskHandle_t xHandleTask2;BaseType_t xReturn;while (1){task1flagrun = 1;task2flagrun = 0;taskidleflagrun = 0;printf("1");xReturn = xTaskCreate(Task2Function, "Task2", 1024, NULL, 2, &xHandleTask2);if(xReturn != pdPASS)printf("xTaskCreate err\r\n");vTaskDelete(xHandleTask2);}
}

删除以后,直接就会清除内存。为什么呐?

我们知道如果任务2没有阻塞,那么任务1再也不会被执行。但是我们在创建任务2的时候设置了阻塞。

那这就不得不带来一个疑问,空闲任务到底是什么时候有机会执行内存清空的?

FreeRTOS的SysTick中断(通常1ms一次)会强制触发任务调度器检查任务状态。即使Task1未主动阻塞(如无vTaskDelay),​SysTick中断仍会暂停Task1的执行,使调度器有机会切换到空闲任务(Idle Task)。 存疑?

在创建空闲任务以后,注意他的优先级是0,要给他运行的机会,不然永远就不会被运行。

在这里插入图片描述

里面不能包含死循环,不然就不能干其他事情了,就不会清空内存了。

所以说这一点还是要注意的。

1.2.9 任务调度算法

configUSE_PREEMPTION 实现是否是抢占式调度

关于是否抢占式调度式可以配置的,一种是抢占式调度,另外一种就是非抢占式调度。抢占式很简单,就是高优先级的执行,然后自己主动阻塞释放CPU资源,给低优先级的任务执行空间。而非抢占式就是很简单,只要我不释放资源,谁都抢不走我。这种也被称为合作调度模式,这样的写法就是每个任务都要配置延时函数,主动的释放相关资源。理解起来还是简单的。在实际应用中,一般都是使用抢占式调度。

configUSE_TIME_SLICING 可抢占的前提下,同优先级的任务是否轮流执行

轮流执行,就是很简单,你执行一次,我执行一次,大家轮流执行。

不轮流执行,就是我要一直执行,除非我主动释放。

在这里插入图片描述

可以看出,当最高优先级的任务到来以后,肯定是执行最高优先级的任务,但是高优先级任务执行完成以后,这些同优先级的任务除了空闲任务会主动释放资源,其他的任务都是不会释放任务,那么就会一直霸占CPU资源。

configIDLE_SHOULD_YIELD 空闲任务是否让步于用户任务

空闲任务低人一等,每执行一次循环,就看看是否主动让位给用户任务,上面就是只执行一次。

空闲任务跟用户任务一样,大家轮流执行,没有谁更特殊

空闲任务如果礼让别人就是相当于自己主动触发一次调度任务函数。

不礼让的波形:

在这里插入图片描述

礼让的波形:

在这里插入图片描述

可以明显看出两者的区别。

如果觉得我的内容对您有帮助,希望不要吝啬您的赞和关注,您的赞和关注是我更新优质内容的最大动力。



专栏介绍

《嵌入式通信协议解析专栏》
《PID算法专栏》
《C语言指针专栏》
《单片机嵌入式软件相关知识》
《FreeRTOS源码理解专栏》
《嵌入式软件分层架构的设计原理与实践验证》



文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。

【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。

感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言,笔者一定知无不言,言无不尽。

http://www.dtcms.com/a/323391.html

相关文章:

  • AVL树的四种旋转
  • 【Python 语法糖小火锅 · 第 4 涮】
  • 资深全栈工程师面试题总结
  • 【牛客刷题】小红的区间删除
  • 第16届蓝桥杯Scratch选拔赛初级及中级(STEMA)2024年11月24日真题
  • Linux之shell脚本篇(四)
  • SQL 172 未完成试卷数大于1的有效用户
  • 9. 堆和栈有什么区别
  • 01数据结构-图的邻接矩阵和遍历
  • 从零开始理解编译原理:设计一个简单的编程语言
  • svg 图片怎么设置 :hover 时变色
  • 交 换
  • sigaction 中 sa_handler = SIG_IGN 的深度解析与应用实践
  • day14 - html5
  • 2025年TOP5服装类跟单软件推荐榜单
  • 复杂正则语句(表格数据)解析
  • CentOS7运行AppImage
  • 历史数据分析——首旅酒店
  • 电子电气架构 --- 48V车载供电架构
  • ubuntu修改密码
  • 基于dynamic的Druid 与 HikariCP 连接池集成配置区别
  • 论文阅读 2025-8-3 [FaceXformer, RadGPT , Uni-CoT]
  • 数论——约数之和、快速乘
  • 新手入门:Git 初次配置与 Gitee 仓库操作全指南 —— 从环境搭建到代码推送一步到位
  • 【unitrix数间混合计算】2.9 小数部分特征(t_non_zero_bin_frac.rs)
  • Java基础-完成局域网内沟通软件的开发
  • day 16 stm32 IIC
  • day 35_2025-08-09
  • 202506 电子学会青少年等级考试机器人四级器人理论真题
  • Java -- 日期类-第一代-第二代-第三代日期