第1章 实时操作系统及μC/OS-III简介
本章首先介绍从单片机应用程序框架引入的实时操作系统,接着介绍怎么学习μC/OS-III源码,然后简单介绍μC/OS-III的文件结构和数据结构、任务、内核对象、常见代码段等,为后面的章节做铺垫。有些概念刚开始接触可能不是很容易理解,可以先忽略它们继续阅读下去,后面就会有“柳暗花明又一村”的感觉。
1.1 单片机应用程序框架
1.1.1 前后台系统
在单片机应用程序中,最常用的就是前后台系统,通常由一个大循环和中断组成,大循环是后台,中断是前台。前后台系统的程序流程比较简单,但是,当芯片处理的事情越来越多、越来越复杂的时候,前后台系统并不能保证实时性。这在一些协议栈等实时性要求高的场合是不允许的,这时编写程序的人就要重新设计程序框架或者使用本书介绍的实时操作系统帮忙进行合理的任务调度,让紧急的事务优先执行。
顺序执行的前后台系统的程序如代码清单1-1所示。
代码清单1-1 前后台系统程序框架
main() { Peripheral_Init(); //外设初始化 while(1) { process(); //程序主要处理部分 } }
除了简单的顺序执行那样的前后台系统程序外,还可以采用时间片轮询法加以改进。
时间片轮询法是多个任务以一定的频率执行,就像多个任务一起无干扰地执行一样。下面看看一个简单的时间片轮询法的代码框架是怎么实现的。
本次介绍使用时间片轮询法创建多个任务并一起运行,LED1任务每500ms转换一次状态, LED2任务每1s转换一次状态,LED3任务每2s转换一次状态,实现起来非常简单。首先看看main函数的流程,时间片轮询法中任务的编写方式如代码清单1-2所示,并且放入while(1)循环中。
代码清单1-2 任务编写方式
01 if(Task_delay[i]==0) 02 { 03 Task(i); 04 Task_Delay[i]=XXX; 05 }
Task_delay是一个数组,有多少个任务就有多少个数组元素,我们在例程1-1中设置为3个,但一开始数组元素都设置为0。第一次判断肯定会进入if判断里面执行任务Task(i)的代码,执行完这个任务之后,Task_Delay[i]变成了XXXX。这里假设XXXX=1000,下次判断如果Task_Delay[i]不为0,就不会执行任务Task(i)。如果使用一个定时器每1ms将Task_Delay[i]减1,那么在1000ms之后的判断条件又成立了,并且任务执行的间隔是1000ms,如此循环往复,就可以让任务Task(i)的执行频率大致为1Hz。在while(1)这个循环中,还可以创建其他任务,并且可以通过设置Task_delay[j]的大小来决定任务执行的频率。想要理解好上面的内容,需要忽略任务运行的时间、定时器中断服务程序执行的时间。只要不在任务代码里添加太长的延时、执行时间长的任务,那么任务的执行频率跟理想化的不会相差太远,而导致前后台系统实时性差的也正是这些原因。
01 /***************************************************************************** 02 * 函数名称: main 03 * 输入参数: void 04 * 输出参数: int 05 * 功 能: 程序入口 06 * 作 者: 骁龙Lyc 07 * 举 例: 无 08 ***************************************************************************/ 09 int main(void) 10 { 11 /*LED灯管脚的配置*/ 12 LED_ Config(); 13 14 /*这里中断频率配置为1000Hz*/ 15 Interrupte _Config (72000); 16 17 /* Infinite loop */ 18 while (1) 19 { 20 if(Task_Delay[0]==0) 21 { 22 LED1_TOGGLE; 23 24 Task_Delay[0]=500; 25 } 26 27 if(Task_Delay[1]==0) 28 { 29 LED2_TOGGLE; 30 31 Task_Delay[1]=1000; 32 } 33 34 if(Task_Delay[2]==0) 35 { 36 LED3_TOGGLE; 37 38 Task_Delay[2]=2000; 39 } 40 } 41 }
前面介绍了中断服务程序会在每次中断时将Task_delay这个数组的所有元素执行减1操作。接下来看看嘀嗒定时器的中断服务程序。
01 /***************************************************************************** 02 * 函数名称: Interrupte_Handler 03 * 输入参数: void 04 * 输出参数: void 05 * 功 能: 时间片轮询法中定时器的中断服务程序 06 * 作 者: 骁龙Lyc 07 * 举 例: 1ms,1次中断 08 ***************************************************************************/ 09 void Interrupte_Handler(void) 10 { 11 unsigned char i; 12 for(i=0;i<NumOfTask;i++) 13 { 14 if(Task_Delay[i]) 15 { 16 Task_Delay[i]--; 17 } 18 } 19 }
中断服务程序其实很容易理解,在for循环中将NumOfTask个任务对应于数组Task_Delay中的元素都减1,但是在减1之前要判断元素是否为0,如果为0就停止减1操作。通过上述代码,就简单地实现了时间片轮询法,可以在此基础上任意地添加任务。想对一般程序来说,这段代码的实时性比抢占式实时操作系统差。宏观上,时间片轮询法的各个任务也不会互相影响,好似多个任务一起相安无事地在进行着,实际上,若任务很多或者任务执行的时间比较长,还是会影响任务的执行频率。比如其中一个任务霸占CPU的时间足1s,而其他任务在这1s内完全是不执行的,即执行频率变为0,这在某些系统中是不允许的。
1.1.2 嵌入式实时操作系统
前后台系统就像一个人从头到尾去做一件事情,偶尔还被叫去处理一些突发事件(中断)。编写程序的人可完全掌握程序的运行流程,但是当事情多的时候可能无法把所有的事情都做好。而引入实时操作系统就像是请了一个管理团队,这个管理团队会帮你协调好这些事情,这样在处理事情的时候就会显得井井有条,提高了CPU处理复杂事情的能力。但是请一个管理团队这个过程会占用一些资源,比如请管理人员的费用等,若公司的运行效率提高了,那么花这些费用还是值得的。在实时操作系统中这就对应着增加CPU的负担、占用芯片的内存。
实时操作系统通过一系列软件管理让一个CPU形成多个线程,就像有多个CPU在一起执行一样。至于这是怎么实现的,就是本书要讲解的重点。
实时操作系统中比较重要的是实时性,即要求系统有比较快的响应速度和执行速度。在时间片轮询法中,任务是一个一个执行的,如果其中一个任务的执行时间过长,那么会影响到其他任务的执行,这就不适合实时性要求高的系统。为了应对更复杂的程序开发,嵌入式实时操作系统应运而生。不管当前任务是否放弃使用CPU,嵌入式系统中的任务可以随时切换到优先级比较高的任务。所以,只要将任务的优先级设置得足够高,这个任务的实时性就很好。实时操作系统可以分为硬实时操作系统和软实时操作系统。硬实时操作系统要求在规定的时间内必须完成任务,而软实时操作系统要求越快完成越好。
实时操作系统又可分为不可剥夺型操作系统和可剥夺型操作系统。不可剥夺型内核只有在当前线程放弃使用CPU之后,其他的线程才可以占用CPU。而可剥夺型内核只要存在有更高优先级的线程就绪,低优先级的线程就会被打断,高优先级线程就占有CPU。μC/OS-III属于可剥夺型内核。
在可剥夺型操作系统中,任务代码随时都可能被打断而派去执行另外的任务,这时任务可能正在执行往一个变量写入数值的过程,而这个过程是需要多个指令的。如果写入过程只进行到一半,任务就被打断,而且这个变量有可能在中断或者其他的任务中被读取,那么读取到的这个变量将是一个错误的值。所以写入的这个过程就不能被中断或者被其他任务抢占。这种类似主要的程序段我们称为临界段。临界段是程序不能被中断或者其他优先级的任务打断的程序段。一般来说,有两种方式可以保护临界段:一种是关中断,另一种是锁调度器。其中,锁调度器可以防止其他任务访问临界段代码,但是不能防止中断访问临界段代码。
注意:
1)变量的读取如果只在一个任务的上下文中,那么就不需要保护这个变量的读/写过程。
2)如果变量只可能在其他的任务中读取,而不可能被中断读取,那么可以采用锁调度器的方式,避免了关中断,节省了关中断的时间。
因为线程可以随意被高优先级打断,所以需要保存当前线程运行的上下文,以及注意临界段的保护。因此可剥夺型内核实现起来更困难。
1.2 如何使用和学习μC/OS-III源码
好的学习方法事半功倍。本节将结合笔者学习μC/OS-III源码的经验来介绍下如何学习μC/OS-III源码。
1. 查阅文档,动手实践
在学习源码之前,还是先让自己对μC/OS-III有个整体的感知,这是本书对读者的最低要求。一般来说,开发板例程会提供μC/OS-III直接可用的程序,你可以在开发板例程的基础上照猫画虎写几个任务并运行之。或许你不知道任务的奇怪参数是什么意思,但这也丝毫不会影响你编写简单的程序。创建完任务后最好还使用一些内核对象,如信号量、消息队列、定时器等,这些也可以照猫画虎,但也可以自己照着μC/OS-III的手册和函数注释来使用这些内核对象。下面举例说明笔者一开始是怎么使用定时器的。笔者刚开始接触μC/OS-III,是在做一个比较大的项目,需要用到多个定时器,了解用硬件定时器太麻烦,而μC/OS-III能提供软件定时器。首先到定时器的文件中看看有什么函数可以调用,如图1-1所示。
在函数列表中可看到一个OSTmrCreate函数(见图1-1),无论是变量名、宏定义还是函数名,μC/OS-III命令都十分规范,让人很容易见名知意,而通过OSTmrCreate这个函数名称就可以知道这是一个创建定时器的函数。到底要怎么调用这个函数呢?μC/OS-III函数注释得非常详细,如图1-2所示。
图1-1 OSTmrCreate函数的位置
找到OSTmrCreate函数的定义处,函数前面就有详细的注释。通过注释可以知道函数的用途,应该输入什么样的参数,会有什么返回值,有什么注意事项等,可以根据这些提示来调用函数。注释不可能面面俱到,读完注释后,在调用过程中可能还会有很多不能理解的地方,比如定时的单位是什么。这要靠你对μC/OS-III的理解,可以查阅一些相关书籍,本书也会讲解μC/OS-III部分函数的使用。为什么是部分呢?在看源码注释的时候,我们会看到一些函数的注释中包含这样的注释:
* Note(s) : 1) This function is INTERNAL to uC/OS-III and your application MUST NOT call it.
图1-2 OSTmrCreate函数注释
如果函数的注释中有这样的注释,说明在使用μC/OS-III的时候不能调用他们,这些函数一般都是内核操作相关的,如果随便调用可能导致意想不到的错误,最好还是不要调用他们,所以本书也就不讲解这些函数是怎么使用的。代码清单1-3展示了怎么调用函数OSTmrCreate。
代码清单1-3 函数OSTmrCreate的调用
1 OSTmrCreate ((OS_TMR *)&TmrOfKey, 2 (CPU_CHAR *)"TmrOfKey", 3 (OS_TICK )0, 4 (OS_TICK )1, 5 (OS_OPT )OS_OPT_TMR_PERIODIC, 6 (OS_TMR_CALLBACK_PTR )cbTimerOfKey, 7 (void *)0, 8 (OS_ERR *)&err);
本书是在笔者阅读μC/OS-III源码和使用μC/OS-III的基础上进行介绍的。只翻译注释来讲解每个函数怎么使用是没有多少意义的。我们在解析每个源码之前必须先讲解怎么使用函数。看函数源码解析的时候最好先按照本书的编排方式看看函数是怎么使用的,函数的使用也可以看成是源码解析的一部分,因为函数的使用会介绍函数的功能、函数的参数等,这些都是理解函数必不可少的信息。
用OSTmrCreate创建完定时器后,定时器是否就可以运行了呢?实践证明还是不可以。当时笔者接着查看了定时器文件的函数,又看到另一个函数OSTmrStart,可能还要调用这个函数来开启定时器才能运行,如图1-3所示。这样调用函数之后发现定时器就真的运行了。
由以上过程可以看到,调用μC/OS-III函数的过程还是挺方便的。调用函数其实不难,但要结合注释或者一些书籍的讲解才能真正用好这些函数。本书配套了每个内核对象的使用例程,注释也十分详细,跟着例程照猫画虎地使用这些内核对象也是一种不错的选择。
或许有人会想,如果已经有相应移植好的工程,那么在上手μC/OS-III之前就先进行移植。这是完全没有必要的,因为官方帮我们将大部分μC/OS-III移值到了CPU上,加上移植的过程并不简单,移植需要建立在对CPU内核和μC/OS-III有足够理解的基础上。本书将移植μC/OS-III放在最后介绍,也可以说是因为μC/OS-III的移植是最难的。
2.循环渐近,深入了解
以上介绍了怎么用好μC/OS-III,接下来介绍怎么深入学习μC/OS-III源码。最开始编写本书的时候,笔者在想, μC/OS-III中那么多内容究竟应该按照什么样的顺序讲解才是最好的。一开始最好挑最容易的,与整个μC/OS-III联系最少的那些源码进行讲解。本书讲解的顺序相对来说是比较好的。所以,首先要对嵌入式操作系统和μC/OS-III有一个大致的了解。接着了解比较容易的时钟节拍和定时器、多值信号量。由于多值信号量跟二值信号量、消息队列、事件标志联系比较紧密,所以将这些内容放在一起介绍。最后讲解任务切换、任务相关等内容。每学习一个内核对象之前,最好先了解内核对象的数据结构。
图1-3 OSTmrStart函数的位置
1.3 μC/OS-III文件结构简介
μC/OS-III文件将按照由底层到上层的排列顺序进行整理。下面根据图1-4中的序号对μC/OS-III的文件结构进行介绍。
①配置文件。通过配置文件定义宏的值就可以轻易地裁剪其他不用的代码。
②应用程序。应用程序包含任务的定义和声明,用户主要编写的是这部分代码。
③μC/OS-III与CPU无关代码。这部分代码与CPU无关,可以不做修改移植到任何CPU,本书主要讲解这部分代码。
④库。这部分主要是底层函数库,比如字符串的一些常规操作、一些通常的数学计算等。
⑤μC/OS-III与移植相关代码,如果读者想要移植μC/OS-III到不同平台上,那么可以根据CPU来修改这部分代码。
⑥μC/OS-III与CPU相关代码。
图1-4 μC/OS-III文件结构
从图1-4中可以看出μC/OS-III文件结构层次分明,我们仅需修改⑤、⑥这两部分的代码即可,直到不同的CPU。
1.4 μC/OS-III数据结构简介
学习OS(操作系统),很重要的也是很基础的就是要先了解它里面复杂繁多的数据结构。数据结构是程序操作的对象,操作的过程都建立在这些数据结构的基础上面,操作对象搞明白了,操作的过程也很容易懂。不管学习什么OS的源码,笔者建议大家一定要在前面花些时间去了解它的数据结构,并做做笔记、画画图。
如果读者自学μC/OS-III,则可能会被μC/OS-III中“指来指去”的指针搞晕。为什么要搞这么复杂的数据结构呢?数据结构虽然复杂,但是操作却容易,在函数的入口只要输入一个简单的变量就可以找到很多相关的变量。同时,设计好的数据结构也方便程序的编写。如果读者还没有学过数据结构,建议同时看看《大话数据结构》这本书。对于本书来讲,只要了解前面的线性表章节就可。本书后面介绍内核对象的章节都会讲解相关的数据结构。读者要是清楚这些数据结构,那就在后面讲到这些相关变量操作的时候再回来看看那些图片。笔者也没有记住多少内容,只是看名字就大概知道它们的作用以及它们之间的关系。图片可以让大家更好地理解数据结构的关系,所以本书笔者精心制作了多幅图片来讲解它们之间的关系。下面以节拍列表的数据结构图来讲解应该怎么看这些图片(见图1-5)。
图1-5 TickList数据结构
第一眼看图1-5,读者可能有点看不明白。这种图在本书中很常见,也常见于数据结构的书,本书用这样的图来展示出μC/OS-III中的各种数据结构。从图1-5中首先可以看到由几个小方格组成一个大方格。大方格表示一个结构体类型,大方格上面的名称就是相应的结构体类型的名称;小方格是大方格的成员,小方格里面的名称表示成员的名称。大方格左侧是具体的结构体变量的名称,如图1-5左上角中的OSCfgTickWheel[0]是一个OS_TICK_SPOKE类型的结构体,结构体中有三个成员,分别是FirstPtr、NbrEntries、NbrEntrMax。Entry表示条目,记载数量的变量名经常有这个单词。Nbr是number的缩写,μC/OS-III中有很多变量,千万不要死记硬背,而要根据其命名和习惯来知道它们代表什么,这点对理解源码也是很有帮助的。
从图1-5的右边可以看到几个由单向箭头组成的双向箭头,双向箭头表示双向链表。值得注意的是,虽然TickNextPtr在图中看起来是指向下一个结构体的TickNextPtr,但实际上是TickNextPtr存放下一个结构体的地址。如果不知道双向链表的含义,请参阅数据结构相关的书籍。
从图1-5中还可以看到有很多个箭头,箭头代表指向其他变量的指针,如图1-5中最上方的一个箭头表示的就是OSCfg_TickWheel[0](结构体)的成员FirstPtr存放的是OS_TCB类型的数据。
后面若有相关的图,也是按照上面的解释来解读。
1.5 任务
在μC/OS-III的管理下,可以创建多个任务,并让它们看起来是各自有一个CPU在并发运行。例如在计算机上,我们可以一边浏览网页,一边听歌,这也得亏操作系统的管理。任务一般都是死循环,如果只想任务运行一次,那么可以在执行完任务后将其删除。OS_TCB结构体类型可以定义任务控制块,每个任务都会有这样一个结构体变量,里面包含任务的各种信息,包括优先级、任务的状态、堆栈的基地址、任务名称等。OS_TCB为每个任务定义的任务控制块可以看成是任务的“身份证”,“身份证”包含任务的各种信息。
任务的状态
理解好任务的几个状态十分重要,内核中所有的程序几乎都涉及这些状态的转化。任务将其状态保存在任务控制块的元素TaskState中。
1)OS_TASK_STATE_RDY:就绪状态。μC/OS-III可能有多个任务处于就绪状态,但只能是其中优先级最高的任务占用CPU,因为CPU只有一个,不可能多个任务一起并行运行。运行的任务是就绪状态。创建任务完成的时候也是就绪状态。
2)OS_TASK_STATE_DLY:延时状态。用官方的延时函数(OSTimeDly/OSTimeDlyHMSM)将其设置为这种状态,这时任务会放弃CPU让其他就绪任务中优先级最高的任务运行,如果没有任务就绪,系统就会运行优先级最低的空闲任务——这其实就是不断地给变量做加法的任务。
3)OS_TASK_STATE_PEND:任务在等待某些事情的发生,比如等待其他任务释放资源后发送一个信号量来让任务运行,也可以是其他的任务发送消息过来等。将其设置为这种状态的一般都是名字中含有Pend的内核函数,比如OSQPend、OSSemPend等。
4)OS_TASK_STATE_PEND_TIMEOUT:也是任务在等待某些事件的发生,不过这是要经过超时检测的,一旦任务调用等待函数OSQPend、OSSemPend等将任务处于等待状态,就要设置超时的时间。如果超时时间设置为0,那么任务就是OS_TASK_STATE_PEND,即无限期地等下去,直到事件发生。如果超时时间设置为N(N>0),设置为等待后的时间N内任务状态就是OS_TASK_STATE_PEND_TIMEOUT,在等待N个系统节拍后事件还是没有发生就会退出等待状态而转为就绪状态。
5)OS_TASK_STATE_SUSPENDED:挂起状态,可以理解为强行暂停一个任务。
6)OS_TASK_STATE_DLY_SUSPENDED:这种情况就是任务自己先产生一个延时,延时没有结束的时候又被其他的任务挂起。注意,不是挂起后又被延时,因为挂起的时候任务已经被剥夺了CPU的使用权,而延时的时候只能自己延时自己。需要这两种状态都解除才可以就绪。
7)OS_TASK_STATE_PEND_SUSPENDED:这种情况也是任务自己先等待一个事件的发生,还没有等到事件发生就又被挂起。需要两种状态都解除才可以就绪。
8)OS_TASK_STATE_PEND_TIMEOUT_SUSPENDED:与上面的状态一样,只是加了一个超时检测。需要两种状态都解除才可以就绪。
9)OS_TASK_STATE_DEL:任务被删除后的状态。任务被删除后将不再运行,要让其恢复运行只能重新创建任务。
1.6 内核对象简介
内核对象有很大一部分是为任务服务的,比如想在两个任务之间传递数据就可以采用消息队列。任务之间是相互独立的,它们之间的“沟通”是通过内核来完成的。所有的内核对象包括任务、堆栈、信号量、事件标志组、消息队列、消息、互斥信号量、内存分区、软件定时器等。
1.6.1 信号量
这里的信号量主要是指多值信号量。多值信号量包含两项功能:一项是管理资源,一项是标志事件的发生。管理资源的一个很通俗的例子就是停车场,比如停车场开始的停车位有10个,每用掉一个就将停车位剩余数量减1,这时信号量就相当于停车场外剩余停车位的个数,这里管理的资源就是停车位。当停车位为0时就不可以将车开进停车场。想象如果停车场外没有这个标志或者没有人充当这个标志来管理,那么停车场在满的情况下外面的车还是不断地开进去,最后将导致整个停车场瘫痪。管理的资源如内存空间、I/O口等如果没有管理也会出错。下面以单片机内部的串口为例进行介绍。如果串口使用过程中被其他任务占用,那么输出的结果将不伦不类,如同一个句子写一半后接着另外句子的开头。这也说明芯片的外设在抢占式实时操作系统的管理下,如果在不同的任务中被使用,则需要用信号量保护起来。操作系统给编程带来便利的同时也带来了额外需要考虑的问题。信号量的另一项功能是标志事情的发生,这时初始值要设置为0,表示没有发生事件。若程序有时候想让两件事情同步,就可以先让信号量作为同步的中介。第一个任务的一个事件先发生后,想等待另外一个任务的另外一个事件发生,就可以先等待(等待的英文为pend)一个信号量。等待的事件发生后调用OS内部提交(提交的英文为post)信号量的函数,函数就会把等待信号量的任务解除等待,并置于就绪队列。
1.6.2 事件标志组
实际上,信号量的第二项功能才是事件标志组经常干的事情。事件标志组可以用来标志多个事件的发生,同信号量一样,也可以进行post和pend操作。事件标志组可以设置多个位,最大的位数由数据类型OS_FLAGS决定,在我们的例程中是32位,代表多个事件的情况。其他的任务可以等待某些位被置1或者清0,然后允许任务执行,这里时间标志组充当了中间的媒介。一个现实的例子就是键盘的组合操作,通过事件标志就可以很容易设置组合键。任务先设置一个两个位都被置1的等待事件,然后将这两个位置0,当一个按键被按下时就post这个按键相应位置1的情况。若两个按键都被按下,那么两个位都被置1,等待这两个事件发生的任务不再被阻塞,继续执行要执行的内容。事件标志组听起来可能有点抽象,读者可能不知道具体为何物,这是很正常的,保持好奇心去阅读就好。
1.6.3 消息队列
消息队列可以分为消息和队列。消息的集合才是队列,队列是用来管理消息的。消息队列主要用来在任务之间传输数据。消息可以简单地理解为一个存储发送数据的信息(包括信息的地址、信息的字节大小等)的存储空间。
1.6.4 互斥信号量
前面已有信号量的存在,为什么还要互斥信号量(互斥信号量英文为Mutex)呢?这里面存在一个称为优先级反转的问题。抢占式内核的宗旨就是让高优先级的任务尽量先占用CPU。信号量(这时信号量用来管理资源)已经被低优先级占用的时候,高优先级就要等待这个低优先级释放信号量。这是可以理解的,也是很合理的,资源不像CPU那样可以随便切换,比如前面讲过的串口打印数据,打印一半就切换给高优先级的任务,结果肯定不是我们想要的,我们需要的是各自完整的打印结果。更有甚者,即使目前占用资源的低优先级任务被挂起,也不可以“切换”资源给等待这个信号量的高优先级任务。等待资源的这个过程,高优先级已经被挂起,意味着高优先级任务的优先级已经“降低”到占用信号量的任务的较低优先级,“降低”的原因是受资源不能切换的限制。而这时如果还有一个处于前面提到的这两种高低优先级中间的优先级任务想要占用CPU,那么CPU就会被中优先级的这个任务占用,因为它的优先级比低优先级高。这不符合我们前面的宗旨,中优先级占用CPU实际上让高优先级等待更多的时间才运行,所以不能让中优先级占用CPU!解决办法就是优先级继承,就是提高前面提到的低优先级任务的优先级,让它跟等待资源的高优先级处于同一个优先级。前面提到的优先级“降低”的问题就迎刃而解,“降低”到跟自身一样相当于没有“降低”。因此就产生了互斥信号量。互斥信号量比多值信号量多一个优先级反转的机制,互斥信号量可以避免优先级反转的问题,但是操作过程占用的时间也更多。
1.6.5 内存分区
内存分区主要用于减少内存碎片,相关内容请参见相应章节。
1.6.6 软件定时器
跟硬件定时器一样,软件定时器也有定时的功能。相对来说,软件定时器的配置比较容易,但是定时精度很难达到硬件定时器的标准。因此,软件定时器可以用于精度要求相对不高的一些事务中,比如1分钟后关闭LCD屏幕的背光等。
1.7 μC/OS-III常见的程序段
本节介绍的是经常在程序中出现的相似的程序段。
1.7.1 中断嵌套层数统计
每进入一层中断,μC/OS-III的OSIntNestingCtr就会加1,每退出一层中断就要减1。中断嵌套层数功能之一就是可以用来检查μC/OS-III是否进入了中断,OSIntNesting大于0就代表至少进入了一层中断,这样可以防止一些不能在中断中调用的函数被调用,比如任务调度函数OSSched,在中断服务程序运行的时候进行了任务切换,其后果肯定是相当严重的。中断通常是比较紧急的事情,就好像这边着火了,你肯定不能救火救到一半去看场电影再回来。还有一些函数的临界段比较长,并且没有必要在中断中运行,比如信号量创建函数OSSemCreate等是不允许在中断中调用的,调用这些函数之前都会先检查是否进入中断。进入中断之前,用户需要调用增加嵌套层数的函数,如代码清单1-4所示。
一个中断服务函数通常如下。
代码清单1-4 μC/OS-III中断服务函数示例
1 void XXX_ISR(void)
2 { 3 OSIntEnter(); //嵌套层数加1 4 5 XXXXXXXXXXXX; //中断执行内容 6 7 OSIntExit(); //嵌套层数减1 8 }
1.7.2 开中断和关中断
开关中断进入临界段的用法如代码清单1-5所示。
代码清单1-5 临界段程序的编写
1 XXX_Fuction(void) 2 { 3 CPU_SR_ALLOC(); 4 CPU_CRITICAL_ENTER(); 5 6 //临界段代码 7 8 CPU_CRITICAL_Exit(); 9 }
后面介绍的每个函数基本上都有这些代码段,因为涉及太多内核的东西,刚接触的读者可能比较难以理解,所以在这里先不讲解其具体代码的实现,只要知道这些函数的功能和怎么使用即可。
注意:不能在关中断期间阻塞任务运行,即不能调用阻塞函数,如包含pend或者delay等词的函数,这些都涉及任务切换。而任务切换跟中断是相关的,如果在关中断期间阻塞任务,则不会达到阻塞任务的效果。
1.7.3 使能中断延迟的锁住和开启调度器
使用中断延迟的时候,进入和退出临界段有两种方式。关中断前面已经介绍了,下面介绍锁住和开启调度器。
锁住调度器在开启中断延迟的时候分为两种情况:一种是调用宏OS_CRITICAL_ENTER(),直接锁住调度器;另一种是调用宏OS_CRITICAL_ENTER_CPU_CRITICAL_EXIT(),调用这个宏之前先关闭中断,接着锁住调度器,恢复中断在关闭之前的状态(注意这里不是打开中断)。这种常用于临界段前面(部分)不被中断打断,而后面部分只要不被其他任务打断即可。前半部分先关中断,后半部分调用开执行OS_CRITICAL_ENTER_CPU_CRITICAL_EXIT(),这样就减少了关中断的时间。没有使用中断延迟的时候整段都要关中断,这样就延长了关中断的时间。
解开一层调度嵌套也有两种方式:一种是调用宏OS_CRITICAL_EXIT(),另外一种是调用宏OS_CRITICAL_EXIT_NO_SCHED()。从宏的名字上看,它们的区别是在解开一层调度嵌套后是否尝试进行任务调度。临界段中是无法实现任务调度的,退出临界段的时候可能需要进行任务调度,所以一般调用的是OS_CRITICAL_EXIT()。但是有些函数如提交函数在调用的时候可以选择是否要进行调度,如果这时退出临界段就先不要调度。因此,要使用OS_CRITICAL_EXIT_NO_SCHED(),在函数的最后应根据选项情况是否进行调度。
1.7.4 没有使能中断延迟的锁住和开启调度器
没有使用中断延迟的时候,进入和退出临界段的操作如代码清单1-6所示。对比使用中断延迟,以下代码中的4种锁住和开启调度的方式非常“简单暴力”,直接调用开关中断的宏。这样做的坏处是导致关中断的时间变长。
代码清单1-6 没有使用中断延迟进入和退出临界段的代码
1 #define OS_CRITICAL_ENTER() CPU_CRITICAL_ENTER() 2 3 #define OS_CRITICAL_ENTER_CPU_CRITICAL_EXIT() 4 5 #define OS_CRITICAL_EXIT() CPU_CRITICAL_EXIT() 6 7 #define OS_CRITICAL_EXIT_NO_SCHED() CPU_CRITICAL_EXIT()
OS_CRITICAL_ENTER_CPU_CRITICAL_EXIT()之所以是一个空宏,是因为在调用这个函数之前肯定已经关中断,这时本想切换到锁住调度器来进入临界段,可是没有使能中断延迟进入临界段,所以只能关中断。
注意:开关中断、开启和锁住调度器名称上有OS和CPU的区别。OS指的是μC/OS-III内核代码,是属于软件层面的,所以OS_CRITICAL_ENTER 就是软件层面的进入临界段,即锁住调度器。CPU是你使用的平台,这里使用的是基于Cortex-M3内核的STM32单片机,指向的是硬件层面,CPU_CRITICAL_ENTER就是硬件层面的进入临界段,即关中断。
1.7.5 中断嵌套检测
中断嵌套检测程序如代码清单1-7所示。
代码清单1-7 中断嵌套检测代码
1 #if OS_CFG_CALLED_FROM_ISR_CHK_EN > 0u 2 if (OSIntNestingCtr > (OS_NESTING_CTR)0) 3 { 4 *p_err = OS_ERR_XXX; 5 return; 6 } 7 #endif
以上代码主要用于检测程序是否在中断中被调用,如等待函数、延时函数等阻塞线程的函数,甚至创建内核对象、删除内核对象等都有这段程序,因为这些函数有较长的临界段。
OSIntNestingCtr这个变量是用来检测中断嵌套层数的。
第2行先判断中断嵌套的次数,每进入一次中断,OSIntNestingCtr就会加1。所以,如果大于0,就是至少进入了一层中断。若没有进入任何中断,则OSIntNestingCtr为0。如果是在中断中调用,且直接返回,那么不再执行下面的程序段。
如果不确定函数是否在中断中被调用,就查看函数的内容有没有这段代码即可,一般阻塞的函数肯定是不可以在中断中被调用的。
1.7.6 调度器嵌套检测
调度器嵌套检测程序如代码清单1-8所示。
代码清单1-8 调度器嵌套检测代码
1 if (OSSchedLockNestingCtr > (OS_NESTING_CTR)0u) 2 { 3 *p_err = OS_ERR_SCHED_LOCKED; 4 return; 5 }
同中断嵌套,调度器每被锁住一次,调度器嵌套层数的变量OSSchedLockNestingCtr就会加1。一些需要任务调度的函数,比如延时的时候需要调用系统提供的函数先把当前的任务置于延时状态,然后调度就绪列表中优先级最高的任务。如代码清单1-8所示,在延时函数的前面会先检测调度器是否已经被锁住,即判断OSSchedLockNestingCtr是否大于0,如果被锁住,就退出延时函数,延时失败。
注意:调度器若嵌套多层,也需要解开多层后才可以执行调度。
1.7.7 时间戳
为了测量任务运行时间、任务等待内核对象时间等,μC/OS-III保存了很多时间戳。时间戳可以理解为是记录时间点的一个量,如代码清单1-9所示。在程序段XXX运行的前后分别保存时间戳,并相减得出XXX程序段运行的时间,最后更新该程序段运行的最大时间。定时器任务、节拍任务、统计任务、中断延迟提交任务都有这段程序,这些任务都是系统任务。
代码清单1-9 计算程序运行时间代码段
ts_start = OS_TS_GET(); XXX; ts_end = OS_TS_GET() - ts_start;
if (ts_end > OSTickTaskTimeMax) { OSTickTaskTimeMax = ts_end; }
1.7.8 错误类型
如果程序运行过程中出现错误,则程序会返回这些错误,以便用户得知出错。为了程序的健壮性,调用系统函数之后最好有相应的错误处理。有些错误类型可以很容易地从其名字中看出来,而且在后面的程序中出现的概率非常高,因此先放在这里介绍。
1)OS_ERR_NONE:正常情况,没有错误。
2)OS_ERR_OBJ_TYPE:内核对象类型错误,根据判断内核对象变量的元素Type即可知道内核对象类型是否错误,比如在一个定时器删除函数中,可以根据元素Type来判断输入的内核对象变量是否是定时器类型。
3)OS_ERR_OPT_INVALID:选项设置有误。
4)OS_ERR_SCHED_LOCKED:调度器被锁住。
5)OS_ERR_TIME_DLY_ISR:在中断中调用延时函数。
1.7.9 参数检测
参数检测程序如代码清单1-10所示。
代码清单1-10 参数检测代码段
1 #if OS_CFG_ARG_CHK_EN > 0u 2 if (p_tmr == (OS_TMR *)0) 3 { 4 *p_err = OS_ERR_TMR_INVALID; 5 return (OS_TMR_STATE_UNUSED); 6 } 7 #endif
代码清单1-10中OS_CFG_ARG_CHK_EN是参数检测的宏,若置1,则会在很多内核函数的前面进行参数检测。主要用于检测传递的指针是否是空指针、参数范围是否符合要求等。
1.7.10 内核对象类型检测
内核对象类型检测程度如代码清单1-11所示。
代码清单1-11 内核对象类型检测代码
1 #if OS_CFG_OBJ_TYPE_CHK_EN > 0u 2 if (p_tmr->Type != OS_OBJ_TYPE_XXX) 3 { 4 *p_err = OS_ERR_OBJ_TYPE; 5 return (DEF_FALSE); 6 } 7 #endif
比如,调用一个定时器删除函数,则传入的内核对象参数类型必须是定时器。这里用于检测传入的内核对象类型是否是定时器。
1.7.11 安全检测
安全检测程序如代码清单1-12所示。
代码清单1-12 安全监测代码段
1 //是否定义安全检测的宏 2 #ifdef OS_SAFETY_CRITICAL 3 if (p_err == (OS_ERR *)0) 4 { 5 //如果传入的参数p_err是空指针,那么将进入安全关键异常,这部分代码需要用户自己编写 6 OS_SAFETY_CRITICAL_EXCEPTION(); 7 return; 8 } 9 #endif
如果定义了安全检测的宏,那么作为参数输入的存放返回错误类型的指针p_err就不能为空指针,否则在调用OS_SAFETY_CRITICAL_EXCEPTION后返回,而且OS_SAFETY_CRITICAL_EXCEPTION需要自己编写。
1.7.12 安全关键IEC61508
安全关键IEC61508程序如代码清单1-13所示。
代码清单1-13 安全关键IEC61508相关代码
1 //是否启动安全关键 2 #ifdef OS_SAFETY_CRITICAL_IEC61508 4 if (OSSafetyCriticalStartFlag == DEF_TRUE) { 5 *p_err = OS_ERR_ILLEGAL_CREATE_RUN_TIME; 6 return; 7 } 8 #endif
在定义了安全关键的宏OS_SAFETY_CRITICAL_IEC61508后,并且OSSafetyCriticalStartFlag被置为DEF_TRUE时,不再允许调用相关创建函数。
1.8 总结
本章首先介绍了单片机程序框架,从最简单的前后台系统到时间片轮询法、再到嵌入式实时操作系统。它们分别对应不同层次的应用,有着各自的局限和优势。
接着介绍了μC/OS-III整体的文件框架,从CPU相关移植代码到μC/OS-III的主体代码(跟CPU无关),再到用户的应用层代码。层次感非常强,方便移植μC/OS-III到不同的CPU上去。
最后介绍了μC/OS-III任务、内核对象以及常见代码段,这些是为后面的内容做铺垫,了解即可。