15、FreeRTOS 软件定时器

文章目录

  • 一、什么是定时器?
    • [1.1 定时器的理解](#1.1 定时器的理解)
    • [1.2 软件定时器的特性](#1.2 软件定时器的特性)
  • [二、 软件定时器的上下文](#二、 软件定时器的上下文)
    • [2.1 守护任务](#2.1 守护任务)
    • [2.2 守护任务的调度](#2.2 守护任务的调度)
    • [2.3 回调函数](#2.3 回调函数)
  • 三、软件定时器的函数
    • [3.1 创建](#3.1 创建)
    • [3.2 删除](#3.2 删除)
    • [3.3 启动/停止](#3.3 启动/停止)
    • [3.5 修改周期](#3.5 修改周期)
    • [3.6 定时器ID](#3.6 定时器ID)
  • 四、案例
    • [4.1 一般使用](#4.1 一般使用)
    • [4.2 消除抖动](#4.2 消除抖动)

一、什么是定时器?

1.1 定时器的理解

简单可以理解为闹钟,到达指定一段时间后,就会响铃。

STM32 芯片自带硬件定时器,精度较高 ,达到定时时间后会触发中断 ,也可以生成 PWM 、输入捕获、输出 比较,等等,功能强大,但是由于硬件的限制,个数有限

软件定时器 也可以实现定时功能,达到定时时间后可调用回调函数,可以在回调函数里处理信息。

你可以设置闹钟

  • 在30分钟后让你起床工作
  • 每隔1小时让你例行检查机器运行情况

软件定时器也可以完成两类事情

  • "未来"某个时间点,运行函数

  • 周期性地运行函数

日常生活中我们可以定无数个"闹钟",这无数的"闹钟"要基于一个真实的闹钟。 在FreeRTOS里,我们也可以设置无数个"软件定时器",它们都是基于`系统滴答中断(Tick Interrupt)。

1.2 软件定时器的特性

我们在手机上添加闹钟时,需要指定时间、指定类型(一次性的,还是周期性的)、指定做什么事;还有 一些过时的、不再使用的闹钟。

使用定时器跟使用手机闹钟是类似的:

  • 指定时间:启动定时器和运行回调函数,两者的间隔被称为定时器的周期(period)。

  • 指定类型,定时器有两种类型:

    • 一次性(One-shot timers)
      • 这类定时器启动后,它的回调函数只会被调用一次;
      • 可以手工再次启动它,但是不会自动启动它。
    • 自动加载定时器(Auto-reload timers )
      • 这类定时器启动后,时间到之后它会自动启动它;
      • 这使得回调函数被周期性地调用。
  • 指定要做什么事,就是指定回调函数

实际的闹钟分为:有效、无效两类。软件定时器也是类似的,它由两种状态:

  • 运行(Running、Active):运行态的定时器,当指定时间到达之后,它的回调函数会被调用
  • 冬眠(Dormant):冬眠态的定时器还可以通过句柄来访问它,但是它不再运行,它的回调函数不会 被调用

定时器运行情况示例如下:

  • Timer1 :它是一次性的定时器,在t1启动,周期是6个Tick。经过6个tick后,在t7执行回调函数。 它的回调函数只会被执行一次,然后该定时器进入冬眠状态。
  • Timer2 :它是自动加载的定时器,在t1启动,周期是5个Tick。每经过5个tick它的回调函数都被执 行,比如在t6、t11、t16都会执行

二、 软件定时器的上下文

2.1 守护任务

要理解软件定时器API函数的参数,特别是里面的 xTicksToWait ,需要知道定时器执行的过程。

FreeRTOS中有一个Tick中断,软件定时器基于Tick来运行。在哪里执行定时器函数?第一印象就是在 Tick中断里执行:

  • 在Tick中断中判断定时器是否超时
  • **如果超时了,调用它的回调函数 **

FreeRTOS是RTOS,它不允许在内核、在中断中执行不确定的代码:如果定时器函数很耗时,会影响整 个系统。

所以,FreeRTOS中,不在Tick中断中执行定时器函数

在哪里执行?

在某个任务里执行,这个任务就是:RTOS Damemon Task,RTOS守护任务。以前被称 为 "Timer server",但是这个任务要做并不仅仅是定时器相关,所以改名为:RTOS Damemon Task

当FreeRTOS的配置项configUSE_TIMERS被设置为1时,在启动调度器时,会自动创建RTOS Damemon Task

我们自己编写的任务函数要使用定时器时,是通过"定时器命令队列"(timer command queue)守护任 务交互,如下图所示:

守护任务的优先级为:configTIMER_TASK_PRIORITY ;定时器命令队列的长度为 configTIMER_QUEUE_LENGTH

2.2 守护任务的调度

守护任务的调度,跟普通的任务并无差别。当守护任务是当前优先级最高的就绪态任务时,它就可以运 行。它的工作有两类:

  • 处理命令:从命令队列里取出命令、处理
  • 执行定时器的回调函数

能否及时处理定时器的命令、能否及时执行定时器的回调函数,严重依赖于守护任务的优先级。下面使 用2个例子来演示。

  • 例子1:守护任务的优先性级较低

    • t1:Task1处于运行态,守护任务处于阻塞态。 守护任务在这两种情况下会退出阻塞态切换为就绪态:命令队列中有数据、某个定时器超时了。 至于守护任务能否马上执行,取决于它的优先级
    • t2:Task1调用 xTimerStart() 要注意的是, xTimerStart() 只是把"start timer"的命令发给"定时器命令队列",使得守护任务 退出阻塞态。 在本例中,Task1的优先级高于守护任务,所以守护任务无法抢占Task1
    • t3:Task1执行完 xTimerStart() 但是定时器的启动工作由守护任务来实现,所以 xTimerStart() 返回并不表示定时器已经被启动 了。
    • t4:Task1由于某些原因进入阻塞态,现在轮到守护任务运行。 守护任务从队列中取出"start timer"命令,启动定时器。
    • t5:守护任务处理完队列中所有的命令,再次进入阻塞态。Idel任务时优先级最高的就绪态任务, 它执行。

    注意:假设定时器在后续某个时刻tX超时了,超时时间是"tX-t2",而非"tX-t4",从 xTimerStart() 函数被调用时算起

  • 例子2:守护任务的优先性级较高
  • t1:Task1处于运行态,守护任务处于阻塞态。 守护任务在这两种情况下会退出阻塞态切换为就绪态:命令队列中有数据、某个定时器超时了。 至于守护任务能否马上执行,取决于它的优先级。
  • t2:Task1调用 xTimerStart() 要注意的是, xTimerStart() 只是把"start timer"的命令发给"定时器命令队列",使得守护任务 退出阻塞态。 在本例中,守护任务的优先级高于Task1,所以守护任务抢占Task1,守护任务开始处理命令队 列。 Task1在执行 xTimerStart() 的过程中被抢占,这时它无法完成此函数。
  • t3:守护任务处理完命令队列中所有的命令,再次进入阻塞态。 此时Task1是优先级最高的就绪态任务,它开始执行。
  • t4:Task1之前被守护任务抢占,对 xTimerStart() 的调用尚未返回。现在开始继续运行次函 数、返回。
  • t5:Task1由于某些原因进入阻塞态,进入阻塞态。Idel任务时优先级最高的就绪态任务,它执 行

注意,定时器的超时时间是基于调用 xTimerStart() 的时刻tX,而不是基于守护任务处理命令的时刻 tY。假设超时时间是10个Tick,超时时间是"tX+10",而非"tY+10"

2.3 回调函数

定时器的回调函数的原型如下:

c 复制代码
void ATimerCallback( TimerHandle_t xTimer );

定时器的回调函数是在`守护任务中被调用的,守护任务不是专为某个定时器服务的,它还要处理其他定 时器。

所以,定时器的回调函数不要影响其他人:

  • 回调函数要尽快实行,不能进入阻塞状态
  • 不要调用会导致阻塞的API函数,比如 vTaskDelay()
  • 可以调用 xQueueReceive() 之类的函数,但是超时时间要设为0:即刻返回,不可阻塞

三、软件定时器的函数

根据定时器的状态转换图,就可以知道所涉及的函数

3.1 创建

要使用定时器,需要先创建它,得到它的句柄。 有两种方法创建定时器:动态分配内存、静态分配内存。函数原型 如下:

c 复制代码
/* 使用动态分配内存的方法创建定时器
* pcTimerName:定时器名字, 用处不大, 尽在调试时用到
* xTimerPeriodInTicks: 周期, 以Tick为单位
* uxAutoReload: 类型, pdTRUE表示自动加载, pdFALSE表示一次性
* pvTimerID: 回调函数可以使用此参数, 比如分辨是哪个定时器
* pxCallbackFunction: 回调函数
* 返回值: 成功则返回TimerHandle_t, 否则返回NULL
*/
TimerHandle_t xTimerCreate( const char * const pcTimerName,
                        const TickType_t xTimerPeriodInTicks,
                        const UBaseType_t uxAutoReload,
                        void * const pvTimerID,
                        TimerCallbackFunction_t pxCallbackFunction );
/* 使用静态分配内存的方法创建定时器
回调函数的类型是:
10.3.2 删除
动态分配的定时器,不再需要时可以删除掉以回收内存。删除函数原型如下:
定时器的很多API函数,都是通过发送"命令"到命令队列,由守护任务来实现。
如果队列满了,"命令"就无法即刻写入队列。我们可以指定一个超时时间 xTicksToWait ,等待一会。
10.3.3 启动/停止
启动定时器就是设置它的状态为运行态(Running、Active)。
停止定时器就是设置它的状态为冬眠(Dormant),让它不能运行。
涉及的函数原型如下:
* pcTimerName:定时器名字, 用处不大, 尽在调试时用到
* xTimerPeriodInTicks: 周期, 以Tick为单位
* uxAutoReload: 类型, pdTRUE表示自动加载, pdFALSE表示一次性
* pvTimerID: 回调函数可以使用此参数, 比如分辨是哪个定时器
* pxCallbackFunction: 回调函数
* pxTimerBuffer: 传入一个StaticTimer_t结构体, 将在上面构造定时器
* 返回值: 成功则返回TimerHandle_t, 否则返回NULL
*/
TimerHandle_t xTimerCreateStatic(const char * const pcTimerName,
                                TickType_t xTimerPeriodInTicks,
                                UBaseType_t uxAutoReload,
                                void * pvTimerID,
                                TimerCallbackFunction_t pxCallbackFunction,
                                StaticTimer_t *pxTimerBuffer );

回调函数的类型是:

c 复制代码
void ATimerCallback( TimerHandle_t xTimer );
typedef void (* TimerCallbackFunction_t)( TimerHandle_t xTimer );

3.2 删除

动态分配的定时器,不再需要时可以删除掉以回收内存。删除函数原型如下:

c 复制代码
/* 删除定时器
* xTimer: 要删除哪个定时器
* xTicksToWait: 超时时间
* 返回值: pdFAIL表示"删除命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerDelete( TimerHandle_t xTimer, TickType_t xTicksToWait );

定时器的很多API函数,都是通过发送"命令"到命令队列,由守护任务来实现。 如果队列满了,"命令"就无法即刻写入队列。我们可以指定一个超时时间 xTicksToWait ,等待一会。

3.3 启动/停止

启动定时器就是设置它的状态为运行态(Running、Active)

停止定时器就是设置它的状态为冬眠(Dormant)

让它不能运行。 涉及的函数原型如下:

C 复制代码
/* 启动定时器
* xTimer: 哪个定时器
* xTicksToWait: 超时时间
* 返回值: pdFAIL表示"启动命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerStart( TimerHandle_t xTimer, TickType_t xTicksToWait );
/* 启动定时器(ISR版本)
* xTimer: 哪个定时器
* pxHigherPriorityTaskWoken: 向队列发出命令使得守护任务被唤醒,
* 如果守护任务的优先级比当前任务的高,
* 则"*pxHigherPriorityTaskWoken = pdTRUE",
* 表示需要进行任务调度
* 返回值: pdFAIL表示"启动命令"无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerStartFromISR( TimerHandle_t xTimer,
								BaseType_t *pxHigherPriorityTaskWoken );
/* 停止定时器
* xTimer: 哪个定时器
* xTicksToWait: 超时时间
* 返回值: pdFAIL表示"停止命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerStop( TimerHandle_t xTimer, TickType_t xTicksToWait );
/* 停止定时器(ISR版本)
* xTimer: 哪个定时器
* pxHigherPriorityTaskWoken: 向队列发出命令使得守护任务被唤醒,
* 如果守护任务的优先级比当前任务的高,
* 则"*pxHigherPriorityTaskWoken = pdTRUE",
* 表示需要进行任务调度
* 返回值: pdFAIL表示"停止命令"无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerStopFromISR( TimerHandle_t xTimer,
								BaseType_t *pxHigherPriorityTaskWoken );

注意,这些函数的 xTicksToWait 表示的是,把命令写入命令队列的超时时间。命令队列可能已经满 了,无法马上把命令写入队列里,可以等待一会。 xTicksToWait 不是定时器本身的超时时间,不是定时器本身的"周期"。 创建定时器时,设置了它的周期(period)。 xTimerStart() 函数是用来启动定时器。假设调用 xTimerStart() 的时刻是tX,定时器的周期是n,那么在 tX+n 时刻定时器的回调函数被调用。 如果定时器已经被启动,但是它的函数尚未被执行,再次执行 xTimerStart() 函数相当于执行 xTimerReset() ,重新设定它的启动时间。

3.4 复位

从定时器的状态转换图可以知道,使用 xTimerReset() 函数可以让定时器的状态从冬眠态转换为运行 态,相当于使用 xTimerStart() 函数。

如果定时器已经处于运行态,使用 xTimerReset() 函数就相当于重新确定超时时间。假设调用 xTimerReset() 的时刻是tX,定时器的周期是n,那么 tX+n 就是重新确定的超时时间。

复位函数的原型如下:

C 复制代码
/* 复位定时器
* xTimer: 哪个定时器
* xTicksToWait: 超时时间
* 返回值: pdFAIL表示"复位命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerReset( TimerHandle_t xTimer, TickType_t xTicksToWait );
/* 复位定时器(ISR版本)
* xTimer: 哪个定时器
* pxHigherPriorityTaskWoken: 向队列发出命令使得守护任务被唤醒,
* 如果守护任务的优先级比当前任务的高,
* 则"*pxHigherPriorityTaskWoken = pdTRUE",
* 表示需要进行任务调度
* 返回值: pdFAIL表示"停止命令"无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerResetFromISR( TimerHandle_t xTimer,
								BaseType_t *pxHigherPriorityTaskWoken );

3.5 修改周期

从定时器的状态转换图可以知道,使用 xTimerChangePeriod() 函数,处理能修改它的周期外,还可以 让定时器的状态从冬眠态转换为运行态。

修改定时器的周期时,会使用新的周期重新计算它的超时时间。假设调用 xTimerChangePeriod() 函数 的时间tX,新的周期是n,则 tX+n 就是新的超时时间。

相关函数的原型如下:

c 复制代码
/* 修改定时器的周期
* xTimer: 哪个定时器
* xNewPeriod: 新周期
* xTicksToWait: 超时时间, 命令写入队列的超时时间
* 返回值: pdFAIL表示"修改周期命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerChangePeriod( TimerHandle_t xTimer,
                            TickType_t xNewPeriod,
                            TickType_t xTicksToWait );
/* 修改定时器的周期
* xTimer: 哪个定时器
* xNewPeriod: 新周期
* pxHigherPriorityTaskWoken: 向队列发出命令使得守护任务被唤醒,
* 如果守护任务的优先级比当前任务的高,
* 则"*pxHigherPriorityTaskWoken = pdTRUE",
* 表示需要进行任务调度
* 返回值: pdFAIL表示"修改周期命令"在xTicksToWait个Tick内无法写入队列
* pdPASS表示成功
*/
BaseType_t xTimerChangePeriodFromISR( TimerHandle_t xTimer,
                                        TickType_t xNewPeriod,
                                        BaseType_t *pxHigherPriorityTaskWoken );

3.6 定时器ID

定时器的结构体如下,里面有一项 pvTimerID ,它就是定时器 ID

怎么使用定时器ID,完全由程序来决定:

  • 可以用来标记定时器,表示自己是什么定时器
  • 可以用来保存参数,给回调函数使用

它的初始值在创建定时器时由 xTimerCreate() 这类函数传入,后续可以使用这些函数来操作:

  • 更新ID:使用 vTimerSetTimerID() 函数
  • 查询ID:查询pvTimerGetTimerID()函数 这两个函数不涉及命令队列,它们是直接操作定时器结构体。

函数原型如下

c 复制代码
/* 获得定时器的ID
* xTimer: 哪个定时器
* 返回值: 定时器的ID
*/
void *pvTimerGetTimerID( TimerHandle_t xTimer );
/* 设置定时器的ID
* xTimer: 哪个定时器
* pvNewID: 新ID
* 返回值: 无
*/
void vTimerSetTimerID( TimerHandle_t xTimer, void *pvNewID );

四、案例

4.1 一般使用

要使用定时器,需要做些准备工作:

c 复制代码
/* 1. 工程中 */
添加 timer.c
/* 2. 配置文件FreeRTOSConfig.h中 */
#define configUSE_TIMERS 				1 /* 使能定时器 */
#define configTIMER_TASK_PRIORITY 		31 /* 守护任务的优先级, 尽可能高一些 */
#define configTIMER_QUEUE_LENGTH 		5 /* 命令队列长度 */
#define configTIMER_TASK_STACK_DEPTH 	32 /* 守护任务的栈大小 */
/* 3. 源码中 */
#include "timers.h"

main函数中创建、启动了2个定时器:一次性的、周期

c 复制代码
static volatile uint8_t flagONEShotTimerRun = 0;
static volatile uint8_t flagAutoLoadTimerRun = 0;
static void vONEShotTimerFunc( TimerHandle_t xTimer );
static void vAutoLoadTimerFunc( TimerHandle_t xTimer );
/*-----------------------------------------------------------*/
##define mainONE_SHOT_TIMER_PERIOD pdMS_TO_TICKS( 10 )
##define mainAUTO_RELOAD_TIMER_PERIOD pdMS_TO_TICKS( 20 )
int main( void )
{
    TimerHandle_t xOneShotTimer;
    TimerHandle_t xAutoReloadTimer;
    prvSetupHardware();
    xOneShotTimer = xTimerCreate("OneShot", /* 名字, 不重要 */
                                mainONE_SHOT_TIMER_PERIOD, /* 周期 */
                                pdFALSE, /* 一次性 */
                                0, /* ID */
                                vONEShotTimerFunc /* 回调函数 */
                                );
    xAutoReloadTimer = xTimerCreate(
                                "AutoReload", /* 名字, 不重要 */
                                mainAUTO_RELOAD_TIMER_PERIOD, /* 周期 */
                                pdTRUE, /* 自动加载 */
                                0, /* ID */
                                vAutoLoadTimerFunc /* 回调函数 */
                                );
    if (xOneShotTimer && xAutoReloadTimer)
    {
        /* 启动定时器 */
        xTimerStart(xOneShotTimer, 0);
        xTimerStart(xAutoReloadTimer, 0);
        /* 启动调度器 */
        vTaskStartScheduler();
    }
	/* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
	return 0;
}

这两个定时器的回调函数比较简单:

c 复制代码
static void vONEShotTimerFunc( TimerHandle_t xTimer )
{
    static int cnt = 0;
    flagONEShotTimerRun = !flagONEShotTimerRun;
    printf("run vONEShotTimerFunc %d\r\n", cnt++);
}
static void vAutoLoadTimerFunc( TimerHandle_t xTimer )
{
    static int cnt = 0;
    flagAutoLoadTimerRun = !flagAutoLoadTimerRun;
    printf("run vAutoLoadTimerFunc %d\r\n", cnt++);
}

逻辑分析仪如下图所示:

运行结果如下图所示:

4.2 消除抖动

在嵌入式开发中,我们使用机械开关时经常碰到抖动问题:引脚电平在短时间内反复变化。

怎么读到确定的按键状态?

  • 连续读很多次,知道数值稳定:浪费CPU资源
  • 使用定时器:要结合中断来使用

对于第2种方法,处理方法如下图所示,按下按键后:

  • t1产生中断,这时不马上确定按键,而是复位定时器,假设周期时20ms,超时时间 为"t1+20ms"

  • 由于抖动,在t2再次产生中断,再次复位定时器,超时时间变为"t2+20ms"

  • 由于抖动,在t3再次产生中断,再次复位定时器,超时时间变为"t3+20ms"

  • 在"t3+20ms"处,按键已经稳定,读取按键值

main 函数中创建了一个一次性的定时器,从来处理抖动;创建了一个任务,用来模拟产生抖动。代码 如下:

c 复制代码
/*-----------------------------------------------------------*/
static void vKeyFilteringTimerFunc( TimerHandle_t xTimer );
void vEmulateKeyTask( void *pvParameters );
static TimerHandle_t xKeyFilteringTimer;
/*-----------------------------------------------------------*/
#define KEY_FILTERING_PERIOD pdMS_TO_TICKS( 20 )
int main( void )
{
    prvSetupHardware();
    xKeyFilteringTimer = xTimerCreate(
                                        "KeyFiltering", /* 名字, 不重要 */
                                        KEY_FILTERING_PERIOD, /* 周期 */
                                        pdFALSE, /* 一次性 */
                                        0, /* ID */
                                        vKeyFilteringTimerFunc /* 回调函数 */
                                        );
    /* 在这个任务中多次调用xTimerReset来模拟按键抖动 */
    xTaskCreate( vEmulateKeyTask, "EmulateKey", 1000, NULL, 1, NULL );
    /* 启动调度器 */
    vTaskStartScheduler();
    /* 如果程序运行到了这里就表示出错了, 一般是内存不足 */
    return 0;
}

模拟产生按键:每个循环里调用3次xTimerReset,代码如下:

c 复制代码
void vEmulateKeyTask( void *pvParameters )
{
    int cnt = 0;
    const TickType_t xDelayTicks = pdMS_TO_TICKS( 200UL );
    for( ;; )
    {
        /* 模拟按键抖动, 多次调用xTimerReset */
        xTimerReset(xKeyFilteringTimer, 0); cnt++;
        xTimerReset(xKeyFilteringTimer, 0); cnt++;
        xTimerReset(xKeyFilteringTimer, 0); cnt++;
        printf("Key jitters %d\r\n", cnt);
        vTaskDelay(xDelayTicks);
    }
}

定时器回调函数代码如下:

c 复制代码
static void vKeyFilteringTimerFunc( TimerHandle_t xTimer )
{
    static int cnt = 0;
    printf("vKeyFilteringTimerFunc %d\r\n", cnt++);
}

在人户函数中多次调用xTimerReset,只触发1次定时器回调函数,运行结果如下图所示:


文章是自己总结而记录,有些知识点没说明白的,请各位看官多多提意见,多多交流,欢迎大家留言
如果技术交流可以加以下群,方便沟通
QQ群:370278903
点击链接加入群聊【蜡笔小芯的嵌入式交流群】

相关推荐
XINVRY-FPGA25 分钟前
10CL016YF484C8G Altera FPGA Cyclone
嵌入式硬件·网络协议·fpga开发·云计算·硬件工程·信息与通信·fpga
Hero_11271 小时前
学习Stm32 的第一天
stm32·嵌入式硬件·学习
ye150127774554 小时前
DC6v-36V转3.2V1A恒流驱动芯片WT7017
单片机·嵌入式硬件·其他
闻道且行之11 小时前
嵌入式|VNC实现开发板远程Debian桌面
运维·debian·嵌入式
scilwb14 小时前
RoboCon考核题——scilwb
单片机
点灯小铭15 小时前
基于STM32单片机智能RFID刷卡汽车位锁桩设计
stm32·单片机·汽车·毕业设计·课程设计
bai54593617 小时前
STM32 软件I2C读写MPU6050
stm32·单片机·嵌入式硬件
有信仰17 小时前
操作系统——进程和线程
操作系统
逼子格19 小时前
AT89C52单片机介绍
单片机·嵌入式硬件·51单片机·硬件工程师·硬件工程师真题·at89c52·器件手册
生涯にわたる学び21 小时前
ARM 实操 流水灯 按键控制 day53
arm开发·嵌入式硬件