🐱作者:一只大喵咪1201
🐱专栏:《RTOS学习》
🔥格言:你只管努力,剩下的交给时间!
软件定时器 | 中断处理
- 🏀软件定时器
- ⚽守护任务
- 守护任务的调度
- ⚽使用软件定时器的函数
- ⚽基本使用
- 🏀中断处理
- ⚽两套API函数
- ⚽对比两套API函数
- 🏀总结
🏀软件定时器
如上图,我们在手机上添加闹钟时,需要指定时间、指定类型(一次性的,还是周期性的)、指定做什么事;还有一些过时的、不再使用的闹钟。
软件定时器和手机闹钟是类似的:
-
指定时间:启动定时器和运行回调函数,两者的间隔被称为定时器的周期(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 );
如上图定时器结构体,创建好定时器以后,调用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:
如上图,定时器的结构体如下,里面有一项 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) | xEventGroupSetBits | xEventGroupSetBitsFromISR |
xHigherPriorityTaskWoken 参数:
所有带FromISR
后缀的函数中,都有一个参数xHigherPriorityTaskWoken
,该参数的含义是:是否有更高优先级的任务被唤醒了。如果为pdTRUE
,则意味着后面要进行任务切换。
如上图,在xTimerResetFromISR
中会改变xHigherPriorityTaskWoken
的值,该函数调用结束以后,需要调用portYIELD_FROM_ISR
来判断是否要发起调度。
⚽对比两套API函数
普通任务写队列:
如上图代码,是普通任务向队列中写数据xQueueSend
的底层部分函数。
- 传参时,需要传入超时时间。
- 所有关于列表的判断放在一个死循环中,如果没有返回则会不断循环判断。
- 如果队列没有满可以写入数据,写入以后会立刻发起调度,好让更高优先级的任务抢占执行。
如上图是另外部分的函数。
- 队列满了时,无法写入数据。
- 如果超时时间为0,则该任务不等待直接错误返回。
- 如果超时时间没有达到,则将该任务放入阻塞链表。
- 再次发起调度,好让其他任务执行。
- 普通任务向队列中写数据时,有超时时间,死循环,还会在函数中直接发起任务调度。
ISR写队列:
如上图是ISR向队列中写数据xQueueSendFromISR
的底层函数。
- 传入的参数没有超时时间,而是是否发起调度标志。
- 没有死循环,只判断一遍后就返回。
- 队列没有满,将数据写入队列后,再将处于等待状态的任务放入到就绪链表,但是并不立刻发起调度,而是将发起调度标志位置一。
- 队列满了,直接错误返回,没有其他操作链表的多余动作。
- ISR向链表中写数据时,没有超时时间,没有死循环式的判断,数据写入以后仅记录需要发起调度,写入失败后直接返回。
差别:
xQueueSend | xQueueSendFromISR | |
---|---|---|
参数不同 | 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退出以后,由调度器发起任务调度。