uCOS-III 学习记录(9)——时基列表
参考内容:《[野火]uCOS-III内核实现与应用开发实战指南——基于STM32》第 13 章。
1 数据类型定义和宏定义
1.1 时基列表相关宏定义和全局变量(os_cfg_app.h/c & os.h)
在 os_cfg_app.h 中,宏定义时基列表的大小,其推荐值为任务数/4,推荐使用质数,不推荐使用偶数。如果算出来的大小是偶数,则需加 1 变成质数。
1 | /* 时基列表大小 */ |
接下来在 os_cfg_app.c 中,定义两个全局变量,分别是时基列表(数组)和时基列表的大小。注意需要在 os.h 中声明。
1 | OS_TICK_SPOKE OSCfg_TickWheel[OS_CFG_TICK_WHEEL_SIZE]; /* 时基列表 */ |
在 os.h 中定义 32 位的 Tick 计数器,即 SysTick 周期计数器,记录系统启动到现在或者从上一次复位到现在经过了多少个 SysTick 周期。每发起一次 SysTick 中断,该变量就会加一。
1 | OS_EXT OS_TICK OSTickCtr; /* SysTick 周期计数器 */ |
1.2 时基列表定义(os.h)
在 os.h 中定义时基列表结构体,其组成为:
- FirstPtr:用于指向 TCB 双向链表的第一个节点。时基列表的每个数组元素都记录着一条 TCB 单向链表的信息,被插入该条链表的 TCB 会按照延时时间做升序排列。
- NbrEntries:记录 TCB 链表有多少个节点。
- NbrEntriesMax:记录 TCB 链表最多的时候有多少个节点,在节点增加时会刷新,在删除节点时不刷新。
1 | typedef struct os_tick_spoke OS_TICK_SPOKE; /* 时基列表重命名为大写字母格式 */ |
1.3 修改 TCB 定义(os.h)
在 TCB 中新增加了 5 个成员,分别是:
- TickNextPtr:双向链表中指向下一个 TCB 节点。
- TickPrevPtr:双向链表中指向上一个 TCB 节点。
- TickSpokePtr:指向时基列表数组的指针,即用来指示该任务 TCB 属于哪条双向链表。
- TickCtrMatch:该值表示当前时基计数器的值加上任务要延时的周期。
- TickRemain:表示任务还需要延时多少个 SysTick 周期。该变量替代了 TaskDelayTicks 的功能,所以可以去掉 TaskDelayTicks。
1 | struct os_tcb{ |
因此,目前实现的 uCOS 一共有两个列表:就绪列表和时基列表,它们均根据自己的规则建立一个 TCB 双向链表,列表中的数组会存放双向链表的信息。就绪列表的数组下标表示优先级,时基列表的数组下标表示时间周期。
下图展示的是时基列表的结构,它其中一个数组元素存储了一条 TCB 双向链表信息,这是一条有 3 个节点的双向链表,注意每个节点都回指了这个数组元素的地址,第三个节点是漏画了。
2 时基列表的相关函数
2.1 初始化时基列表 OS_TickListInit()(os_tick.c)
该函数用于初始化时基列表数组,将全部成员清零。
1 | /* 初始化时基列表 */ |
需要在 OSInit() 中调用此函数以完成初始化工作。初始化后的时基列表如下所示:
2.2 往时基列表插入任务控制块 OS_TickListInsert()(os_tick.c)
该函数的作用是将任务 TCB 插入到时基列表中,即对应的 TCB 双向链表,注意这是一个升序排序的双向链表,按照延时时间的长度升序排序。该函数完成的工作有:
- 获得该任务要延时的时间 time 后,在 TCB 中记录延时到期时的时间,即将当前时基计数器的值加上任务要延时的时间记录在 TickCtrMatch。
- 在 TCB 中记录任务要延时的时间。TickRemain 的功能可等价为 TaskDelayTicks 的功能。
我们先停一下,举个例子来理解上面的原理。比如现在时基计数器 OSTickCtr = 10(即从系统启动到现在已经过去了 10 个 SysTick 周期),任务需要延时的时间 time(TickRemain) = 2(即任务需要延时 2 个 SysTick 周期),那么意味着当 OSTickCtr = 12 (即从系统启动到现在已经过去了 12 个 SysTick 周期)时任务延时结束,此处的 12 就是 TickCtrMatch 的值。当时基计数器等于 12 时,捕捉到该任务延时结束(所以这也是 Match 的意思?)。
嗯…看到这,你是不是觉得 OSTickCtr 有点像时间戳的作用了…但其实,OSTickCtr 记录的是“周期的时间戳”,而真正的时间戳记录的可是真实的时间哦!
有意思的是,TickRemain 其实可有可无,它更多的是作为计算剩余时间时的过渡变量,在本节涉及到 TickRemain 的代码中有反应这一点。而在 2.4 节中,你可以试着注释掉涉及到 TickRemain 的代码行,你会发现这对仿真结果没有一点影响。
回忆一下前几章中任务延时的办法:SysTick 发起一次中断,系统就遍历就绪列表中的 TCB,给每个 TCB 中的 TaskDelayTicks 减一。现在我们的做法是:定义了一个全局变量,从系统启动就开始计数。任务结束延时的时期,就是这个全局变量加上延时的周期长度,这个值我们记录在 TCB 中。为什么要换方法呢?你看看这两种办法的区别:前者是我们帮每个 TCB 减一,因此比较麻烦;后者是我们在外面计总的时间,你们这些要延时的 TCB 自己看好时间,到时间后你自己站出来报到。而且更绝的是,我们将这些 TCB 按照延时时间排好顺序,如果前一个 TCB 延时时间未到,那后面的 TCB 看都不用看,肯定没到时间。你看,把复杂的事情简单化,uCOS 绝吧!
- TickCtrMatch 除以时基列表的数组大小 OSCfg_TickWheelSize 求余,作为下标访问时基列表数组。
继续以上面例子为例来说明。现在,TickCtrMatch = 12,我们定义 OSCfg_TickWheelSize = 17,求余后是 12,因此要访问的是 OSCfg_TickWheel[12]。这里运用了哈希算法。接下来,这个任务将插入到 OSCfg_TickWheel[12] 下的双向升序链表中,剩余的工作就是大家熟悉的插入链表操作了。
- 如果双向链表为空:则该 TCB 的前向、后向指针域皆为零。
- 如果双向链表不为空:需要遍历整个链表,访问每一个节点,计算该节点的剩余时间,与待插入节点的剩余时间比较一下:如果被访问节点的大,那就插入到它前面去;否则,就是待插入节点的大了,插入到被访问节点的后面去。如果遍历完了都没找到比待插入节点大的,那就排在最后面去。
- 最后,新插入的 TCB 的 TickSpokePtr 需要指向对应的数组元素。
继续以上面例子说明。这个任务已经插入到 OSCfg_TickWheel[12] 下的双向升序链表中了,那么该任务 TCB 的 TickSpokePtr 将指向 OSCfg_TickWheel[12],以说明自己在时基列表中的第 12 个位置。
1 | /* 往时基列表插入一个任务 TCB,根据延时时间的大小升序排列 */ |
再举个例子。我们现在 OSTickCtr = 10,OSCfg_TickWheelSize = 12,有以下三个任务,情况分别是:
- 任务 1:需要延时 1 个周期。因此,TickRemain = 1,TickCtrMatch = 11,需要插入的数组下标是 11 % 12 = 11。
- 任务 2:需要延时 13 个周期。因此,TickRemain = 13,TickCtrMatch = 23,需要插入的数组下标是 23 % 12 = 11。
- 任务 3:需要延时 25 个周期。因此,TickRemain = 25,TickCtrMatch = 35,需要插入的数组下标是 35 % 12 = 11。
那么插入时基列表后的情况如下图:
注意,这三个任务 TCB 都在同一条链表中,那只是数字凑巧而已。假如我把任务 2 的延时改为 14 个周期,那么插入的数组下标是 0,会插到别的链表中。
2.3 在时基列表删除一个指定的任务控制块 OS_TickListRemove()
该函数是在时基列表中删除一个指定的任务 TCB,完成的工作有:
- 根据 TCB 的 TickSpokePtr,获取对应的时基列表数组元素,进而获取链表的头指针。
- 若头指针为空,则说明链表不存在,直接退出。
- 若不为空,说明链表存在,删除指定节点。
- 删除完后,将该 TCB 的前向、后向指针域、剩余时间全部清零。节点数减一。
1 | /* 往时基列表删除一个指定的任务 TCB */ |
2.4 检查任务延时是否到期 OS_TickListUpdate()(os_tick.c)
该函数的作用是:更新时基计数器,扫描时基列表中的任务延时是否到期。完成的工作是:
- 时基计数器加一。
- 由当前时基计数器的值求余,获得时基列表的数组下标,进而获得对应的双向链表。
- 开始遍历链表,访问每一个节点,若发现有节点延时未到,那么后面的节点就不用访问了,因为链表是升序排序的,后面的节点延时肯定未到;若发现有节点延时到了,那么将该任务设置为就绪态,然后继续访问下一个节点。
发现了吗?访问时基列表的哪个数组元素,是跟当前时基计数器的值是息息相关的,通过哈希算法来访问对应的数组元素。不过,这样做的目的似乎不是很明确(?)。
1 | /* 更新时基计数器,扫描时基列表中的任务延时是否到期 */ |
举个例子。我们定义 OSCfg_TickWheelSize = 12,在 OSTickCtr = 7 时,插入了以下三个任务,情况分别是:
- 任务 1:需要延时 16 个周期。因此,TickRemain = 16,TickCtrMatch = 28,需要插入的数组下标是 11 % 12 = 11。
- 任务 2:需要延时 28 个周期。因此,TickRemain = 28,TickCtrMatch = 35,需要插入的数组下标是 23 % 12 = 11。
- 任务 3:需要延时 40 个周期。因此,TickRemain = 40,TickCtrMatch = 47,需要插入的数组下标是 35 % 12 = 11。
时基列表的情况如下图:
当 SysTick 中断来临时,OSTickCtr = 8,对 OSCfg_TickWheelSize(等于 12)求余等于 8,则对 OSCfg_TickWheel[8] 下面的链表进行扫描,从图中可以得知,8 这个索引下没有节点,则直接退出。这三个 TCB 是在 OSCfg_TickWheel[11] 下的链表,根本不用扫描,因为时间只是刚刚过了 1 个时钟周期而已,它们只是刚刚插入时基列表,远远没有达到它们需要的延时时间。
2.5 时基列表运行全景
当我们把时基列表的相关代码都实现出来以后,很有意思的事情发生了:我们假定 OSCfg_TickWheelSize = 17,即时基列表的大小为 17,那么:
- 在系统刚启动时 OSTickCtr = 0;
- SysTick 中断发生后 OSTickCtr = 1,只检查 OSCfg_TickWheel[1];
- SysTick 中断又发生后 OSTickCtr = 2,只检查 OSCfg_TickWheel[2];
- SysTick 中断又发生后 OSTickCtr = 3,只检查 OSCfg_TickWheel[3];
- …
- SysTick 中断又发生后 OSTickCtr = 16,只检查 OSCfg_TickWheel[16];
- SysTick 中断又发生后 OSTickCtr = 17,只检查 OSCfg_TickWheel[0];
- SysTick 中断又发生后 OSTickCtr = 18,只检查 OSCfg_TickWheel[1];
- …
- SysTick 中断又发生后 OSTickCtr = 33,只检查 OSCfg_TickWheel[16];
- SysTick 中断又发生后 OSTickCtr = 34,只检查 OSCfg_TickWheel[0];
- SysTick 中断又发生后 OSTickCtr = 35,只检查 OSCfg_TickWheel[1];
- …
这样循环往复的检查每一个数组元素。这时我们插入一个需要延时的 TCB 会怎样呢?假设在 OSTickCtr = 4 时插入一个 TCB,延时时长为 3,那么:
- 在系统刚启动时 OSTickCtr = 0;
- SysTick 中断发生后 OSTickCtr = 1,只检查 OSCfg_TickWheel[1],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 2,只检查 OSCfg_TickWheel[2],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 3,只检查 OSCfg_TickWheel[3],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 4,只检查 OSCfg_TickWheel[4],发现没有任务;接着任务发起阻塞,延时时间为 3,计算得出在 OSTickCtr = 7 时延时到期(TickCtrMatch = 7),因此在 OSCfg_TickWheel[7] 下插入 TCB;
- SysTick 中断又发生后 OSTickCtr = 5,只检查 OSCfg_TickWheel[5],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 6,只检查 OSCfg_TickWheel[6],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 7,只检查 OSCfg_TickWheel[7],发现有任务,而且 TickCtrMatch == OSTickCtr,延时到期,移出时基列表;
- …
我们再假设在 OSTickCtr = 25 时插入一个 TCB,延时时长为 19,(OSCfg_TickWheelSize = 17)那么:
- 在系统刚启动时 OSTickCtr = 0;
- SysTick 中断发生后 OSTickCtr = 1,只检查 OSCfg_TickWheel[1],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 2,只检查 OSCfg_TickWheel[2],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 3,只检查 OSCfg_TickWheel[3],发现没有任务;
- …
- SysTick 中断又发生后 OSTickCtr = 25,只检查 OSCfg_TickWheel[8],发现没有任务;接着任务发起阻塞,延时时间为 19,计算得出在 OSTickCtr = 44 时延时到期(TickCtrMatch = 44),因此在 OSCfg_TickWheel[10] 下插入 TCB;
- SysTick 中断又发生后 OSTickCtr = 26,只检查 OSCfg_TickWheel[9],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 27,只检查 OSCfg_TickWheel[10],发现有任务,但是未到期(TickCtrMatch != OSTickCtr),继续位于时基列表中;
- …
- SysTick 中断又发生后 OSTickCtr = 43,只检查 OSCfg_TickWheel[9],发现没有任务;
- SysTick 中断又发生后 OSTickCtr = 44,只检查 OSCfg_TickWheel[10],发现有任务,而且 TickCtrMatch == OSTickCtr,延时到期,移出时基列表;
- …
希望这样写有助于大家理解整个时基列表的运行机制。
3 添加和修改相应代码
3.1 将任务设置为就绪态 OS_TaskRdy()(os_core.c)
这是一个新加入的函数,位于 os_core.c 中,作用是将任务设置为就绪态,具体操作是:
- 从时基列表中删除该 TCB。
- 在就绪列表中加入该 TCB。
1 | /* 任务就绪 */ |
3.2 阻塞延时函数 OSTimeDly()(os_time.c)
该函数需要修改,作用是将任务设置为阻塞态,具体操作是:
- 从时基列表中加入该 TCB。
- 在就绪列表中删除该 TCB。
- 执行任务调度。
注意:
- 以上工作位于临界段内。
我们发现,阻塞延时函数和上面函数的操作正好是相反的。
1 | /* 阻塞延时 */ |
3.3 SysTick 发起中断调用 OSTimeTick()(os_time.c)
SysTick每发起一次中断,就会调用本函数,完成两件事情:
- 更新时基列表。让时基计数器加一,以及检查就绪列表中有无任务延时结束。如果有,调用 OS_TaskRdy() 进行处理。如果没有,什么都不用做。
- 然后执行任务调度。
1 | void OSTimeTick (void) |
4 时基列表的应用
4.1 主函数 main(app.c)
app.c 不用改动。
1 |
|
4.2 运行过程
4.2.1 在主函数中
- 系统初始化:初始化各种全局变量,初始化优先级表,初始化就绪列表,初始化时基列表,初始化空闲任务(包括初始化空闲任务栈和空闲任务 TCB)。
- CPU 初始化:暂为空。
- 关中断:因为此时 OS 未启动,若开启中断,那么 SysTick 将会引发中断,打断初始化流程。
- 初始化 SysTick:配置 SysTick 为 10ms 中断一次,Tick = 10ms。
- 创建任务:包括创建任务栈和任务 TCB,以及将 TCB 插入到就绪列表中,在优先级表对应位置置位。
- 启动系统:先找到最高优先级,然后开始运行最高优先级对应的任务(最高优先级为 1,即为 Task1),启动第一次任务切换(此时将完成最后的初始化流程,即有关 PendSV 的中断优先级配置,接着触发 PendSV 异常,发起任务切换),将 CPU 占有权交给任务 Task1。
4.2.2 在 Task1 中
- flag1 = 1。
- 执行到阻塞函数 OSTimeDly:将 Task1 的 TCB 插入到时基列表中(TickCtrMatch = 2),将就绪列表中的 TCB 移除(同时在优先级表中的相应位置,即优先级 1 的位置清零),然后启动任务调度。
- 执行任务调度 OSSched:任务调度器先找到最高优先级,然后再找到最高优先级的任务。 TCB。如果发现该任务就是当前任务,则不进行任务切换。在本案例中发现最高优先级为 2,对应任务是 Task2,不是当前任务,则发起任务切换(发起 PendSV 异常)。
- PendSV 异常处理程序:保存 Task1 的状态,加载 Task2 的状态,更新全局变量的值。
4.2.3 在 Task2 中
- flag2 = 1。
- 执行到阻塞函数 OSTimeDly:将 Task2 的 TCB 插入到时基列表中(TickCtrMatch = 2),将就绪列表中的 TCB 移除(同时在优先级表中的相应位置,即优先级 2 的位置清零),然后启动任务调度。
- 执行任务调度 OSSched:任务调度器先找到最高优先级,然后再找到最高优先级的任务 TCB。如果发现该任务就是当前任务,则不进行任务切换。在本案例中发现最高优先级为 3,对应任务是 Task3,不是当前任务,则发起任务切换(发起 PendSV 异常)。
- PendSV 异常处理程序:保存 Task2 的状态,加载 Task3 的状态,更新全局变量的值。
4.2.4 在 Task3 中
- flag3 = 1。
- 执行到阻塞函数 OSTimeDly:将 Task3 的 TCB 插入到时基列表中(TickCtrMatch = 2),将就绪列表中的 TCB 移除(同时在优先级表中的相应位置,即优先级 3 的位置清零),然后启动任务调度。
- 执行任务调度 OSSched:任务调度器先找到最高优先级,然后再找到最高优先级的任务 TCB。如果发现该任务就是当前任务,则不进行任务切换。在本案例中发现最高优先级为 31,对应任务是 空闲任务,不是当前任务,则发起任务切换(发起 PendSV 异常)。
- PendSV 异常处理程序:保存 Task3 的状态,加载空闲任务的状态,更新全局变量的值。
注意,此时时基计数器为 0,三个任务的延时时间都是 2 个 SysTick 周期,OSCfg_TickWheelSize = 17,因此均位于 OSCfg_TickWheel[2] 下的双向链表。
4.2.5 在空闲任务中 SysTick 发起中断
- 执行 OSTimeTick:时基计数器加一(OSTickCtr = 1),检查时基列表(OSCfg_TickWheel[1]),发现各个任务的延时未到期(TickCtrMatch = 2)。最后发起任务调度,发现不用进行任务切换,空闲任务继续运行。
- 再次发起中断,执行 OSTimeTick:时基计数器加一(OSTickCtr = 2),检查时基列表(OSCfg_TickWheel[2]),发现各个任务的延时已到期(TickCtrMatch = 2),将它们全部置为就绪态。置为就绪态的过程是:在时基列表中删除对应 TCB,在就绪列表中加入对应 TCB(同时将 Task1、Task2、Task3 的优先级在优先级表中的相应位置重新置位)。最后发起任务调度,发现最高优先级为 1,对应的是 Task1,切换到 Task1 运行。
4.2.6 又在 Task1 中
- 将全局变量 flag1 由 1 变成 0。
- 执行到阻塞函数 OSTimeDly:将 Task1 的 TCB 插入到时基列表中(TickCtrMatch = 4),将就绪列表中的 TCB 移除(同时在优先级表中的相应位置,即优先级 1 的位置清零),然后启动任务调度。
- 执行任务调度 OSSched:任务调度器先找到最高优先级,然后再找到最高优先级的任务 TCB。如果发现该任务就是当前任务,则不进行任务切换。在本案例中发现最高优先级为 2,对应任务是 Task2,不是当前任务,则发起任务切换(发起 PendSV 异常)。
- PendSV 异常处理程序:保存 Task1 的状态,加载 Task2 的状态,更新全局变量的值。
如此反复后,三个任务又一次进入阻塞态,此时时基计数器为 2(OSTickCtr = 2),三个任务的延时时间都是 2 个 SysTick 周期(TickCtrMatch = 4),OSCfg_TickWheelSize = 17,因此均位于 OSCfg_TickWheel[4] 下的双向链表。 接下来的步骤跟上面的类似,在发起第四次 SysTick 中断时(OSTickCtr = 4),检查时基列表(OSCfg_TickWheel[4]),发现三个任务已经延时完毕,于是它们又进入到就绪态中。
4.3 实验现象
实验现象与上一篇记录是一样的,也符合以上分析,如图所示:



