为了做毕设花一周时间学习了《嵌入式实时操作系统uC/OS-II原理及应用》,蛮好的书,简洁明了的把uCOS的要点讲了一遍,虽然不是特别深入地讲,但学完一遍后基本使用是没问题了。实际使用中发现uCOS-II(V2.86后)提供了定时器Timer,而书上没有讲到。于是自己研究了下其原理及使用。
注:这篇文章默认读者对uCOS-II操作系统的原理有基本的了解
定时器很适合于有大量需要延时执行或者周期性执行的任务的情况,虽然这也可以通过分别创建任务,然后调用OSTimeDly来延时执行的方式实现。但UCOS-II通过定时器提供了个更简单的实现方式,而且通过定时器,不管你有多少个小任务需要定时/延时执行,都只会占用一个任务优先级(UCOS-II最多只能有64/256个任务优先级,而因为一个任务优先级最多对应一个任务,因此也只能同时管理最多64/256个任务,这其中还包括系统级任务),因此突破了任务优先级的限制,当然,相对地,不同的定时器间也不存在谁优先执行的问题。
因为有些人需要快速学会使用,暂时没时间了解内部怎么实现的,先讲一下最基本的知识以及使用方法。
定时器的相关代码主要在os_tmr文件下。
如果开启了定时器模块(即OS_TMR_EN为1),则uCOS会专门创建一个任务来管理定时器模块。这个定时器管理任务(OSTmr_Task)的作用主要就是对等待中的定时器进行管理及调度,决定现在这一瞬间是否有定时器的时间到了需要触发(调用其对应的函数),触发完了是否需要让其继续等待下一次触发,或者直接丢弃。
类似于任务的管理方式,不同的定时器都由一个定时器控制块(OS_TMR)来管理,这个程序控制块记录了每个定时器的各种信息,如名字、类型、触发周期、回调函数等信息。实际上定时器管理任务主要管理的对象就是这些定时器控制块。对于使用来说,最基本的使用就是按照需要创建(OSTmrCreate)这些定时器控制块,然后使能(OSTmrStart)它,这样它就会被交付定时器管理任务来管理,并在合适的时间被触发。
首先要配置下定时器模块。有如下主要的可配置选项:
os_cfg.h中
……
/* --------------------- TASK STACK SIZE ---------------------- */
#define OS_TASK_TMR_STK_SIZE 160 /* Timer task stack size (# of OS_STK wide entries) */
……
/* --------------------- TIMER MANAGEMENT --------------------- */
#define OS_TMR_EN 1 /* Enable (1) or Disable (0) code generation for TIMERS */
#define OS_TMR_CFG_MAX 16 /* Maximum number of timers */
#define OS_TMR_CFG_NAME_SIZE 0 /* Determine the size of a timer name */
#define OS_TMR_CFG_WHEEL_SIZE 8 /* Size of timer wheel (#Spokes) */
#define OS_TMR_CFG_TICKS_PER_SEC 10 /* Rate at which timer management task runs (Hz) */
app_cfg.h中
……
#define OS_TASK_TMR_PRIO 18 /* Priority of OS_TIMER task*/
……
要使用定时器的话OS_TMR_EN自然要设置为1以使能定时器模块。
OS_TMR_CFG_MAX设置最多同时多少个定时器,OS_TMR_CFG_NAME_SIZE设置定时器名字的最大长度,设为0则不使用名字。
OS_TMR_CFG_WHEEL_SIZE暂时不用管,默认就好,这是uCOS内部管理定时器相关的一个东西。
OS_TMR_CFG_TICKS_PER_SEC则决定了定时器的精度,如注释所说,即定时器的计时频率,定时器模块中涉及时间的东西都是以这个东西为基准的,现在设置为了10即定时器模块的基本时间单位为1s/10=0.1s;要注意的是这个值不应该大于OS_TICKS_PER_SEC,如果大于的话效果其实就是其值等于OS_TICKS_PER_SEC,也就是说定时器模块的最高精度等于OSTick的精度。
而OS_TASK_TMR_PRIO和OS_TASK_TMR_STK_SIZE则是设置之前所说的定时器管理任务的优先级和栈大小的,根据需要来进行设置,要保证栈大小足够执行最占栈空间的那个定时器回调函数。
使用定时器的第一步是使用OSTmrCreate函数创建一个定时器,其注释(已翻译为中文)及定义如下:
/*
************************************************************************************************************************
* CREATE A TIMER
*
* 描述: 你的应用程序代码使用这个函数来创建一个定时器。
*
* 参数: dly 最初的延迟.
* 如果opt选择ONE-SHOT(一次性执行)模式,则这就是等待的时间。
* 如果opt选择PERIODIC(周期性执行)模式,则这是在周期性触发前第一次触发等待的时间。
*
* period 定时器重复执行的“周期”。
* 如果你选择了周期性执行,则每次等待时间耗尽,就会在执行后自动再次等待这个时间。
*
* opt 有两个选项:
* OS_TMR_OPT_ONE_SHOT 定时器只执行一次。
* OS_TMR_OPT_PERIODIC 定时器周期性执行。
*
* callback 指向回调函数的指针,当定时器时间到了,就会调用它。回调函数必须如下这样定义:
*
* void MyCallback (OS_TMR *ptmr, void *p_arg);
*
* callback_arg 当回调函数被调用时传递给回调函数的参数(一个指针)。
*
* pname 一个指向ASCII字符串的指针,用其来给定时器命名。命名有助于调试。名字的最大长度不能超过OS_CFG.H文件中定义的OS_TMR_CFG_NAME_SIZE。
*
* perr 指向错误码的指针。'*perr'会是其下之一:
* OS_ERR_NONE 如果没错误
* OS_ERR_TMR_INVALID_DLY 指定了无效的延迟
* OS_ERR_TMR_INVALID_PERIOD 指定了无效的周期
* OS_ERR_TMR_INVALID_OPT 指定了无效的触发选项
* OS_ERR_TMR_ISR 如果在中断服务程序中调用这个函数
* OS_ERR_TMR_NON_AVAIL 如果Timer池中的Timer用光了
* OS_ERR_TMR_NAME_TOO_LONG 如果名字太长放不下了
*
* 返回值 : 一个指向OS_TMR(定时器控制块)数据结构的指针。
* 这是你的应用用于引用刚创建的定时器的'句柄(handle)'。
************************************************************************************************************************
*/
OS_TMR *OSTmrCreate (INT32U dly,
INT32U period,
INT8U opt,
OS_TMR_CALLBACK callback,
void *callback_arg,
INT8U *pname,
INT8U *perr);
注释应该已经很清楚了,需要注意的是这里的dly和period都是以1/OS_TMR_CFG_TICKS_PER_SEC为基本时间单位的,比如我前面将OS_TMR_CFG_TICKS_PER_SEC设置为了10,那么如果我想要10s执行一次的话,period就应该设置为100。
创建完定时器还必须使用OSTmrStart 函数启动这个定时器,其才能起作用。其注释(已翻译为中文)及定义如下:
/*
************************************************************************************************************************
* START A TIMER
*
* 描述: 你的应用程序代码使用这个函数来启动一个定时器。
*
* 参数: ptmr 一个指向OS_TMR的指针。
*
* perr 一个指向错误代码的指针。'*perr'会是其下之一:
* OS_ERR_NONE 如果没错误
* OS_ERR_TMR_INVALID 'ptmr'指针为空
* OS_ERR_TMR_INVALID_TYPE 'ptmr'指向的不是一个OS_TMR
* OS_ERR_TMR_ISR 如果在中断服务程序中调用这个函数
* OS_ERR_TMR_INACTIVE 如果定时器还未被创建
* OS_ERR_TMR_INVALID_STATE 如果定时器在个无效的状态中
*
* 返回值: OS_TRUE 如果成功启动了定时器
* OS_FALSE 如果发生了错误
************************************************************************************************************************
*/
BOOLEAN OSTmrStart (OS_TMR *ptmr, INT8U *perr);
这里简单示范下创建定时器并启动。假设我需要每秒开启/关闭某个LED灯。
// 定义指向定时器控制块的指针
OS_TMR *LEDTimer;
static void AppTaskStart (void *p_arg)
{
INT8U err;
……
// 周期性执行Callback_LED函数,不命名(因为配置中设置名字长度为0,关闭了命名功能,所以可以传NULL指针),不传回调参数,0.5秒后第一次执行,然后每1s执行一次
LEDTimer = OSTmrCreate(5,10,OS_TMR_OPT_PERIODIC,Callback_LED,NULL,NULL,&err);
// 启动定时器
OSTmrStart(LEDTimer,&err);
……
}
// 被定时执行的函数
void Callback_LED(OS_TMR *ptmr, void *p_arg){
// 开启/关闭LED4
LED_Toggle(LED_4);
}
定时器模块还提供了其他函数来对定时器进行更复杂的操作。如
BOOLEAN OSTmrDel (OS_TMR *ptmr, INT8U *perr); 用于删除定时器。
INT8U OSTmrNameGet (OS_TMR *ptmr, INT8U *pdest, INT8U *perr); 用于获取定时器的名字。
INT32U OSTmrRemainGet (OS_TMR *ptmr, INT8U *perr); 获取定时器还剩多久触发。
INT8U OSTmrStateGet (OS_TMR *ptmr, INT8U *perr); 获取定时器当前状态。
BOOLEAN OSTmrStop (OS_TMR *ptmr, INT8U opt, void *callback_arg, INT8U *perr); 用于停止定时器。
具体就不分别介绍了,需要的可以自己看其注释。
下面我们来分析下uCOS-II具体是怎么实现定时器的。
如前所述,为了方便对定时器进行管理,使用了定时器控制块,定时器控制块是一个结构类型,当用户调用OSTmrCreate() 函数创建一个定时器时,这个函数就会把这个定时器的相关配置存储在它的定时器控制块中。
定时器控制块结构的定义如下(os_tmr.c):
typedef struct os_tmr {
INT8U OSTmrType; // 应被设置为OS_TMR_TYPE
OS_TMR_CALLBACK OSTmrCallback; // 当时间耗尽时的回调函数
void *OSTmrCallbackArg; // 当时间耗尽时会传给回调函数的参数
void *OSTmrNext; // 双向链表指针
void *OSTmrPrev;
INT32U OSTmrMatch; // 当 OSTmrTime == OSTmrMatch时,判定时间耗尽
INT32U OSTmrDly; // 在周期性触发前的延时时间
INT32U OSTmrPeriod; // 定时器的重复周期
#if OS_TMR_CFG_NAME_SIZE > 0
INT8U OSTmrName[OS_TMR_CFG_NAME_SIZE]; // 定时器的名字
#endif
INT8U OSTmrOpt; // 选项 (见 OS_TMR_OPT_xxx)
INT8U OSTmrState; // 定时器当前的状态
} OS_TMR;
OSTmrOpt可能的值:
OSTmrOpt的值 | 选择 |
---|---|
OS_TMR_OPT_ONE_SHOT | 定时器只运行一次 |
OS_TMR_OPT_PERIODIC | 定时器每次运行完都会重新加载自己(周期性执行) |
定时器(准确地说是定时器控制块)有如下几个状态:
OSTmrState的值 | 状态 |
---|---|
OS_TMR_STATE_UNUSED | 定时器还未使用 |
OS_TMR_STATE_RUNNING | 定时器运行中 |
OS_TMR_STATE_STOPPED | 定时器停止中 |
OS_TMR_STATE_COMPLETED | 定时器在一次性模式下运行完毕 |
状态间的转换如下图:
由于COMPLETED状态与STOPPED状态几乎没有差别,只有在OSTmrRemainGet() 函数中对他们的处理有差别,所以并没有单独再画个COMPLETED状态,可把两个状态视作一个。
定时器模块的各函数通过定时器的状态值判断其当前的状态并作出对应的处理,使定时器在不同状态间转换。
图上两个虚线框表示在某状态时定时器所在的控制结构体。在停止状态下,操作系统中不保存定时器控制块的引用,用户需要自行保存对应引用,否则会造成内存泄露;在另外两个状态下,分别由空定时器控制块链表和定时器轮来管理定时器,下面介绍这两个控制结构。
类似于任务模块的做法,为了满足在难以动态分配内存的嵌入式程序中动态创建定时器控制块对象的需求,定时器模块会预先在RAM中分配一个对象池(OSTmrTbl[]),其实就是定时器控制块的数组,并在初始化函数OSTmr_Init()中(由OSInit()函数自动调用)进行初始化,并把所有定时器控制块对象与空定时器控制块链表(OSTmrFreeList)链起来,这样,空定时器控制块链表管理着所有空闲(未用)的定时器控制块对象,因此叫空定时器控制块链表。
初始状态的空定时器控制块链表与对象池的关系如上图所示,图中可知,能同时使用的最大定时器数量由OS_TMR_CFG_MAX决定,其定义在os_cfg.h中。
每当应用程序调用OSTmrCreate()创建定时器时,系统就将空定时器控制块链表指向的第一个定时器控制块分配给该定时器,将各成员变量赋值后将定时器的引用交给用户,同时定时器进入停止状态。而后用户就可以使用OSTmrStart()使其进入运行状态,同时,它也被交与定时器轮(OSTmrWheelTbl)管理。
定时器轮(OSTmrWheelTbl)管理着所有运行中的定时器,准确来说,它是OS_TMR_WHEEL结构的数组,数组的大小为OS_TMR_CFG_WHEEL_SIZE,定义在os_cfg.h中。OS_TMR_WHEEL的定义如下(ucos_ii.h):
typedef struct os_tmr_wheel {
OS_TMR *OSTmrFirst; // 指向链表中第一个定时器的指针
INT16U OSTmrEntries; // 当前轮辐上的定时器个数
} OS_TMR_WHEEL;
如上图所示(未标出的指针都指向NULL),OSTmrWheelTbl就好像一个车轮,其上有好多个轮辐,所有运行状态下的定时器分散于轮辐之上,在同一轮辐上的定时器由链表串成一列。如果有数据结构基础的话可以看出,这实际上就是一个使用分离链接法的散列(哈希表)。其哈希函数十分简单:
spoke = OSTmrMatch % OS_TMR_CFG_WHEEL_SIZE // 注:OSTmrWheelTbl[spoke]即为这个OSTmrMatch对应轮辐
OSTmrMatch在后面会介绍,现在只要知道在定时器装载进定时器轮时就会确定其对应的OSTmrMacth值,这个值在定时器停止或重新装载之前保证不会变化,因此就能根据定时器控制块的OSTmrMacth字段值计算出这个定时器是在哪个轮辐之上,从而把大量的定时器分散于不同的轮辐之上,加速对定时器的操作。
当定时器轮需要接收一个定时器时,会先计算此定时器应该放在哪个轮辐之上,然后将其插入轮辐链表的第一个,如果此轮辐上还有其他定时器,则还会将原来第一个定时器的Prev指针指向新插入的这个定时器。
而当定时器轮卸下一个定时器时,则如果不是在轮辐的第一个,则可以通过Prev指针找到前一个定时器,这样就把删除定时器的时间控制在O(1)了。
定时器轮只是提供了对运行中的定时器的管理,为了实现运行中的定时器的自动执行,就需要有东西驱动定时器的运转,定时器模块中的驱动力就是定时器管理任务。
要了解定时器管理任务是如何驱动定时器模块运转的,就必须同时理解定时器模块是怎么管理时间的。
定时器模块的当前时间由个INT32U类型的全局变量OSTmrTime定义(ucos_ii.h):
OS_EXT INT32U OSTmrTime; // 定时器模块的当前时间
每当产生了个时钟节拍(Tick),其值随后都会加1,以表示到达了下一个时间点。
再进一步考虑,判断某个定时器是否时间耗尽并不需要通过直觉会想到的将某个值递减到0的方式,根据定时器开始时的当前时间与其要延迟的时间就能算出其触发的时间点,然后当时间增长到这个时间点时触发即可(注:值溢出并不会导致出错)。实际上,定时执行的实现方法就是在调用OSTmrStart()启动定时器之时将其触发时间存在OSTmrMatch字段中,然后当OSTmrTime == OSTmrMatch时进行触发,这样的方式配合定时器轮大大增强了定时器模块的运行效率。
要注意,当前时间的增长并不是与时钟节拍的产生同步的,而是通过信号量(OSTmrSemSignal)的方式来通知的。产生个时间节拍其实就是调用了定时器模块的OSTmrSignal()函数,其定义如下(os_tmr.c):
INT8U OSTmrSignal (void)
{
INT8U err;
err = OSSemPost(OSTmrSemSignal);
return (err);
}
可以看出这个函数内部其实就是很简单地Post了这信号量而已。一般来说用户不会调用这个函数,操作系统会自动调用其,以产生定时器模块的时钟节拍,但如果用户有特殊需求,完全可以不使用自动产生的节拍,自己通过调用这函数来控制定时器模块的时间。
而定时器模块的时间的增长最终其实是由定时器管理任务(此任务在定时器模块的初始化过程中被自动创建)来完成的,定时器管理任务会无限循环Pend这个信号量,每次Pend到,即将定时器模块的当前时间加1,然后根据当前时间就可以得到应该处理哪一个轮辐,而进行对应的处理。定时器管理任务的运行逻辑如下图所示:
所以其实定时器管理任务是定时器模块的核心驱动力,同时负责定时器模块的时间增长与定时器上回调函数的执行,这样就会得出一些结论:
a. 所有定时器拥有同一任务优先级,即定时器管理任务的任务优先级OS_TASK_TMR_PRIO (app_cfg.h)。
b. 定时器管理任务的任务堆栈大小OS_TASK_TMR_STK_SIZE(os_cfg.h)起码要能运行可能会执行的最小的定时器回调函数。
c. 如果cpu的负荷过重导致定时器管理任务难以获得cpu时间,会导致时间节拍得不到及时处理,即定时器不能按时执行,严重的话甚至导致时间节拍丢失(即信号量溢出了)。
那话说回来,时间节拍到底是怎么自动产生的呢?
如图所示,定时器模块的时钟节拍实际上是Hook在操作系统的时钟节拍上的,这也决定了其精度不会超过系统时钟节拍(好像超过了也没什么意义),跟Hook有关的代码(os_cpu_c.c中)如下:
……
#if (OS_TMR_EN > 0)
static INT16U OSTmrCtr;
#endif
……
void OSTimeTickHook (void)
{
……
#if OS_TMR_EN > 0
OSTmrCtr++;
if (OSTmrCtr >= (OS_TICKS_PER_SEC / OS_TMR_CFG_TICKS_PER_SEC)) {
OSTmrCtr = 0;
OSTmrSignal();
}
#endif
}
……
可以看出,实际上定时器模块是通过计数的方式对系统节拍进行了分频,系统每Tick了OS_TICKS_PER_SEC / OS_TMR_CFG_TICKS_PER_SEC下就通知(OSTmrSignal)一次定时器模块,要是OS_TMR_CFG_TICKS_PER_SEC大于OS_TICKS_PER_SEC 的话,那相除的结果就是0,也就是每次系统Tick都会通知定时器模块。
这样,通过多种数据结构相互之间的配合,定时器模块得以自动地运作。
本文对嵌入式实时操作系统uC/OS-II的定时器模块做了较为深入的分析,主要关注于模块的运作机制及用到的数据结构,对于模块提供的一些接口函数并没有做过多赘述,它们的作用主要就是转换定时器的状态或者查询定时器的信息;还有一些没那么重要的细节也被作者刻意隐去,感兴趣的读者可以在本文基础之上直接阅读源码以更深入的学习。
阅读uC/OS-II源码的过程中不断有着眼前一亮之感,感慨于前辈们短小高效的代码,看着曾经在书上学到或没学到的各种数据结构和算法是怎么在实践中让程序高效的运行,受益颇多。正好发现书(《嵌入式实时操作系统uC/OS-II 原理及应用》)中没有写定时器模块,而网上也没有搜到啥专门分析这个模块的文章,故自行研究,写出此文,也算为知识的传播作出绵薄之力。文中如有错误或不妥之处,还请前辈们指出。