如上图,我们在手机上添加闹钟时,需要指定时间、指定类型(一次性的,还是周期性的)、指定做什么事;还有一些过时的、不再使用的闹钟。
软件定时器和手机闹钟是类似的:
指定时间:启动定时器和运行回调函数,两者的间隔被称为定时器的周期(period)。
指定类型,定时器有两种类型:
一次性(One-shot timers):这类定时器启动后,它的回调函数只会被调用一次; 可以手工再次启动它,但是不会自动启动它。
自动加载定时器(Auto-reload timers ):这类定时器启动后,时间到之后它会自动启动它; 这使得回调函数被周期性地调用。
定要做什么事,就是指定回调函数。
实际的闹钟分为:有效、无效两类。软件定时器也是类似的,它由两种状态:
执行回调函数:
FreeRTOS 中有一个 Tick 中断,软件定时器基于 Tick 来运行。在哪里执行定时器回调函数?第一印象就是在 Tick 中断里执行:
但是,FreeRTOS 是实时操作系统,它不允许在内核、在中断中执行不确定的代码:如果定时器函数很耗时,就会导致Tick中断迟迟无法结束,影响任务调度,进而会影响整个系统。
- 所以,FreeRTOS 中,不在 Tick 中断中执行软件定时器的回调函数。
在哪里执行?有资格执行函数的必然是一个任务,这个任务就是:RTOS 守护任务。以前被称为"Timer server",但是这个任务要做并不仅仅是定时器相关,所以改名为:RTOS Damemon Task。
- 除此之外,对软件定时器的具体操作也是由守护任务完成的。
我们用户只是在使用软件定时器,如启动,停止,删除,复位,改变定时周期等等操作。但是只是在用户任务中调用相关的API函数,具体的细节操作并不是由用户完成的,而是由守护任务完成的。
定时器的回调函数的原型如下:
void ATimerCallback(TimerHandle_t xTimer);
定时器的回调函数是在守护任务中被调用的,守护任务不是专为某个定时器服务的,它还要处理其他定时器。
所以,定时器的回调函数不要影响其他人:
vTaskDelay()
。xQueueReceive()
之类的函数,但是超时时间要设为 0:即刻返回,不可阻塞 。执行用户命令:
如上图所示,当用户调用操作软件定时器的API时,其实是给守护任务发生了一些指令,守护任务根据指令做出相应的操作,如启动定,停止定时器等。
用户是在用户任务中调用的API,操作是在守护任务中根据不同用户指令执行的,所以就涉及到了两个任务之间的通信。
- 这里任务之间的通信使用的是队列。
用户任务将操作软件定时器的命令写入到命令队列中,守护任务从命令队列中读取命令并做出相应的操作。
当 FreeRTOS 的配置项 configUSE_TIMERS
被设置为 1 时,在启动调度器时,会自动创建 RTOS Damemon Task。
因为要创建命令队列,所以要配置configTIMER_QUEUE_LENGTH
来指定命令队列长度。
既然守护任务也是一个任务,所以要配置它的优先级configTIMER_TASK_PRIORITY
以及栈大小configTIMER_TASK_STACK_DEPTH
。
守护任务的调度,跟普通的任务并无差别。当守护任务是当前优先级最高的就绪态任务时,它就可以运行。它的工作有两类:
能否及时处理定时器的命令、能否及时执行定时器的回调函数,严重依赖于守护任务的优先级。
守护任务的优先性级较低:
xTimerStart()
。要注意的是,xTimerStart()
只是把"start timer"的命令发给"定时器命令队列",使得守护任务退出阻塞态。但是此时,Task1 的优先级高于守护任务,所以守护任务无法抢占 Task1。
xTimerStart()
但是定时器真正的启动工作由守护任务来实现,所以xTimerStart()
返回并不表示定时器已经被启动了。
守护任务从队列中取出"start timer"命令,启动定时器。
- 注意:假设定时器在后续某个时刻 tX 超时了,超时时间是"tX-t2",而非"tX-t4"。
- 超时时间是从
xTimerStart()
函数被调用时算起。
守护任务的优先性级较高:
xTimerStart()
。此时守护任务的优先级高于 Task1,所以守护任务抢占 Task1,守护任务开始处理命令队列。
Task1 在执行xTimerStart()
的过程中被抢占,这时它无法完成自己的函数。
xTimerStart()
的调用尚未返回。现在开始继续运行此函数、返回。
- 注意,定时器的超时时间是基于调用
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()
这类函数传入,后续可以使用这些函数来操作:
vTimerSetTimerID()
函数。pvTimerGetTimerID()
函数。
- 这两个函数不涉及命令队列,它们是直接操作定时器结构体。
- 守护任务的优先级要尽可能高,让定时器的命令或者回调函数及时被执行。
一般使用:
如上图所示,创建两个定时器,一个是一次性定时器,定时周期是10个Tick,另一个是周期定时器,定时周期是20个Tick。然后再创建一个任务,优先级是1。
如上图,在任务1中启动两个定时器,然后在while(1)
中持续打印任务运行信息。
在一次性定时器的回调函数中,将一次性定时器运行标志位反转,并将运行次数加加,然后打印。
在周期性定时器回调函数中,将周期性定时器运行标志位反转,并将运行次数增加,然后打印。
- 两个回调函数中都没有
while(1)
死循环。
如上图运行结果,先看右边串口,任务1在启动两个定时器以后,就不断打印自己的运行信息。其中一次性定时器运行信息打印了一次,周期性定时器运行信息打印了多次。
再看左边逻辑分析仪中标志位的变化,可以看到,一次性定时器的运行标志只发生了一次变化,周期性定时器的运行标志每隔20隔Tick就变化一次。
- 定时器的回调函数并不是由任务1执行的,任务1中没有操作运行标志,更说明定时器的回调函数是由守护任务执行的。
- 一次性定时器只起一次作用,而周期性定时器按定时周期起作用,因为回调函数中并没有死循环,定时器每起一次作用就调用一次回调函数。
消除抖动:
在嵌入式开发中,我们使用机械开关时经常碰到抖动问题:引脚电平在短时间内反复变化。
怎么读到确定的按键状态?
如上图代码所示,配置PA0作为按键,且开启按键外部中断,采样双边沿触发模式。
如上图,在中断函数中,使用定时器进行消抖,每发生一次按键中断,就打印一次中断发生的信息,并且计数发生次数,然后使用xTimerReset
推迟超时时间,实现消抖。
如上图代码,将按键及中断初始化,然后创建一个一次性定时器,这里的超时时间设置为2秒,后面讲解原因。然后再创建一个任务。
如上图,在任务1中,只有一个死循环,是为了保证程序在一直执行,一次性定时器的回调函数中,打印回调函数的执行信息和执行次数。
- 如果按键持续抖动,则会持续发生外部中断,定时器也会被不停复位,超时时间就在不断推后,回调函数始终得不到执行。
- 当按键稳定后,定时器超时,回调函数执行,此时消抖完成,成功读取一次按键。
如上图,将程序使用软件仿真,打开模拟器中的GPIOA,如上图所示,其中第4步红色框中的勾用来就用来操作PA0引脚,来模拟按键中断产生。
- 打上勾时,PA0的电平为高。
- 没有勾时,PA0的电平为低。
所以我们只需要用鼠标不停电机第4步中的红色框位置,就可以模拟处按键过程中的抖动,当不再点击时,抖动消失,按键状态稳定。
- 由于手动模拟,无法在20ms内完成,为了看到实验现象,将定时器的超时时间设置成2秒。
2秒钟内,每点击一次,模拟发生一次抖动,超时时间推后2秒。当不再点击时,按键稳定,等待定时器超时,一次按键读取完成。
如上图,本喵快速点击多次,产生了多次按键中断,定时器超时时间推迟了多次,最后消去了这几次抖动,读取一次按键状态。
在 RTOS 中,需要应对各类事件。这些事件很多时候是通过硬件中断产生,怎么处理中断呢?假设当前系统正在运行 Task1 时,用户按下了按键,触发了按键中断。这个中断的处理流程如下:
ISR 是在内核中被调用的,ISR 执行过程中,用户的任务无法执行。ISR要尽量快,否则:
如果这个硬件中断的处理,就是非常耗费时间呢?对于这类中断的处理就要分为 2 部分:
- 所以:需要 ISR 和任务之间进行通信。
要在 FreeRTOS 中熟练使用中断,有几个原则要先说明:
- ISR 的优先级高于任务:即使是优先级最低的中断,它的优先级也高于任务。
- 任务只有在没有中断的情况下,才能执行。
如上图按键中断代码所示,在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
来判断是否要发起调度。
普通任务写队列:
如上图代码,是普通任务向队列中写数据xQueueSend
的底层部分函数。
- 普通任务向队列中写数据时,有超时时间,死循环,还会在函数中直接发起任务调度。
ISR写队列:
如上图是ISR向队列中写数据xQueueSendFromISR
的底层函数。
- ISR向链表中写数据时,没有超时时间,没有死循环式的判断,数据写入以后仅记录需要发起调度,写入失败后直接返回。
差别:
xQueueSend | xQueueSendFromISR | |
---|---|---|
参数不同 | xTicksToWait: 队列满的话阻塞多久 | 没有xTicksToWait |
唤醒等待的任务 | 写队列后,会唤醒等待数据的任务 | 写队列后,记录要唤醒等待数据任务的需求 |
调度 | 如果被唤醒的任务优先级更高,即刻调度 | 如果被唤醒的任务优先级更高,不会调度 只是记录下来表示:需要调度 |
阻塞 | 如果队列满,可以阻塞 | 如果队列满,不能阻塞 |
对比两套API,发现ISR的API比普通的API高效很多,除了没有超时时间外,就是不会直接发起调度,而是将需要调度的需求记录下来。等ISR退出后,执行portYIELD_FROM_ISR(xHigherPriorityTaskWoken)
函数来发起调度。
- 发起调度不能以内核的身份发起,只能以普通任务的身份去发起。
其实在ISR中发起调度也没有意义,因为中断的优先级比所有任务都高,在中断中发起调度,调度器任务无法处理这个调度请求,因为是此时代码仍然在执行中断函数,就会直接导致程序阻塞。
这种记录调度需求的处理方式叫"中断的延迟处理"(Deferring interrupt processing)。
这样做有多种好处:
软件定时器是经常使用的一种定时方式,要清楚的意识到守护任务的存在,以及守护任务所做的工作,用户操作软件定时器的本质就是:用户任务和守护任务通过命令队列进行通信。
要知道中断的优先级是高于所有用户任务的,而且在中断服务函数中不会立刻发起任务调度,而是记录下需要调度的需求,等ISR退出以后,由调度器发起任务调度。