【RTOS学习】软件定时器 | 中断处理

🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
图

🏀软件定时器

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

软件定时器和手机闹钟是类似的:

  • 指定时间:启动定时器和运行回调函数,两者的间隔被称为定时器的周期(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 都会执行。

⚽守护任务

执行回调函数:

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

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

但是,FreeRTOS 是实时操作系统,它不允许在内核、在中断中执行不确定的代码:如果定时器函数很耗时,就会导致Tick中断迟迟无法结束,影响任务调度,进而会影响整个系统。

  • 所以,FreeRTOS 中,不在 Tick 中断中执行软件定时器的回调函数。

在哪里执行?有资格执行函数的必然是一个任务,这个任务就是:RTOS 守护任务。以前被称为"Timer server",但是这个任务要做并不仅仅是定时器相关,所以改名为:RTOS Damemon Task

  • 除此之外,对软件定时器的具体操作也是由守护任务完成的。

我们用户只是在使用软件定时器,如启动,停止,删除,复位,改变定时周期等等操作。但是只是在用户任务中调用相关的API函数,具体的细节操作并不是由用户完成的,而是由守护任务完成的。

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

void ATimerCallback(TimerHandle_t xTimer);

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

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

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

执行用户命令:

图
如上图所示,当用户调用操作软件定时器的API时,其实是给守护任务发生了一些指令,守护任务根据指令做出相应的操作,如启动定,停止定时器等。

用户是在用户任务中调用的API,操作是在守护任务中根据不同用户指令执行的,所以就涉及到了两个任务之间的通信。

  • 这里任务之间的通信使用的是队列。

用户任务将操作软件定时器的命令写入到命令队列中,守护任务从命令队列中读取命令并做出相应的操作。


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

  • 因为要创建命令队列,所以要配置configTIMER_QUEUE_LENGTH来指定命令队列长度。

  • 既然守护任务也是一个任务,所以要配置它的优先级configTIMER_TASK_PRIORITY以及栈大小configTIMER_TASK_STACK_DEPTH

守护任务的调度

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

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

能否及时处理定时器的命令、能否及时执行定时器的回调函数,严重依赖于守护任务的优先级。

守护任务的优先性级较低:

图
如上图:

  • 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()函数被调用时算起。

守护任务的优先性级较高:

图
如上图:

  • t1:Task1 处于运行态,守护任务处于阻塞态。
  • t2:Task1 调用xTimerStart()

此时守护任务的优先级高于 Task1,所以守护任务抢占 Task1,守护任务开始处理命令队列。

Task1 在执行xTimerStart()的过程中被抢占,这时它无法完成自己的函数。

  • t3:守护任务处理完命令队列中所有的命令,再次进入阻塞态。 此时 Task1 是优先级最高的就绪态任务,它开始执行。
  • t4:Task1之前被守护任务抢占,对xTimerStart()的调用尚未返回。现在开始继续运行此函数、返回。
  • t5:Task1 由于某些原因进入阻塞态,进入阻塞态。Idel 任务时优先级最高的就绪态任务,它执行。
  • 注意,定时器的超时时间是基于调用xTimerStart()的时刻 tX,而不是基于守护任务处理命令的时刻 tY。
  • 假设超时时间是 10 个 Tick,超时时间是"tX+10",而非"tY+10"。

⚽使用软件定时器的函数

创建:

/* 动态创建 */
TimerHandle_t xTimerCreate( 
				const char * const pcTimerName, 
                const TickType_t xTimerPeriodInTicks,
                const UBaseType_t uxAutoReload,
                void * const pvTimerID,
                TimerCallbackFunction_t pxCallbackFunction);
/* 静态创建 */
TimerHandle_t xTimerCreateStatic( 
				const char * const pcTimerName
                const TickType_t xTimerPeriodInTicks,
                const UBaseType_t uxAutoReload,
                void * const pvTimerID,
                TimerCallbackFunction_t pxCallbackFunction,
                StaticTimer_t * pxTimerBuffer);
  • pcTimerName:定时器名字, 用处不大, 尽在调试时用到 。
  • xTimerPeriodInTicks:定时周期, 以 Tick 为单位 。
  • uxAutoReload:定时器类型,pdTRUE表示自动加载, pdFALSE表示一次性 。
  • pvTimerID:回调函数可以使用此参数, 比如分辨是哪个定时器 。
  • pxCallbackFunction:回调函数。
  • 返回值:成功则返回定时器句柄,否则返回 NULL。
  • pxTimerBuffer:静态创建时,需要传入一个StaticTimer_t结构体,在上面构造定时器。

回调函数的类型是:

void ATimerCallback( TimerHandle_t xTimer ); 

tu
如上图定时器结构体,创建好定时器以后,调用xTimerCreate传入的参数都记录到了该结构体中,后面通过定时器句柄就可以访问到这些成员。

启动:

BaseType_t xTimerStart( TimerHandle_t xTimer, 
						TickType_t xTicksToWait);
  • xTimer:哪个定时器。
  • xTicksToWait:超时时间。
  • 返回值:pdFAIL表示"启动命令"在 xTicksToWait 个 Tick 内无法写入队列,pdPASS表示成功 。

这里的超时时间和前面创建定时器的定时周期不是一个东西,这里的超时时间是指用户任务向命令队列中写命令时的超时时间。

停止:

BaseType_t xTimerStop( TimerHandle_t xTimer, 
						TickType_t xTicksToWait );
  • xTimer:哪个定时器。
  • xTicksToWait:超时时间。
  • 返回值:pdFAIL表示"停止命令"在 xTicksToWait 个 Tick 内无法写入队列,pdPASS表示成功 。

修改定时周期:

BaseType_t xTimerChangePeriod( TimerHandle_t xTimer, 
 							TickType_t xNewPeriod, 
 T							ickType_t xTicksToWait );
  • xTimer:哪个定时器。
  • xNewPeriod:新周期。
  • xTicksToWait: 超时时间, 命令写入队列的超时时间。

复位:

BaseType_t xTimerReset( TimerHandle_t xTimer, 
						TickType_t xTicksToWait );
  • xTimer:哪个定时器。
  • xTicksToWait: 超时时间, 命令写入队列的超时时间。

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

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

删除:

BaseType_t xTimerDelete( TimerHandle_t xTimer, 
						TickType_t xTicksToWait );
  • xTimer:哪个定时器。
  • xTicksToWait: 超时时间, 命令写入队列的超时时间。

定时器ID:

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

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

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

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

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

⚽基本使用

图
首先定义这4个宏,然后才能使用软件定时器。

  • 守护任务的优先级要尽可能高,让定时器的命令或者回调函数及时被执行。

一般使用:

图
如上图所示,创建两个定时器,一个是一次性定时器,定时周期是10个Tick,另一个是周期定时器,定时周期是20个Tick。然后再创建一个任务,优先级是1。

图
如上图,在任务1中启动两个定时器,然后在while(1)中持续打印任务运行信息。

在一次性定时器的回调函数中,将一次性定时器运行标志位反转,并将运行次数加加,然后打印。

在周期性定时器回调函数中,将周期性定时器运行标志位反转,并将运行次数增加,然后打印。

  • 两个回调函数中都没有while(1)死循环。

图
如上图运行结果,先看右边串口,任务1在启动两个定时器以后,就不断打印自己的运行信息。其中一次性定时器运行信息打印了一次,周期性定时器运行信息打印了多次。

再看左边逻辑分析仪中标志位的变化,可以看到,一次性定时器的运行标志只发生了一次变化,周期性定时器的运行标志每隔20隔Tick就变化一次。

  • 定时器的回调函数并不是由任务1执行的,任务1中没有操作运行标志,更说明定时器的回调函数是由守护任务执行的。
  • 一次性定时器只起一次作用,而周期性定时器按定时周期起作用,因为回调函数中并没有死循环,定时器每起一次作用就调用一次回调函数。

消除抖动:

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

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

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

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

  • 在 t1 产生中断,这时不马上确定按键,而是复位定时器,假设周期时20ms,超时时间为"t1+20ms" 。
  • 由于抖动,在 t2 再次产生中断,再次复位定时器,超时时间变为"t2+20ms" 。
  • 由于抖动,在 t3 再次产生中断,再次复位定时器,超时时间变为"t3+20ms" 。
  • 在"t3+20ms"处,按键已经稳定,读取按键值 。

图
如上图代码所示,配置PA0作为按键,且开启按键外部中断,采样双边沿触发模式。

图
如上图,在中断函数中,使用定时器进行消抖,每发生一次按键中断,就打印一次中断发生的信息,并且计数发生次数,然后使用xTimerReset推迟超时时间,实现消抖。

图
如上图代码,将按键及中断初始化,然后创建一个一次性定时器,这里的超时时间设置为2秒,后面讲解原因。然后再创建一个任务。

图
如上图,在任务1中,只有一个死循环,是为了保证程序在一直执行,一次性定时器的回调函数中,打印回调函数的执行信息和执行次数。

  • 如果按键持续抖动,则会持续发生外部中断,定时器也会被不停复位,超时时间就在不断推后,回调函数始终得不到执行。
  • 当按键稳定后,定时器超时,回调函数执行,此时消抖完成,成功读取一次按键。

图
如上图,将程序使用软件仿真,打开模拟器中的GPIOA,如上图所示,其中第4步红色框中的勾用来就用来操作PA0引脚,来模拟按键中断产生。

  • 打上勾时,PA0的电平为高。
  • 没有勾时,PA0的电平为低。

所以我们只需要用鼠标不停电机第4步中的红色框位置,就可以模拟处按键过程中的抖动,当不再点击时,抖动消失,按键状态稳定。

  • 由于手动模拟,无法在20ms内完成,为了看到实验现象,将定时器的超时时间设置成2秒。

2秒钟内,每点击一次,模拟发生一次抖动,超时时间推后2秒。当不再点击时,按键稳定,等待定时器超时,一次按键读取完成。

图
如上图,本喵快速点击多次,产生了多次按键中断,定时器超时时间推迟了多次,最后消去了这几次抖动,读取一次按键状态。

🏀中断处理

在 RTOS 中,需要应对各类事件。这些事件很多时候是通过硬件中断产生,怎么处理中断呢?假设当前系统正在运行 Task1 时,用户按下了按键,触发了按键中断。这个中断的处理流程如下:

  • CPU 跳到固定地址去执行代码,这个固定地址通常被称为中断向量,这个跳转时硬件实现的 。
  • 执行代码做什么?
  • 保存现场:Task1 被打断,需要先保存 Task1 的运行环境,比如各类寄存器的值 。
  • 分辨中断、调用处理函数(这个函数就被称为 ISR(interrupt service routine)
  • 恢复现场:继续运行 Task1,或者运行其他优先级更高的任务 。

ISR 是在内核中被调用的,ISR 执行过程中,用户的任务无法执行。ISR要尽量快,否则:

  • 其他低优先级的中断无法被处理:实时性无法保证。
  • 用户任务无法被执行:系统显得很卡顿 。

如果这个硬件中断的处理,就是非常耗费时间呢?对于这类中断的处理就要分为 2 部分:

  • ISR:尽快做些清理、记录工作,然后触发某个任务。
  • 任务:更复杂的事情放在任务中处理。
  • 所以:需要 ISR 和任务之间进行通信。

要在 FreeRTOS 中熟练使用中断,有几个原则要先说明:

  • FreeRTOS 把任务认为是硬件无关的,任务的优先级由程序员决定,任务何时运行由调度器决定。
  • ISR 虽然也是使用软件实现的,但是它被认为是硬件特性的一部分,因为它跟硬件密切相关 。
  • 何时执行?由硬件决定。
  • 哪个 ISR 被执行?由硬件决定。
  • ISR 的优先级高于任务:即使是优先级最低的中断,它的优先级也高于任务。
  • 任务只有在没有中断的情况下,才能执行。

⚽两套API函数

图
如上图按键中断代码所示,在ISR中调用了xTimerReset函数写命令到命令队列,这是ISR和守护任务在进行通信,该函数的第二个参数是超时时间,如果设置为porMAX_DELAY,ISR就有阻塞的可能,这对于ISR是绝对不允许的,所以需要另外的API函数来和守护任务通信。

图
如上图,应该将用于通信的API换成xTimerResetFromISR,可以看到,该函数相比于之前多了一个FromISR后缀,这是专门用来进行ISR和任务之间的通信的。

  • 每一类任务间通信函数都有两套,一套是用来实现用户任务间通信的,没有FromISR后缀,另一套是带有后缀的。

两套API函数列表:

类型在任务中在ISR中
队列(queue)xQueueSendToBack
xQueueSendToFront
xQueueReceive
xQueueOverwrite
xQueuePeek
xQueueSendToBackFromISR
xQueueSendToFrontFromISR
xQueueReceiveFromISR
xQueueOverwriteFromISR
xQueuePeekFromISR
信号量(semaphore)xSemaphoreGive
xSemaphoreTake
xSemaphoreGiveFromISR
xSemaphoreTakeFromISR
事件组(event group)xEventGroupSetBitsxEventGroupSetBitsFromISR

xHigherPriorityTaskWoken 参数:

所有带FromISR后缀的函数中,都有一个参数xHigherPriorityTaskWoken,该参数的含义是:是否有更高优先级的任务被唤醒了。如果为pdTRUE,则意味着后面要进行任务切换。

图
如上图,在xTimerResetFromISR中会改变xHigherPriorityTaskWoken的值,该函数调用结束以后,需要调用portYIELD_FROM_ISR来判断是否要发起调度。

⚽对比两套API函数

普通任务写队列:
图
如上图代码,是普通任务向队列中写数据xQueueSend的底层部分函数。

  1. 传参时,需要传入超时时间。
  2. 所有关于列表的判断放在一个死循环中,如果没有返回则会不断循环判断。
  3. 如果队列没有满可以写入数据,写入以后会立刻发起调度,好让更高优先级的任务抢占执行。

图
如上图是另外部分的函数。

  1. 队列满了时,无法写入数据。
  • 如果超时时间为0,则该任务不等待直接错误返回。
  • 如果超时时间没有达到,则将该任务放入阻塞链表。
  • 再次发起调度,好让其他任务执行。
  • 普通任务向队列中写数据时,有超时时间,死循环,还会在函数中直接发起任务调度。

ISR写队列:

图

如上图是ISR向队列中写数据xQueueSendFromISR的底层函数。

  1. 传入的参数没有超时时间,而是是否发起调度标志。
  2. 没有死循环,只判断一遍后就返回。
  3. 队列没有满,将数据写入队列后,再将处于等待状态的任务放入到就绪链表,但是并不立刻发起调度,而是将发起调度标志位置一。
  4. 队列满了,直接错误返回,没有其他操作链表的多余动作。
  • ISR向链表中写数据时,没有超时时间,没有死循环式的判断,数据写入以后仅记录需要发起调度,写入失败后直接返回。

差别:

xQueueSendxQueueSendFromISR
参数不同xTicksToWait: 队列满的话阻塞多久没有xTicksToWait
唤醒等待的任务写队列后,会唤醒等待数据的任务写队列后,记录要唤醒等待数据任务的需求
调度如果被唤醒的任务优先级更高,即刻调度如果被唤醒的任务优先级更高,不会调度
只是记录下来表示:需要调度
阻塞如果队列满,可以阻塞如果队列满,不能阻塞

对比两套API,发现ISR的API比普通的API高效很多,除了没有超时时间外,就是不会直接发起调度,而是将需要调度的需求记录下来。等ISR退出后,执行portYIELD_FROM_ISR(xHigherPriorityTaskWoken)函数来发起调度。

  • 发起调度不能以内核的身份发起,只能以普通任务的身份去发起。

其实在ISR中发起调度也没有意义,因为中断的优先级比所有任务都高,在中断中发起调度,调度器任务无法处理这个调度请求,因为是此时代码仍然在执行中断函数,就会直接导致程序阻塞。


这种记录调度需求的处理方式叫"中断的延迟处理"(Deferring interrupt processing)。

图
如上面流程图所示:

  • t1:任务 1 运行,任务 2 阻塞。
  • t2:发生中断,该中断的 ISR 函数被执行,任务 1 被打断。
  • ISR 函数要尽快能快速地运行,它做一些必要的操作(比如清除中断),然后唤醒任务 2 。
  • t3:在创建任务时设置任务 2 的优先级比任务 1 高(这取决于设计者),所以 ISR 返回后,运行的是任务 2,它要完成中断的处理。
  • t4:任务 2 处理完中断后,进入阻塞态以等待下一个中断,任务 1 重新运行 。

这样做有多种好处:

  • 效率高:避免不必要的任务切换。
  • 让 ISR 更可控:中断随机产生,在 API 中进行任务切换的话,可能导致问题更复杂 。
  • 可移植性好。

🏀总结

软件定时器是经常使用的一种定时方式,要清楚的意识到守护任务的存在,以及守护任务所做的工作,用户操作软件定时器的本质就是:用户任务和守护任务通过命令队列进行通信。

要知道中断的优先级是高于所有用户任务的,而且在中断服务函数中不会立刻发起任务调度,而是记录下需要调度的需求,等ISR退出以后,由调度器发起任务调度。

  • 36
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 31
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一只大喵咪1201

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值