第2章 时钟节拍
所谓时钟节拍,就是CPU以固定的频率产生中断,可以看成是操作系统的“心跳”。内核可以利用这个时钟节拍来管理各个任务的一些时间管理,比如延时、定时、超时检测、时间片轮转调度等。总之,内核中跟时间有关的都跟时间节拍有关。
还没有了解时钟节拍是怎么运作之前,读者可以想想,有时一个系统可能会有多个任务在延时,常规的检查办法需要每次时钟节拍到来的时候都检查这些延期的任务是否到期,这种情况下有什么办法可以减轻系统的负担?如果没有想到好的办法,就让我们一起来领略μC/OS-III的奥妙吧。
2.1 系统节拍中断服务程序
时钟节拍的频率可以为10Hz~1000Hz。频率太低会让有些任务不能及时就绪(在每次时钟节拍到来前都会检查是否要进行任务调度),因为每个时钟节拍到来系统都会进行一些超时检测等;频率太高会导致内核的负担加重。本书的例程都采用1000Hz的时钟节拍,但在os_cfg_app.c文件中需要根据实际节拍频率来设置这个宏的值如下所示。仔细查看这个宏的名称,其实很快就可以明白它的含义。所以在自己命名一些变量或者宏的时候,也应尽量做到通俗易懂。
1 #define OS_CFG_TICK_RATE_HZ 1000u
每次产生系统节拍的时候,系统会执行中断服务程序,在中断服务程序中再调用一个函数OSTimeTick,调用的过程如代码清单2-1所示。其中,第3、5行是前面介绍过的中断嵌套统计函数,OSTimeTick就是节拍中断中所做事情的函数。
注意:CPU产生时钟节拍的方法有很多,可以都是中断,也可以是某种固定频率的中断源。本书例程采用芯片提供的系统滴答定时器。
代码清单2-1 节拍中断调用OSTimeTick
1 void SysTick_Handler(void) 2 { 3 OSIntEnter(); //用于统计中断的嵌套层数,对嵌套层数加1 4 OSTimeTick(); //统计时间,遍历任务,对延时任务计时减1
5 OSIntExit(); //对嵌套层数减1,在退出中断前启动任务调度 6 }
下面仔细查看代码清单2-2第4行的OSTimeTick函数的内容。
代码清单2-2 OSTimeTick函数
1 void OSTimeTick (void) 2 { 3 OS_ERR err; 4 #if OS_CFG_ISR_POST_DEFERRED_EN > 0u 5 CPU_TS ts; 6 #endif 7 8 9 OSTimeTickHook(); 10 11 #if OS_CFG_ISR_POST_DEFERRED_EN > 0u 12 13 ts = OS_TS_GET(); 14 OS_IntQPost((OS_OBJ_TYPE) OS_OBJ_TYPE_TICK, 15 (void *)&OSRdyList[OSPrioCur], 16 (void *) 0, 17 (OS_MSG_SIZE) 0u, 18 (OS_FLAGS ) 0u, 19 (OS_OPT ) 0u, 20 (CPU_TS ) ts, 21 (OS_ERR *)&err); 22 23 #else 24 25 (void)OSTaskSemPost((OS_TCB *)&OSTickTaskTCB, 26 (OS_OPT ) OS_OPT_POST_NONE, 27 (OS_ERR *)&err); 28 29 30 #if OS_CFG_SCHED_ROUND_ROBIN_EN > 0u 31 OS_SchedRoundRobin(&OSRdyList[OSPrioCur]); 32 #endif 33 34 #if OS_CFG_TMR_EN > 0u 35 OSTmrUpdateCtr--; 36 if (OSTmrUpdateCtr == (OS_CTR)0u) { 37 OSTmrUpdateCtr = OSTmrUpdateCnt; 38 OSTaskSemPost((OS_TCB *)&OSTmrTaskTCB, 39 (OS_OPT ) OS_OPT_POST_NONE, 40 (OS_ERR *)&err); 41 } 42 #endif 43 44 #endif 45 }
首先,代码段中有多个#if、#else、#endif,μC/OS-III可利用条件编译来让代码具有可裁剪性。只要#if里非0(0为假,非0为真),就可将#if里的内容编译进来,#else同理。如果不需要用到队列,那么只需将队列的宏OS_CFG_Q_EN置0,就不能将队列的代码编译进来,这样可以任意改变使用的功能和裁剪代码。这里需要对比经常用到的if。if不管条件真假,代码都会被编译进去。
一开始执行的是OSTimeTickHook,这是一个钩子函数,可以简单理解为这是你自己自由编写的函数,如代码清单2-3所示。系统在这里设置钩子函数的原因是:如果有一个事件要在产生时间节拍的时候运行,就可以在这里设置;如果某段程序的执行周期是1个时间节拍,那么最好放在这个钩子里面;如果用延时函数延时1个时间节拍,则会不准确,这在时间管理章节会讲解到。要想使用钩子函数,要先将宏OS_CFG_APP_HOOKS_EN置1。接下来介绍OSTimeTickHook的具体内容,先判断OS_AppTimeTickHookPtr是否为空指针,如果不是,则调用OS_AppTimeTickHookPtr。这里关系到函数指针的用法。对于一个函数,调用的时候既可以写成“(*函数名)();”,也可以写成“函数名();”。平常见得最多的是第二种,而这里使用的是第一种,如代码清单2-3所示。
代码清单2-3 OSTimeTickHook 函数
1 void OSTimeTickHook (void) 2 { 3 #if OS_CFG_APP_HOOKS_EN > 0u 4 if (OS_AppTimeTickHookPtr != (OS_APP_HOOK_VOID)0) { 5 (*OS_AppTimeTickHookPtr)(); 6 } 7 #endif 8 }
如果将定义的函数名称My_Hook_Function赋给系统定义的全局函数指针变量OS_AppTimeTickHookPtr,则定义的函数自然就以系统节拍的频率执行。每次系统节拍到来时都会调用这个函数,如代码清单2-4所示。
代码清单2-4 钩子函数的初始化过程
OS_AppTimeTickHookPtr = (OS_APP_HOOK_TCB)My_Hook_Function;
表2-1是μC/OS-III所有的钩子函数及其执行场合。
表2-1 所有的钩子函数及其执行场合
回到时钟节拍调用的函数OSTimeTick,代码清单2-2第11~27行都是给时钟节拍任务OSTickTaskTCB发送任务信号量,这里的信号量用来告诉时钟节拍任务已经到来。虽然有两个分支,但是它们只是两种不同发布消息的方式。如果OS_CFG_ISR_POST_DEFERRED_EN这个中断延迟的宏定义为1,则将采取延迟发布的形式,中断的时候只保存发布函数的相关信息,退出中断的时候让优先级最高的延迟发布任务就绪并进行延迟发布。上面介绍的将中断级的事情转化为任务级可以提高系统的实时性,这里也是相同的原理。如果将OS_CFG_ISR_POST_DEFERRED_EN定义为0,那就按照常规发布函数的方法来发布内核对象,这样会增加关中断的时间。
接下来看看时钟节拍任务的执行过程,聪明的读者肯定知道这时时钟节拍任务一定在等待ISR给它发消息。时钟节拍处理不是直接在ISR中处理,而是作为一个任务来处理,这样的好处是中断占用的时间少。μC/OS-III在其手册上也将这点作为其改进的特性之一。中断函数应该要处理简短重要的事情,如果事情很重要而且比较长,那么可以在ISR中发消息给优先级比较高的任务,要处理的事情放在这个任务中,并且将其挂起,等待ISR给其发信息才执行,这样就将中断级转化为任务级。
注意:时钟节拍到来的时候,做的事情很多,代码清单2-2第30~42行是与时间片轮转调度、定时器相关的,暂不讨论。
2.2 节拍任务处理时间相关事务
在代码清单2-5中,任务的死循环中调用了OSTaskSemPend以等待ISR给其发送信号量。初次见到这些陌生的函数不用担心,一看名称,二看函数定义处的英文解析,一般都会明白。如果OSTaskSemPend执行过程没有出现错误且OS已经开始,就调用OS_TickListUpdate,真正处理延时、超时等就在这里。处理完之后还在任务的死循环里,继续等待下一个时钟节拍的到来。
代码清单2-5 时钟节拍任务
1 void OS_TickTask (void *p_arg) 2 { 3 OS_ERR err; 4 CPU_TS ts; 5 6 7 p_arg = p_arg; 8 9 while (DEF_ON) { 10 11 //等待任务信号量 12 (void)OSTaskSemPend((OS_TICK )0, 13 (OS_OPT )OS_OPT_PEND_BLOCKING, 14 (CPU_TS *)&ts, 15 (OS_ERR *)&err); 16 17 18 if (err == OS_ERR_NONE) { 19 //检查系统是否正在运行 20 if (OSRunning == OS_STATE_OS_RUNNING) { 21 //时钟节拍更新函数 22 OS_TickListUpdate(); 23 } 24 } 25 } 26 }
注意:μC/OS-III中死循环使用的都是while (DEF_ON){},DEF_ON被宏定义为1,这也是μC/OS-III的一种代码规范。一般来说,μC/OS-III函数的第一个参数都是操作的对象,最后一个参数就是错误信息,OSTaskSemPend同样也符合这样的规则。
2.2.1 节拍列表更新
节拍列表更新函数OS_TickListUpdate的源码如代码清单2-6所示。
代码清单2-6 节拍列表更新函数
1 void OS_TickListUpdate (void) 2 { 3 CPU_BOOLEAN done; 4 OS_TICK_SPOKE *p_spoke; 5 OS_TCB *p_tcb; 6 OS_TCB *p_tcb_next; 7 OS_TICK_SPOKE_IX spoke; 8 CPU_TS ts_start; 9 CPU_TS ts_end; 10 CPU_SR_ALLOC(); 11 12 //进入临界段 13 OS_CRITICAL_ENTER(); 14 //获取程序运行前的时间戳,后面计算程序时间 15 ts_start = OS_TS_GET(); 16 //已经发生的时间戳的个数 17 OSTickCtr++; 18 //对于常数OSCfg_TickWheelSize区域,根据结果取出对应的数组元素 19 spoke = (OS_TICK_SPOKE_IX)(OSTickCtr % OSCfg_TickWheelSize); 20 p_spoke = &OSCfg_TickWheel[spoke]; 21 //取出数组元素双向链表的第一个任务控制块 22 p_tcb = p_spoke->FirstPtr; 23 done = DEF_FALSE; 24 while (done == DEF_FALSE) { 25 if (p_tcb != (OS_TCB *)0) { 26 p_tcb_next = p_tcb->TickNextPtr; 27 //首先判断任务的状态 28 switch (p_tcb->TaskState) { 29 //这些都是不可能的状态, 30 case OS_TASK_STATE_RDY: 31 case OS_TASK_STATE_PEND: 32 case OS_TASK_STATE_SUSPENDED: 33 case OS_TASK_STATE_PEND_SUSPENDED: 34 break; 35 36 //如果任务是因为延时而被插入节拍列表的 37 case OS_TASK_STATE_DLY: 38 39 //更新任务还有多少个节拍到期 40 p_tcb->TickRemain = p_tcb->TickCtrMatch 41 - OSTickCtr; 42 //如果任务延时到期 43 if (OSTickCtr == p_tcb->TickCtrMatch) { 44 45 //置任务状态为就绪 46 p_tcb->TaskState = OS_TASK_STATE_RDY; 47 //将任务脱离节拍列表,插入就绪列表 48 OS_TaskRdy(p_tcb); 49 } else { 50 //一旦检查到时间还没有到,就退出循环 51 done = DEF_TRUE; 52 } 53 break; 54 55 //如果任务是因为等待有超时限制而插入节拍列表的 56 case OS_TASK_STATE_PEND_TIMEOUT: 57 58 //更新任务还有多少个节拍到期 59 p_tcb->TickRemain = p_tcb->TickCtrMatch 60 - OSTickCtr; 61 62 //超时时间到 63 if (OSTickCtr == p_tcb->TickCtrMatch) { 64 #if (OS_MSG_EN > 0u) 65 //将任务控制块暂时保存消息存放数据的指针和所占字节大小都置0 66 p_tcb->MsgPtr = (void *)0; 67 p_tcb->MsgSize = (OS_MSG_SIZE)0u; 68 #endif 69 p_tcb->TS = OS_TS_GET(); 70 //将任务脱离等待列表 71 OS_PendListRemove(p_tcb); 72 //将任务脱离节拍列表,插入就绪列表 73 OS_TaskRdy(p_tcb); 74 //等待已经超过限定时间,将任务状态置于就绪状态 75 p_tcb->TaskState = OS_TASK_STATE_RDY; 76 //记录任务等待超时 77 p_tcb->PendStatus = OS_STATUS_PEND_TIMEOUT; 78 //任务已经就绪,不再等待任何内核对象 79 p_tcb->PendOn = OS_TASK_PEND_ON_NOTHING; 80 } else { 81 //一旦检查到时间还没有到,就退出循环 82 done = DEF_TRUE; 83 } 84 break; 85 86 //如果在延时过程被挂起 87 case OS_TASK_STATE_DLY_SUSPENDED: 88 89 //更新任务还有多少个节拍到期 90 p_tcb->TickRemain = p_tcb->TickCtrMatch 91 - OSTickCtr; 92 //如果延时已经到期 93 if (OSTickCtr == p_tcb->TickCtrMatch) { 94 //任务没有延时,剩下挂起状态 95 p_tcb->TaskState = OS_TASK_STATE_SUSPENDED; 96 //脱离节拍列表 97 OS_TickListRemove(p_tcb); 98 } else { 99 //一旦检查到时间还没有到,就退出循环 100 done = DEF_TRUE; 101 } 102 break; 103 104 //有超时限制的等待且被挂起 105 case OS_TASK_STATE_PEND_TIMEOUT_SUSPENDED: 106 107 //更新任务还有多少个节拍超时 108 p_tcb->TickRemain = p_tcb->TickCtrMatch 109 - OSTickCtr; 110 //如果时间到了等待的事情还没有发生 111 if (OSTickCtr == p_tcb->TickCtrMatch) { 112 #if (OS_MSG_EN > 0u) 113 p_tcb->MsgPtr = (void *)0; 114 p_tcb->MsgSize = (OS_MSG_SIZE)0u; 115 #endif 116 //存放超时时的时间戳 117 p_tcb->TS = OS_TS_GET(); 118 //脱离等待列表 119 OS_PendListRemove(p_tcb); 120 //脱离节拍列表 121 OS_TickListRemove(p_tcb); 122 //任务还处于挂起状态 123 p_tcb->TaskState = OS_TASK_STATE_SUSPENDED; 124 //记录任务等待的状态是等待超时 125 p_tcb->PendStatus = OS_STATUS_PEND_TIMEOUT; 126 //不再处于等待状态 127 p_tcb->PendOn = OS_TASK_PEND_ON_NOTHING; 128 } else { 129 //一旦检查到时间还没有到,就退出循环 130 done = DEF_TRUE; 131 } 132 break; 133 134 default: 135 break; 136 } 137 p_tcb = p_tcb_next; 138 } else { 139 done = DEF_TRUE; 140 } 141 } 142 //计算程序运行的时间 143 ts_end = OS_TS_GET() - ts_start; 144 //更新程序运行的最长时间 145 if (ts_end > OSTickTaskTimeMax) { 146 OSTickTaskTimeMax = ts_end; 147 } 148 OS_CRITICAL_EXIT(); 149 }
这段代码中,将第17行的OSTickCtr加1,这个变量用于记录OS从启动到现在已经经历的时钟节拍的个数。若变量后面是Ctr,则表明这是个计数的变量,Ctr是Counter的缩写。
2.2.2 节拍列表
接下来了解OSCfg_TickWheel这个数组的数据结构,这个数据结构可以解决我们开始提出的问题——怎么才能快速查找已经到期的任务?首先μC/OS-III会定义一个全局变量数组OSCfg_TickWheel[OS_CFG_TICK_WHEEL_SIZE],里面用于存放延时、超时检测等全部信息,加上使用链表将它们串起来,就组成了我们所说的节拍列表——Tick List,如图1-5所示。
每个任务都有一个TCB数据结构类型的变量,里面包含很多任务信息,跟本章相关的有以下几个。
注意:本书前面介绍任务控制块时没有深入介绍其全部元素,只在介绍相关内容的时候才将其一一“披露”出来,这是本书风格之一。笔者阅读源码的时候也不是一看到任务控制块就全部将其看完。
1)TickNextPtr、TickPrevPtr:共同组成双向链表,每个双向链表对应挂在数组OSCfg_TickWheel的一个元素中。双向链表中越后面的任务越晚达到期限,这对理解OS_TickListUpdate函数流程也是有帮助的,插入操作也遵守这条原则。
2)TickCtrMatch:该变量与OSTickCtr相匹配的时候,任务脱离TickList。
3)TickRemain:任务脱离TickList剩下的时钟节拍数时,可以知道其值可以由TickRemain=OSTickCtr-TickCtrMatch得到。
4)TaskState:任务状态,前面已详述。
5)TS:上一次等待事件发生的时间戳,接收到可能还需要一定的时间。严格来说,等待被强制结束、等待对象被删除、等待超时的时候都会把当时的时间戳保存到TS中,本章介绍的就是等待超时。
6)PendStatus:等待相关的状态,这个参数的值包含以下几个:
❑OS_STATUS_PEND_OK:表示处于正常的等待状态或者等待已经完成。
❑OS_STATUS_PEND_ABORT:等待后被其他任务强制放弃了,内核对象还是存在的。
❑OS_STATUS_PEND_DEL:内核对象被删除。
❑OS_STATUS_PEND_TIMEOUT:该变量与第1章介绍的任务状态中的OS_TASK_STATE_PEND_TIMEOUT不一样。任务挂起的时候是OS_STATUS_PEND_OK,超时的时候才会变成OS_STATUS_PEND_TIMEOUT。而当任务开始等待且有超时限制时,任务状态就是OS_TASK_STATE_PEND_TIMEOUT、OS-STATUS_PEND_OK、OS_STATUS_PEND_TIMEOUT用来描述等待的状态,OS_TASK_STATE_PEND_IZMEOUT用来描述任务的状态是超时等待。
7)TickSpokePtr:任务在OSCfg_TickWheel[OS_CFG_TICK_WHEEL_SIZE]数组的哪个元素的双向链表里。这个数据结构在将任务移出节拍列表的时候,就可以帮忙找到任务是插入哪个元素中,并会找到那个双向链表。
8)PendOn:描述任务等待的对象,包含以下几个值。
❑OS_TASK_PEND_ON_NOTHING:没有等待的对象。
❑OS_TASK_PEND_ON_FLAG:等待事件标志,即等待其他任务设置相关事件标志。
❑OS_TASK_PEND_ON_MULTI:等待多个对象。这里的多个对象只能是消息队列和信号量的混合。
❑OS_TASK_PEND_ON_MUTEX:等待二值信号量。
❑OS_TASK_PEND_ON_Q:等待消息队列发送消息。
❑OS_TASK_PEND_ON_TASK_Q:等待发送过来的任务消息队列。注意跟下面的OS_TASK_PEND_ON_Q 区分开来,其不同如图2-1所示。从图中可以看到,任务消息队列专属于某个任务;而普通消息队列则属于任何任务,任何任务都可以在其上面等待。在实际使用中,任务消息队列的使用更广泛,因为很少会有多个任务等待一个消息队列这种情况,反而是获取数据后传输给特定的任务处理这种情况比较多。
❑OS_TASK_PEND_ON_SEM :等待信号量。
❑OS_TASK_PEND_ON_TASK_SEM:等待发送过来的任务信号量,注意跟上面的OS_TASK_PEND_ON_SEM 区分开来,同普通消息队列和任务消息队列。
图2-1 普通消息队列跟任务消息队列的区别
2.2.3 哈希算法检测到期任务
当一个任务要进行延时和超时检测时,内核会将这些任务插入这个数组中的不同元素,每个时钟节拍到来时就会检测这些数组里的元素。为了能快速检测到到期的任务,可将任务插入数组OSCfg_TickWheel的时候对其进行分类,代码清单2-7是插入时的部分操作。这段代码在OS_TickListInsert函数中,OSCfg_TickWheelSize是一个const修饰的变量,在我们给出的例程中此值为13。根据任务TickCtrMatch对OSCfg_TickWheelSize的余数决定该任务插入在数组OSCfg_TickWheel的那个元素中。更新检查的时候,先对当前的OSTickCtr取余,再根据取余结果查看数组OSCfg_TickWheel对应的元素即可。这里实际上是利用取余来减小检查的范围,如果TickCtrMatch和OSTickCtr各自对OSCfg_TickWheelSize的余数不相等,那么TickCtrMatch和OSTickCtr肯定不相等。即使余数相等,两个数值也不一定相等,需要进一步进行检查。这样,实际上每次检查的任务个数最多只是全部的1/OSCfg_TickWheelSize;而如果一个个检查,最大个数可能就是全部的个数。也许有人会想,是不是OSCfg_TickWheelSize越大,每次检查的最大个数不就越小吗?是的,但是数组OSCfg_TickWheel[OSCfg_TickWheelSize]也会更大。如果单片机空间比较充足,则OSCfg_TickWheelSize设置可大些。其实上述做法就是哈希算法,数据分类是利用了取余运算。哈希算法还可以利用其他的规则进行数据分类,后面介绍的哈希算法的数据分类过程默认都是基于取余运算的。
代码清单2-7 插入TickList部分操作代码
1 spoke = (OS_TICK_SPOKE_IX)(p_tcb->TickCtrMatch % OSCfg_TickWheelSize); 2 p_spoke = &OSCfg_TickWheel[spoke];
了解了原理之后,代码清单2-6中的其他内容就可以很好理解了。审阅时可结合图1-5,在第19行先取出余数、第20行根据余数取出数组元素的指针;然后在第21行取出该元素双向链表上的第一个任务控制块。接着进入死循环,用switch筛选出任务不可能的状态,因为任务是从TickList中提取出来的,所以不可能是就绪、挂起、等待等状态。如果这部分内容不明白,需要回头查看任务状态的那部分内容。
现在查看OS_TASK_STATE_DLY的状态。先更新TickRemain,检查TickCtrMatch和OSTickCtr是否相等,如果相等,就表明该延期到了,要准备就绪。注意,如果检查延期到了,就继续在循环里检查,因为可能TickList后面延时的时间跟当前的相等。如果当前的任务还没有到达期限,则后面的任务肯定也没有到达期限,因为越后面,TickCtrMatch越大。
第二个是OS_TASK_STATE_PEND_TIMEOUT,这时如果检测到TickCtrMatch和OSTickCtr相等,就说明等待的时候设置了一定的时间限制,并且在等待时间发生之前限制的时间已经超时。第64~68行是针对给任务发送消息的,超时了对应的事件还没有发生,等待的有可能是消息,没有等待到故将消息指针和消息的大小(单位为字节)都清空。第49行将当前的时间戳赋给任务控制块的元素TS,任务就绪后返回任务就可以返回相应的时间戳信息。OS_PendListRemove是将任务从等待列表中删除,接着OS_TaskRdy将任务从TickList(如果有插入这个列表)中删除并插入RdyList,最后修改任务控制块的一些参数。后面几种任务状态的处理很容易理解,留给大家去思考。
注意:结构体指向其元素有两种方式:一种是结构体指针用的,格式为“结构体指针->元素”;一种是结构体变量用的,格式为“结构体变量.元素”。μC/OS-III中使用的大多是第一种。
2.3 总结
CPU以一定的频率产生中断,这就是时钟节拍。每个时钟节拍到来时,OS首先给时钟节拍任务发送一个信号量,时钟节拍任务收到信号量之后就更新TickList。当任务插入列表的时候,会将某常数余数相同的放在特定数组的一个相同元素里,并用链表将其串起来。系统首先会计算OSTickCtr对常数的余数,取出数组中存放与这个余数相同的那个元素;然后根据链表的指向找出任务并进行判断和相应的操作。