第2章 基于时间触发的合作式调度器
本期教程带领大家了解调度器的相关知识,主要是合作式调度,抢占式调度和时间片调度以及合作式调度器的使用方法。
2.1调度器介绍
2.2合作式调度器设计
2.3合作式调度器注意事项
2.4实验说明
2.5实验总结
2.1 调度器介绍
简单的说,调度器就是使用相关的调度算法来决定当前需要执行的任务。所有的调度器有一个共同的特性:调度器可以区分就绪态任务和挂起任务(由于延迟,信号量等待,邮箱等待,事件组等待等原因而使得任务被挂起)。调度器可以选择就绪态中的一个任务,然后激活它(通过执行这个任务)。当前正在执行的任务是运行态的任务。不同调度器之间最大的区别就是如何分配就绪态任务间的完成时间。
嵌入式实时操作系统的核心就是调度器和任务切换,调度器的核心就是调度算法。任务切换的实现在各个RTOS中区别不大,基本相同的架构,任务切换也是相似的。调度算法就有些区别了,比如咱要讲的μCOS-III和FreeRTOS在抢占式调度器算法上就是两种不同的调度方法,下面我们主要了解一下合作式调度器,抢占式调度器和时间片调度器。
2.1.1 合作式调度器
合作式调度器实现起来比较简单,易学易用,但是要真正的运用好这种调度器,还是要用心研究下的,要不就不存在那么多嵌入式领域的工程师做这方面的研究,比如咱们上期教程里面说的Michael J. Pont,他是这方面的资深专家,有自己的团结和公司,他们设计了一款处理器内嵌基于时间触发的合作式调度器,重要的是已经商用。这么说来应该算是这个领域成功的典范,下面的截图是他们设计这款处理器的框图。
上面的HWSCH就是硬件调度器的意思。
如何理解合作式调度器呢,如果用过FreeRTOS的话,FreeRTOS就支持合作式调度(和本期教程要讲的调度方式稍有区别)。没有用过FreeRTOS的话,μCOS-II里面的Timer组件也有类似的功能(μCOS-III里面还保留着这个功能),其实这些东西都是相同的,而且在Micrium公司出的μCOS-II或μCOS-III配套官方书籍中说的不可剥夺性内核(Non-Preemptive Kernel)和咱们这里要说的合作式调度器也是类似的。简单的来说,合作式调度器就是根据用户的设置时刻(周期或者单次)来执行相应的任务,每个时刻只有一个任务可以执行,这些任务间不支持被强占,直到该任务自愿放弃 CPU 的控制权。下面要说的合作式调度器特性就是摘自时间触发嵌入式模式那本书。
合作式调度器
l 合作式调度器提供了一种单任务的的系统结构
操作:
l 在特定的时刻被调度运行(以周期性或者单次方式)
l 当任务需要运行的时候,被添加到等待队列。
l 当CPU空闲的时候,运行等待队列中的下一个(如果有的话)。
l 任务运行直到完成,然后由调度器来控制。
实现:
l 这种调度器很简单,用少量代码即可实现。
l 该调度器必须一次只为一个任务分配存储器。
l 该调度器通常完全由高级语言(比如“C”)实现。
l 该调度器不是一种独立的系统,它是开发人员代码的一部分。
新能:
l 设计阶段需要小心以快速响应外部事件。
可靠性和安全性:
l 合作式调度简单,可预测,可靠并且安全。
2.1.2 抢占式调度器
在实际的应用中,不同的任务需要不同的响应时间。例如,我们在一个应用中需要使用电机,键盘和LCD显示。电机比键盘和LCD需要更快速的响应,如果我们使用前面说的合作式调度器或者后面要说的时间片调度,那么电机将无法得到及时的响应,这时抢占式调度是必须的。
如果使用了抢占式调度,最高优先级的任务一旦就绪,总能得到 CPU 的控制权。当一个运行着的任务使一个比它优先级高的任务进入了就绪态,当前任务的CPU 使用权就被剥夺了,或者说被挂起了,那个高优先级的任务立刻得到了 CPU 的控制权。如果是中断服务子程序使一个高优先级的任务进入就绪态,中断完成时,中断了的任务被挂起,优先级高的那个任务开始运行。
使用抢占式调度器,使得最高优先级的任务什么时候可以执行,可以得到 CPU 的控制权是可知的,同时使得任务级响应时间得以最优化。
总的来说,学习抢占式调度掌握最关键的一点是:抢占式调度器会为每个任务都分配一个优先级,调度器会激活就绪任务中优先级最高的任务。
上期教程提到的embOS,FreeRTOS,μCOS-II,μCOS-III,RTX都支持抢占式调度,可以说这种调度算法在小型嵌入式RTOS中极为流行。抢占式调度器给任务带来快速响应的同时也使得任务间的同步和通信机制显的很麻烦,而且源码中的很多地方都需要设置临界段(通过开关中断来实现)。
2.1.3 时间片调度器
在小型的嵌入式RTOS中,最常用的的时间片调度算法就是Round-robin调度算法。这种调度算法可以用于抢占式或者合作式的多任务中,时间片调度适合用于不要求任务实时响应的情况下。
实现Round-robin调度算法需要给同优先级的任务分配一个专门的列表,用于记录当前就绪的任务,并为每个任务分配一个时间片(也就是需要运行的时间长度,时间片用完了就进行任务切换)。
目前embOS,FreeRTOS,μCOS-III和RTX都支持Round-robin调度算法。
2.2 合作式调度器设计
合作式调度器提供了一种简单而且可预测性非常高的平台。该调度器全部用C语言实现而且成为系统的的一部分。这将使整个系统的运行更加清晰,而且易于开发,维护以及向不同平台上面移植。存储器的开销为每个任务16个字节,对CPU的要求(随时标间隔而变,也就是嘀嗒定时器的周期)很低。
合作式调度器主要由以下几部分组成:
l 调度器数据结构
l 初始化函数
l 嘀嗒定时器中断,用来以一定的时间间隔刷新调度器
l 向调度器增加任务函数
l 使任务在应当运行的时候被执行的调度函数
l 从调度器删除任务的函数(此功能未做)
下面就把这几方面的设计详细讲述下。
2.2.1 调度器数据结构
调度器的数据结构如下:
typedef unsigned char tByte; typedef unsigned int tWord; typedef struct { void (*pTask)(); /*指向任务的指针必须是一个*void(void)*函数;*/ tWord Delay; /*延时(时标)知道下一个函数的运行*/ tWord Period; /*连续运行之间的间隔*/ tByte RunMe; /*当任务需要运行的时候由调度器加1*/ }sTask;
这个结构体需要占用16个字节,为什么是16个字节而不是13个字节,指针函数占用4个字节 + Delay占用4个字节 + Period占用4个字节 +RunMe占用1个字节 = 13个字节。实际测试中发现做了结构体会以组员中占用字节最多的类型做字节对齐,比如上面这个就是按照16个字节来算。这点大家要特别的注意,再举一个例子,大家可以算算这个结构体占用几个字节:typedef struct { unsigned short int RunMe1; unsigned char RunMe2; unsigned char RunMe3; unsignedchar RunMe4; }sTask1;
任务队列的大小通过下面进行定义:#define SCH_MAX_TASKS 5 sTask SCH_task_G[SCH_MAX_TASKS]; /*建立的任务数*/
建立任务的时候要注意不能超过这个最大任务数。
2.2.2 初始化函数
这里的初始化函数主要是指滴答定时器的初始化,以此来产生调度器所需要的时标。一般情况下把时标间隔都设置为1ms。关于嘀嗒定时器方面的知识已经在前面第10章: SysTick实验有详细的讲解,这里就不再赘述了,只把初始化函数贴出来:/* ******************************************************************************************* * 函 数 名: bsp_InitTimer * 功能说明: 配置systick中断,并初始化软件定时器变量 * 形 参: 无 * 返 回 值: 无 ******************************************************************************************* */ void bsp_InitTimer(void) { uint8_t i; /* 清零所有的软件定时器 */ for (i = 0; i <TMR_COUNT; i++) { s_tTmr.Count =0; s_tTmr.PreLoad= 0; s_tTmr.Flag =0; s_tTmr.Mode =TMR_ONCE_MODE; /* 缺省是1次性工作模式 */ } /* 配置systic中断周期为1ms,并启动systick中断。 SystemCoreClock是固件中定义的系统内核时钟,对于STM32F4XX,一般为 168MHz SysTick_Config()函数的形参表示内核时钟多少个周期后触发一次Systick定时中断. --SystemCoreClock / 1000 表示定时频率为 1000Hz, 也就是定时周期为 1ms --SystemCoreClock / 500 表示定时频率为 500Hz, 也就是定时周期为 2ms --SystemCoreClock / 2000 表示定时频率为 2000Hz, 也就是定时周期为 500us 对于常规的应用,我们一般取定时周期1ms。对于低速CPU或者低功耗应用,可以设置定时周期为 10ms */ SysTick_Config(SystemCoreClock/ 1000); }
这里有一点要特别的强调下,时间触发嵌入式模式的作者Michael J. Pont在书中特别强调一个中断的原则,原话是这样的:
it is assumed throughout thisbook that only the ‘tick’ interrupt source is active: specifically, it isassumed that no other interrupts are enabled.
If you attempt to use thescheduler code with additional interrupts enabled,the system cannot beguaranteed to operate at all: at best, you will generally obtain veryunpredictable, and unreliable, system behavior.
意思就是说:如果用户使用时间触发模式,强烈建议只用一个中断,这个中断用于调度器的时标,如果用户还使用了其它中断,那么基于时间触发模式的可预测性和可靠的系统结构将被破坏。
2.2.3 刷新函数
刷新函数的主要功能是:每个时标中断执行一次。在嘀嗒定时器中断里面执行。当刷新函数确定某个任务要执行的时候,将这个任务结构体的成员RunMe加1,要注意的是刷新任务不执行任何函数,这里只是设置 一下RunMe标志,由调度函数根据此标志执行相应任务。
下面是具体的函数代码:/* ******************************************************************************************* * 函 数 名: SCH_Update(void) * 功能说明: 调度器的刷新函数,每个时标中断执行一次。在嘀嗒定时器中断里面执行。 * 当刷新函数确定某个任务要执行的时候,将RunMe加1,要注意的是刷新任务 * 不执行任何函数,需要运行的任务有调度函数激活。 * 形 参:无 * 返 回 值: 无 ******************************************************************************************* */ void SCH_Update(void) { tByte index; /*注意计数单位是时标,不是毫秒*/ for(index = 0; index <SCH_MAX_TASKS; index++) { /*检测这里是否有任务*/ if(SCH_task_G[index].pTask) { if(SCH_task_G[index].Delay== 0) { /*任务需要运行 将RunMe置1*/ SCH_task_G[index].RunMe+= 1; if(SCH_task_G[index].Period) { /*调度周期性的任务再次执行*/ SCH_task_G[index].Delay= SCH_task_G[index].Period; } } else { /*还有准备好运行*/ SCH_task_G[index].Delay-= 1; } } } }
2.2.4 添加任务函数
添加任务函数的主要功能是将任务添加到任务队列上,下面主要是说一下这个函数中参数的功能:tByte SCH_Add_Task(void (*pFuntion)(void), tWord DELAY, tWord PERIOD) 1. void (*pFuntion)(void) :表示函数的地址,也就是将函数名填进去就行了。 2. DELAY :表示函数第一次运行需要等待的时间。 3. PERIOD:表示函数周期性执行的时间间隔
举四个例子说明一下:
l SCH_Add_Task(DOTASK,1000, 0)
DOTASK是函数的运行地址,1000是1000个时标以后开始运行,只运行一次。
l SCH_Add_Task(DOTASK,0, 1000)
DOTASK是函数的运行地址,每个1000个时标周期性的运行一次。
l SCH_Add_Task(DOTASK,300, 1000)
DOTASK是函数的运行地址,300是300个时标以后开始运行,以后就是每1000个时标周期运行一次,也就是说运行时刻是300,1300,2300,3300,4300等等。
l Task_ID= SCH_Add_Task(DOTASK,1000,0);
Task_ID是任务标示符,将任务标示符保存以便以后删除任务。
下面是函数的源代码:/* ******************************************************************************************* * 函 数 名: SCH_Add_Task * 功能说明: 添加任务。 * 形 参:void(*pFuntion)(void) tWord DELAY tWord PERIOD * 返 回 值: 返回任务的ID号 ******************************************************************************************* */ tByte SCH_Add_Task(void (*pFuntion)(void), tWord DELAY, tWord PERIOD) { tByte index = 0; /*首先在队列中找到一个空隙,(如果有的话)*/ while((SCH_task_G[index].pTask!= 0) && (index <SCH_MAX_TASKS)) { index ++; } if(index == SCH_MAX_TASKS)/*超过最大的任务数目 则返错误信息*/ { Error_code_G =ERROR_SCH_TOO_MANY_TASKS;/*设置全局错误变量*/ returnSCH_MAX_TASKS; } SCH_task_G[index].pTask =pFuntion; /*运行到这里说明申请的任务块成功*/ SCH_task_G[index].Delay =DELAY; SCH_task_G[index].Period =PERIOD; SCH_task_G[index].RunMe =0; return index; /*返回任务的位置,以便于以后删除*/ }
2.2.5 调度函数
在前面已经说过,刷新函数不执行任何函数任务,需要运行的任务由调度函数激活。下面是调度函数的源码:/* ******************************************************************************************* * 函 数 名:SCH_Dispatch_Tasks * 功能说明: 在主任务里面执行的调度函数。 * 形 参:无 * 返 回 值: 无 ******************************************************************************************* */ void SCH_Dispatch_Tasks(void) { tByte index; /*运行下一个任务,如果下一个任务准备就绪的话*/ for(index = 0; index <SCH_MAX_TASKS; index++) { if(SCH_task_G[index].RunMe>0) { /*执行任务 */ (*SCH_task_G[index].pTask)(); /* 执行任务完成后,将RunMe减一 */ SCH_task_G[index].RunMe-= 1; /*如果是单次任务的话,则将任务删除 */ if(SCH_task_G[index].Period== 0) { SCH_Task_Delete(index); } } } }
有上面的五步,一个简单的合作式调度器就算设计完成了。
2.3 合作式调度器注意事项
使用合作式调度器要特别的注意下面三个问题,这三个问题也是做时间触发嵌入式模式研究的主要方向,研究时间触发要解决的就是这三个问题。
2.3.1 只有一个中断的原则
这个原则应该在很大程度上限制了基于时间触发的合作式调度器使用范围,比如我们想使用按键中断来及时的响应外部事件。如果不使用中断,那么外部事件将无法得到及时的响应。
2.3.2 任务重叠的问题
任务重叠是怎么回事呢?举个例子:假如有两个任务A和B,A任务每隔1s运行一次,B每隔3s运行一次,两个任务的运行时间都是0.5ms,运行时标是1ms
SCH_Add_Task(TASKA, 0, 1000);
SCH_Add_Task(TASKB, 0, 3000);
这个例子中会出现两个任务同时运行的情况,而且A任务在B任务之前运行,这就意味着如果A任务运行时间有变化,那么B任务将出现“抖动”,当两个任务重叠的时候,则不能在正确的时间调用。
或者,假设以如下的方式调度这些任务
SCH_Add_Task(TASKA, 0, 1000);
SCH_Add_Task(TASKB, 5, 3000);
这样在很大程度上就避免了任务的重叠,因为B任务总是在A任务后5ms才会运行,还有一个解决任务重叠的办法,不过这个办法不适合在软件上面实现(适合用硬件来实现,比如FPGA,这就是为什么Michael J. Pont在TTE32微控制器中集成硬件时间触发的合作式调度器),这里简单的介绍下这个方面的实现思路:首先我们需要得到任务最坏情况的执行时间(这个不容易得到,我们只能得到一个大概的时间,因为很多情况是我们无法现场模拟的),然后设置一个任务最大执行时间,一旦任务超多了这个时间,那么就将任务关闭,然后根据需要执行备份任务,这个备份任务也是偶最大执行时间要求的,要不会影响其它任务的执行。这种解决办法比较难,大家作为了解就行。
2.3.3 任务超时的问题
使用合作式调度器的应用程序有一个重要的要求:任务的运行时间 < 时标间隔 这个要求非常重要,而且实现起来额不容易,特别是程序中含有一些无法确定时间的函数,比如下面这个:do { HSEStatus =RCC->CR & RCC_CR_HSERDY; } while(HSEStatus == 0);
这个时候我们就需要用下面这种形式的函数来实现do { HSEStatus = RCC->CR &RCC_CR_HSERDY; StartUpCounter++; } while((HSEStatus == 0) && (StartUpCounter !=HSE_STARTUP_TIMEOUT));
如果觉得软件计数不够准确,那么就可以将其换成硬件计数,这样就更加的准确些。这里还有一个重要的问题要特别的注意,那就是时标间隔的设置。这个在时间触发嵌入式模式一书中第225页有详细的说明,有兴趣的可以看一下。 |