最近在做OS相关的项目,一方面涉及到大量(通用)操作系统的概念和理解,另一方面要深入到一个小实时操作系统的源代码中,并在此操作系统上开发应用。虽然说内容上并不难,但是操作系统本身是一个非常基础,影响深远的计算机科技的小分支,所以把操作系统的知识重新整理一遍,同时结合实时操作系统的实际情况,形成这样一套笔记,一是对项目的一个回顾,另外也方便将来回来查阅吧。
OS其实是一个很宏大的概念,不好捉摸。根据维基百科的定义,OS是一组管理硬件和为应用软件提供服务的软件。这个说法很精练但依然抽象。我觉得大部分OS扮演着两个最主要的角色:硬件的抽象表达者和资源的管理者。
如果要读取硬盘中的某一个数据,首先得确定盘片,然后确定磁道和扇区,最后通过磁头读取进内存。这实际上是一系列复杂的机械和电子的操作,虽然说复杂,但是影响最终运作结果的因素(读写地址)不多,大部分操作是对于不同的读写都是相同的。如果每一次执行读写硬盘操作,应用程序都要明确地对硬件作出准确的指示,那么应用程序会臃肿无比,程序员也会为编写这样的程序感到非常无趣。
正如David Wheeler的那句名言:“Any problem in computer science can be solved with another layer of indirection.”OS的首要任务正是把这些繁杂的底层硬件的执行细节隐藏,提供给程序和用户一个相对抽象的概念。例如把一系列的机械和电子操作转化为简单的一句代码-读取硬盘(地址)。
一般普通计算机使用者之所以不熟悉底层的这些细节,也正是因为有OS,这个硬件的抽象的存在。他们对计算机运作的认识,只是一个建立在OS基础之上的心理模型(mental model)。
Hard Disk Driver - 《Operating System Concepts》
本篇笔记将简单介绍RTX,包括基本架构,如何在Keil中配置。需要安装ARM-MDK和一块硬件板,笔记以STM32F4Discovery为例子。
尽管把所有程序放在一个大的循环里顺序执行,总是可能的(甚至很多时候是足够的胜任任务的),但是这样做有好几个明显的缺点:
RTOS主要是把要执行的运算包装在小的task里面,这样好的好处是:
一个不成文的小经验:如果源程序大于1MB,那就有必要用RTOS了!
当然,也有很多情况下我们不希望用RTOS的:
为什么选RTX不选别的RTOS,例如FreeRTOS?
其实主要还是看应用,RTX在行业里声誉还是很好的,稳定性强,开发成本低,而且基本功能齐全。
RTX其实是Keil Real-Time Library (RTL)的核心,这个RTL有很多部分的,都是在RTX kernel基础上库。 RTX Kernel本身的话,结构大概如下图:
主要的组件有:mutex互斥锁,memory pool内存池,mailbox邮箱,time(timer)定时器, event事件, semaphore旗语或信号灯,task management进程管理等等和最核心的Scheduler排程器(进程调度器)。
移植一个现有的ARM-MDK工程到RTX上非常简单:
1.在工程配置中选择RTX Kernel作为你的操作系统,如下图:
2.在你的main.c里添加头文件RTL.H:
4.将你原有的函数改造成task,就是在函数返回类型前添加标记 __task (双下划线) 例如:
完成上述几步后,你会发现你的工程里主要多了这三个文件:
第一个是整个RTL的API函数签名。第二个是RTX的配置文件,你可以通过configuration wizard提供的GUI去配置你的OS。最后一个是内核的配置文件,有一些可以实时调用的配置函数。
这里多说说第二个文件,你可以使用text editor去修改代码,也可以使用configuration wizard,如下图:
这里先逐项简单介绍这里的选项:
一般主要调的就时钟频率和排程的设置。
uVision5提供的RTOS是CMSIS-RTOS,有点让人觉得摸不着头脑。其实CMSIS-RTOS是在RTX上的另一层封装。这样做的意义在于,建立在不同RTOS(例如RTX和FreeRTOS)的项目可以用同一套API。对于大部分ARM的核来说,其实其底层就是RTX。
所以其实本质上是一个东西,只是API名称不同。使用CMSIS-RTOS 的好处就是稍微强的移植性。但是考虑到ARM的市场占有和他们RTX本身的兼容性,不用CMSIS-RTOS好像更好。而且两层封装,很容易把人弄晕。
RTX一般是由ARM-MDK链接到预编译的RL-RTX库的,但是修改RTX内核function或者是增添新的function并且在自己的工程里面编译和链接修改后的RTX都是没问题的。本篇笔记主要关于如何在利用ARM-MDK在RTX中添加一个新的function,并且在自己的工程文件里面使用这个新的function。同样地,需要安装ARM-MDK和一块硬件板,笔记以STM32F4Discovery为例子。
这部分内容可能实战中很少会用到!如果你只是应用开发,可以放心跳过。
首先我们要找到RTX的源文件。一般来说路径是 C:\Keil\ARM\RL\RTX,这个文件夹下我们需要的有:RTX_Lib_CM.uvpot, RTX_Lib_CM, Config和SRC下的CM中的部分文件。
SRC下CM文件夹中的HAL_CM1,HAL_CM3可以删除掉,因为STM32F4Discovery用的是Cortex-M4的核。而删除的这两个文件其实是Cortex-M1,Cortex-M3的硬件抽象层(Hardware Abstraction Layer)。不同的板子需要有不同的HAL。
在工程文件夹中,新建一个文件夹,例如MyProject\RTXMod,作为修改后的RTX的源代码文件夹。复制上诉文件并粘贴进RTXMod中,注意保持其相对路径!
然后用uVision打开RTX_Lib_CM。首先注意到工程下有这些文件:
SRC下CM文件夹中的HAL_CM1,HAL_CM3可以删除掉,因为STM32F4Discovery用的是Cortex-M4的核。而删除的这两个文件其实是Cortex-M1,Cortex-M3的硬件抽象层(Hardware Abstraction Layer)。不同的板子需要有不同的HAL。
在工程文件夹中,新建一个文件夹,例如MyProject\RTXMod,作为修改后的RTX的源代码文件夹。复制上诉文件并粘贴进RTXMod中,注意保持其相对路径!
然后用uVision打开RTX_Lib_CM。首先注意到工程下有这些文件:
同样地,从工程中删除HAL_CM1,HAL_CM3文件。工程目标也要作相应的改动,点
接下来就可以Build了。正常情况不会报错。
那么打开RTXMod中的CM4F_LE文件夹,就能够看到RTX_CN4.lib文件,这个就是我们在自己工程文件下编译的RTX库文件。
如果想修改过的RTX被多个工程使用,那么最好的办法就是建立一个多工程的workspace,那么任何对RTX的修改,都会在workspace下的所有工程中得到体现。
首先在uVision中,选择Project-New Multi-Project Workspace...然后选择一个合适的路径,建立workspace文件,例如在MyProject文件夹中建立一个叫MyWorkspacet的workspace。然后就可以添加相应的工程进入这个workspace了。
首先添加你的RTX工程文件,其路径应该是MyProject/RTXMod/RTX_Lib_CM。然后添加你想使用RTX的工程文件。uVision5的工程文件好像还不太支持workspace,所以如果工程文件是uVision5的,得先转存为uVision4的工程文件,再添加到workspace中。
在Blinky能够调用本地的RTX前,需要添加几个文件:
1.RTL.h: 这个文件是编译器自带的头文件,如果要使用修改的RTX的function,那么必须得使用相应修改过的RTX.h。这个文件可以在C:\Keil\ARM\RV31\INC中找到。在RTXMod下创建INC文件夹,将该头文件复制到这个目录下。并在main.c中,加入其路径,如
- #include "C:\Users\User\Desktop\MyProject\RTXMod\INC\RTL.h"
这里使用""而不是<>去标明include的文件,是因为""会让编译器先搜索指定路径,而<>会让编译器直接在预编译的库里寻找相关文件。
2.RTX_Conf_CM.c: 这个文件是RTX的参数配置文件,在MyProject\Config\下,在uVision工程中添加该文件即可。RTX_Conf_CM中include的是原来的RTX,同样得按照main那样,修改include语句。
3.RTX_CM4.lib: 这个就是我们刚刚第一步编译好的库文件,在MyProject\RTXMod\CM4F_LE\下,同样用uVision添加进工程。
完成这几步就可以使用自己编译的RTX了。尝试加入os_sys_init(init);或者其他RTX的function,build和run。没有问题。
首先创建.c文件和相应的.h文件,例如rt_Mod.c和rt_Mod.h,为了和原来的路径系统协调,把这两个文件也放入SRC\CM目录下,然后在RTX工程中添加rt_Mod.c文件。
这个文件需要添加三个头文件,第一就是其对应的.h文件,这里就是rt_Mod.h文件,第二就是RTX_Config.h文件,最后就是rt_TypeDef.h文件,这些都是RTX提供的统一配置和类型定义文件,参考其他的.c文件你可以发现这些文件是必须被include的。
同时,由于我们这里要使用__RL_ARM_VER,所以也必须要引用RTL.h头文件,所以rt_Mod.c最后就是:
相应地,头文件rt_Mod.h是:
最后,在RTL.h中加入函数签名,注意要在Functions Cortex-M部分中添加,而不是Functuions ARM部分添加。
到这一步,就可以在你要使用新function os_sys_version();添加这个语句。然后注意因为你新添加了一个文件进入RTX projct,它还未被编译,所以要在build你的project前要先编译RTX project。
当然也可以Batch Build两个工程。注意选择当前Active Project为你的project,而不是RTX project。点击
至此,大工告成。
RTX本身源代码就是开放的,而且由于其轻量级和实用性都很突出,是一个很出色的RTOS。但它也不是面面俱到,能涵盖所有的工程需求的。修改RTX内核只是为了针对具体项目进一步优化OS本身。当然这个工作量不会小。
另一方面,查看RTX源代码,并尝试修改(或者破坏),也有利于对OS本身的理解。由于现在开发工具和开发手段的优化程度都很高了,很多应用开发并不需要深入到对OS的理解,所以OS源代码总给人庞大而枯燥的感觉。RTX是一个很好的出发点,让我们去了解一些OS更加细节的实现问题。我也希望能够通过结合对RTX内核源代码的理解和书本文献介绍的OS概念结合起来,拓宽和深化自己对OS设计的认识。
进程,英文称呼很多Process, Task 等等,一般通用操作系统称Process的比较多,各种称呼涵义稍微有不一样。一般而言,进程是对一个运行单元的抽象,主要包括内存(code,data,heap和stack),CPU状态(PC,SP和寄存器值等)与其他OS管理相关的内容。进程是一个运行中的程序。在RTX中,一个task就是一个进程。
一般我们有一个进程控制块(Process control block,PCB),用于记录进程的相关信息。在RTX上,这个控制块叫做task control block(TCB),是一个结构体,其中的成员记录了关于该task的信息,其定义在rt_TypeDef.h中:
一个进程会有它自己的周期,会处在不同的进程状态(state,见上面的state成员),不同的状态有不同的意味,不同的状态间可以相互转换。
在RTX中,task的状态是在rt_Task.h中定义的,一共有10种:
简单说来,可以分为4大类,inactive(进程被清理),ready(就绪),running(执行)和waiting(等待)。状态3至9都可以归为等待状态,区别在于他们等待的东西不同,从等待状态触发到就绪状态的条件不同。
进程的创建和消灭都是主要都牵涉到内存分配,排程器的安排,TCB的处理等等,需要具体的OS具体的分析,我们这里贴一下RTX进程创建的源代码:
之前讲到,进程在RTX里的基本形式是:
进程相关操作就是RTX提供的围绕这样一个task的一些基本操作,例如创建,消灭等等。
从应用角度来说,了解以下进程基本操作就足够了:
最主要的是这个:
把函数名填入,和进程的优先度,优先度后面的笔记会介绍。
如果留心看源代码,其实源代码的create操作要求一共4个参数(FUNCP task, U32 prio_stksz, void *stk, void *argv)。我们最基本的这个创建函数并没有接受后两个参数。如果实在有需要,有以下另外三个相关的操作:
这个是用于传递一个初始参数para给相关进程的。例如你有一个LED_On的进程,而你有4个LED,你只有在创建进程时才能决定,你点亮的是哪个LED,那么就可以用这个操作,通过传递参数来决定具体要亮哪个LED。
这个是用来给进程创建自定义stack的。需要传递stack的地址和大小。
这个明显就是上面两个的结合。
以上这些创建操作,返回类型都是OS_TID,进程ID,实际值从0到255。 所以可以先声明一个该类型的值,然后创建进程时让其返回该值。
填入你要消灭的进程的进程ID,TID。
如果要消灭进程本身,用:
如果想要知道当前进程的ID,使用以下操作:
还有一个非常重要的:
这个操作初始化整个RTX,如果不在main中执行这一操作,一切都是空谈。该操作会创建第一个进程,也就是first_task。
一般而言,进程可以创造别的进程,也可以消灭别的进程。但进程只能够消灭本身,而不能创造本身。所以就需要有一个操作去创建第一个进程,然后别的进程可由这个第一个进程去创造。
一下是一个从初始化,到创建第一个进程,到第一个进程创建别的进程,最后消灭自己的一个例子:
这个简单的例子足够应付最基本使用RTX的需求了。
上一节简单记录了进程task。有了进程以后,我们需要关心怎么样分配CPU资源(或者运行时间)给每个进程。那么就要引入排程(scheduling)的概念。排程一般都是OS里面非常重要的一部分,但是在深入进入排程和理解RTX排程器(scheduler)如何运作之前,不妨看看RTX提供的许多简单易容的时间管理相关的操作,这些操作虽然也涉及排程器的运作,但是不需要我们对排程器和相关算法有深刻的理解。
具体的原因,具体介绍完排程器后就会一目了然。
我们可以考虑上一节笔记给的例子:
os_time_get(void);
首先是这个操作,返回一个U32数,为当前操作系统运行的时间,以Timer Ticks Value为单位(见上面RTX配置图),预设是10ms。所以如果返回0x000000C4,那么OS走了1960ms,也就是1.96s。
然后就是三个主动放弃当前对CPU占用的操作。这也是为什么我称之为简单的时间管理操作,因为这看起来并不是排程器要求当前进程放弃其对CPU的占用,而是他们“自愿”放弃的。也就是说,这三个操作,只能在当前进程中使用,而且其目标对象就是当前进程本身。效果都是把他们从运行的状态改变到其他状态。
如果我们看上面的程序,我们会发现,其实如果没有相应的事件管理的话,task1其实是会一直运行直到结束。那么如果task1在某一时刻,执行以下任一操作:
os_tsk_pass();
进程状态从RUNNING(运行)进入READY(就绪),加入一个先进先出的队列。排程器此时会选择下一个队列中已经READY(就绪)的进程去执行,在这里,也就是task2。那么如果task2运行一段时间后也执行了相同操作,那么它就会把运行机会重新交回给task1。
os_dly_wait(delay_time);
进程状态从RUNNING(运行)进入WAIT_DLY(等待延迟)。排程器此时会选择下一个队列中已经READY(就绪)的进程去执行,在这里,也就是task2。和os_tsk_pass()不同的是,进程并不直接进入就绪等待队列,而是等delay_time×Timer Ticks Value之后才重新加入这个先进先出的队列。例如填入5,那么预设情况下,task1就会暂停,等待50ms后,重新加入就绪等待队列。
os_itv_set(interval_time); 和 os_itv_wait(void);
这个得先在进程入口设置周期时间,interval_time,然后在进程中执行该操作的话,进程状态从RUNNING(运行)进入WAIT_ITV(等待周期)。排程器此时会选择下一个队列中已经READY(就绪)的进程去执行,在这里,也就是task2。和os_tsk_pass()不同的是,进程并不直接进入就绪等待队列,而是等interval_time×Timer Ticks Value之后才重新加入这个先进先出的队列。例如填入5,那么预设情况下,task1就会暂停,等待50ms后,重新加入就绪等待队列。但是与os_delay_wait()不同的是,如果在等待周期过程中,没有别的task在占用CPU,这个在等待WAIT_ITV的task是可以进入RUNNING状态的。这个很明显是为有周期性的进程而设的。
这三个介绍完,就到一个定时调用,执行如下操作:
另外在RTX_Config.c中,还有一个类似的原型,不过这次是一个进程,void os_idle_demon(void); 如果当前没有进程运行或处在就绪状态(都在等待状态),那么RTX就会运行这个进程,预设这个进程只是空转,不干任何实际的事情。
上一篇笔记介绍了一些绕开排程器(或调度程序,scheduler)来进行时间管理的一些小方法。这一篇具体介绍RTX的任务调度原理。
RTX主要有三种调度方式:
在正式介绍这些方式之前,先看一下RTX的进程优先等级设置。
每一个task在创建之初都会有一个优先级(os_tsk_create(task_name,priority);)。优先级是一个从0到255的整形数据,该数据越高的task,优先级越高。每一个优先级都有一个先入先出的队列结构。
首先,RTX并不能处理快速中断(FIQ,ARM处理器中最高优先级的中断),相反地,当快速中断发生时,RTX内核可能会被打断。
然后到普通中断,普通中断并不是一个进程,所以不需要设定优先度,但普通中断一定会打断进程。
然后就到优先度为2-255的进程,这些进程会按照先入先出的顺序运行。低优先度的进程不能打断高优先度的进程,但高优先度的进程会打断低优先度的进程。如果当前最高优先度是x,但所有优先度为x的进程都处于等待状态,那么排程器就会考虑下一优先度(x-1)的进程,但一旦任一x进程进入就绪状态,排程器会打断低优先度进程。
优先度为1的进程时轮转进程,下面介绍到轮转排程时会记录它与优先度为2-255进程的区别。
优先度为0的进程为空闲进程。当没有进程执行时,RTX会执行它,并提升其优先度到1.
几个特殊进程的优先度:os_idle_demon(void) 的优先度永远为0, RTX实在没进程可跑才会运行这个进程。os_error (U32 err_code)的优先度永远为255,用于处理错误的。这两个进程原型都在RTX_CONFIG.C文件中。
另外,除了创建时给进程分配优先级,优先级也是可以通过调用以下服务改变:
os_tsk_prio(taskID,priority);用于改变其他进程的优先度。
os_tsk_prio_self(priority); 用于改变当前进程的优先度。
搞清楚RTX的优先级后,其实RTX的调度不难理解。
每一个进程都有不同的优先级,最高优先级的进程会运行,排程器不会终止它,所以它会运行直到它自行中止挂起(blocked),或者被更高优先级的进程打断。自行挂起的办法其实我们上一节介绍过,就三个:os_tsk_pass();, os_dly_wait(delay_time);和os_itv_wait(void);.如果其被中止挂起,其余优先级最高的进程会运行。这个配置的办法就是除能在RTX_CONFIG.C中的Round-Robin Task Switching项。
每一个进程的优先级都是1,每一个进程都会被分配到一个时间片,在运行完这个时间片后,该进程就会加入优先级为1的队列的末端,然后队列最前端的进程继续运行。时间片由RTX_CONFIG.C中的Round_Robin Timeout[ticks]决定,准确时间是Round_Robin Timeout[ticks]×Time tick value[us] (μs). 如上图所示,那么时间片就是10000×5=50000μs.配置该排程需要使能Round-Robin Task switching项。
这个是所有进程都是相同的优先度(例如1,)且除能了轮转式排程。 在这种合作模式下,进程不会被排程器挂起,只能自己中止。
其实RTX的任务调度非常灵活,最常用的就是轮转和抢断式调度混用: 一部分进程的优先度是大于2的,其他进程的优先度是1(轮转式排程)。也就是说如果优先度大于2的进程就绪了,优先运行,如果没有就绪的大于2的进程,那么就先执行轮转式的进程。而且加上不同的进程间可以相互调节进程的优先度,所以调度的自由度很大的。
上一篇笔记讲了一下RTX的三种调度机制。可以看到RTX配置下是有并发的,也就是有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行的情况。实际上并行是会涉及很多问题的,好比如果只是一个人在干活那么他不需要和别人沟通就能完成这个任务,但如果多人同时在干活(或者是为了同一个目的,或者不是为了同一个目的),如果他们需要共享到一些资源,那就涉及到沟通协调的额外付出了。OS的设计也涉及类似的问题。在进一步讨论一些并发会造成的问题前,我们先介绍一下RTX是如何实现进程与进程间的通讯的。
RTX的进程间通讯主要依赖于四种机制,分别是事件(Event),互斥锁(Mutex),旗语或信号量(Semaphore),和邮箱(Mailbox)。前三种机制侧重进程间的同步,邮箱则侧重进程间的数据通讯。这里先讨论事件和互斥锁,信号量和邮箱请参见下一篇。
我们先考虑如下一个场景,A进程运行到一定时间后,需要等待一个外设的信号(或者别的数据),它可以选择忙绿等待(busy-waiting):
设计进程间的通讯的一部分功能就是避免忙等,但更多的是为了解决一下并发性问题。下面我们具体看看事件和互斥锁机制。
简单说来,就是每一个进程有16个标示,每个标示只有1和0的状态。进程可以设置其他进程的标示,也就是说通知其他进程,某事件发生了。在满足一定条件的后,就可以激活这个进程。
相应的事件标示保存在TCB(进程控制块)上,如果进程需要等待事件,将会进入WAIT_OR或者WAIT_AND状态,当事件条件满足之后,就会重新进入READY,等待排程器去调度。
具体说来,当进程执行如下几个操作,就会进入等待状态,直到标示状态满足:
os_evt_wait_and(U16wait_flags,U16timeout);
和事件等待,第一个参数是一个十六位的二进制数,哪一位是1就代表进程需要等待那一位的事件标示。第二个参数设置的是最大等待时间,以time tick value为单位,进程只会等待到最大等待事件,然后恢复为就绪状态等待调度。如果设置为0xFFFF,则最大等待时间为无穷,也就是只会等待事件标示状态满足wait_flags的要求。os_evt_wait_or(U16wait_flags,U16timeout);
与事件等待,参数意义与和事件等待相同。
这两个操作的差别在于(其实应该比较一目了然)对于和事件等待而言,所有标示都满足了,进程才会被唤醒,对于与事件等待而言,任一标示状态满足了就会唤醒进程。 举个例子:
os_evt_wait_and (0xABCD, 0xFFFF);
os_evt_wait_or (0xABCD, 0xFFFF);
第一个语句的话,进程会等待15,13,11,9,8,7,6,3,2,0位(0xABCD)的标示全部被设为1时才会被唤醒,而对第二个语句而言,15,13,11,9,8,7,6,3,2,0位中的任一一位标示被设为1,进程就会被唤醒。
这两个语句返回类型都是OS_RESULT。结果有两种,一是,OS_R_EVT,意思就是事件条件满足了(与或和),进程会被唤醒而且标志会被清零,另外一种就是OS_R_TMO,进程等待超越了最大等待时间。
设置事件标示执行以下操作(一般是别的进程):
os_evt_set(0xABCD,taskID);
这就会设置进程D指定进程的事件标示。
os_evt_clear(0xABCD,taskID);
类似地,这个用于清理进程ID指定进程的事件标示。
另外简单提一下,中断(在RTX中,中断不算进程)也可以设置这些事件标示的,而且RTX提供了一个更加轻量的设置语句:
如果第一次接触互斥锁,那这个概念其实还不容易解释。总的来说,就是一个锁,他基本的运用就是一个进程需要先取钥匙去解锁(有且只有一把钥匙,且取钥匙和还钥匙的过程不可被中断),完成特定任务后还钥匙以便其他进程可以继续使用。
一个简单的应用场景是,整栋房子只有一个公共厕所(共享资源),要使用厕所就要去前台拿钥匙(互斥锁),用完厕所后需要还钥匙给前台。这样做的原因就是为了保证对某些资源的互斥操作(例如你要避免多个进程同时读写一块内存区域)。一些复杂的话题我们迟点再讨论,先简单说说RTX的互斥锁怎么用。
首先要声明一个互斥锁,互斥锁在RTX中是一个结构体。
RTX的进程间通讯主要依赖于四种机制,分别是事件(Event),互斥锁(Mutex),旗语或信号量(Semaphore),和邮箱(Mailbox)。前三种机制侧重进程间的同步,邮箱则侧重进程间的数据通讯。上一篇讲了事件和互斥锁。这次讲一下信号量和邮箱。
信号量其实是一个很抽象的操作系统原语,它最早由荷兰计算机科学家Dijkstra提 出,用于解决多项资源的分配问题。实际上信号量的适用范围非常广,可以很好地解决很多问题。简单说来,信号量有三个部分,第一部分就是一个代币容器(一个 整形变量),记录着可用的资源数,第二部分就是取用资源(P或者proberen,尝试的荷兰语)的操作,第三个就是还回资源(V或者verhogen, 增加的荷兰语)的操作。当资源数量为0时,任何取用资源的操作都会被阻断,直到资源数量增加。刚刚接触可能会觉得跟互斥锁有点难区分,因为RTX中信号量 的相关操作跟互斥锁基本一致。先看看怎么使用RTX中的信号量。
类似互斥锁,首先要声明一个信号量结构体。
OS_SEM semID;
然后需要初始化。
os_sem_init (semID,unsignedtoken_count);
这里与互斥锁不同的是,你需要指定初始的资源数,token_count,可以是任意非负整数,如果是0,就意味着一开始没有资源可用。
然后当你需要取用资源时:
os_sen_wait (semID, timeout );
timeout的意义就不具体多说了,跟前面介绍的一致。如果成功取用了资源(token_count大于0),那么token_count会减一,并且返回OS_R_OK。如果token_count为0,那么就没办法取用资源,那么这个函数会返回OS_R_SEM,并且进程进入WAIT_SEM状态。
当你用完了资源,需要返回资源时,(token_count增加1):
os_sem_send (semID);
和互斥锁不同的是,当前进程不需要拥有资源,也可以调用该服务,去增加资源数!
该服务的中断版本:
isr_sem_set (semID);
一般的误区是,信号量用于管理多个只能独享的资源。我们重新考虑上一次提到的场景:整栋房子只有一个公共厕所(共享资源),要使用厕所就要去前台拿钥匙(互斥锁),用完厕所后需要还钥匙给前台。在这种情况下,使用互斥锁机制肯定没问题,那么使用信号量机制会有问题么?
如果所有人(进程)都循规蹈矩,那没问题。但如果有人没有去厕所,但也还了一把钥匙给前台,那会发生什么事?那就会有两把钥匙,通向同一个厕所,如果当前有 人在上厕所,另外一个人也想要上,前台也会给钥匙给第二个人,那么就会发生尴尬的情况。这个在使用信号量时是有可能的,因为当前进程不需要拥有资源,就能 够os_sem_send!所以哪怕在这种最简单的情况下,也是很有可能误用信号量的。
复杂一点的情况,我们考虑如果有两个公共厕所,如果使用互斥锁,那就要声明并初始化两个互斥锁,分别管理两个厕所。那么使用信号量机制,初始化一个os_sem_init (toilet,2);的信号量会不会有问题呢?如果我们不考虑有人恶意还钥匙(os_sem_send误用)的话,好像没问题,因为信号量本身就是为了这样的场景(管理多个独享资源)而设计的?
实际上,一样是会有问题的,因为信号量实际上是不区分资源的,而且也不会记录资源使用的顺序。按照我们的例子,也就是说前台会有两把相同的钥匙,任一一把都 可以打开两个厕所。假设第一个人去前台拿了一把钥匙,进了厕所A,然后第二个人去前台拿了第二把钥匙,实际上第二个人是无法得知,两个厕所里面有没有人, 如果有,是哪一个厕所里面有人。所以,也很有可能会发生尴尬的情况。
总的来说,当多个独享资源的先后顺序无关时(例如,生产者和消费者问题),使用信号量才比较合适。
或者当进程本身同时是资源的占用者和释放者时,使用互斥锁:
当资源的释放者不一定是进程的占用者时,使用信号量:
信号量的用法实在是太丰富,而且很容易误用,具体可以参见《The Little Book of Semaphores》和RTX的官方文档,这里摘几个经典的用法(修改过)作为例子:
这个用法是用于保证不同函数的调用顺序(这是C语言很缺乏的一个特征),在这个例子里面,信号量的作用就是确保在每一次调用Function2之前,Function1都有一次完整的调用。
这个用法是更通用的Signaling用法,目的是让FunctionA1,functionB1都完成以后,再执行FunctionA2和FunctionB2。
这个用法能保证最多只有五个进程能够同时调用Function();
更多的例子,请参考上面提到的材料。
邮箱在RTX中往往是用于在进程间传输大段数据。简单说来,一个邮箱就是一个用户定义长度的队列。队列的每一个单元都是4bytes长,一个单元可以直接保 存数据(32位),或者保存一个指针(地址),指向另外一段数据。用邮箱的一个问题就是要用户手动分配内存和回收内存。下面先看看有哪些相关操作。
首先要创建一个邮箱,
os_mbx_declare(mailbox_name,mail_slots);
在RTL.h中有这个的定义,具体是:
#define os_mbx_declare(name,cnt) U32name [4 + cnt]
也就是说邮箱其实是一个名字为mailbox_name,长度为mail_slots的U32数组。另外注意到,额外的4个slots,是用于管理邮箱的,而不是用来直接存储信息的。
创建完邮箱后就要初始化这个邮箱:
os_mbx_init (&mailbox_name,sizeof(mailbox_name));
发信:
os_mbx_send(&mailbox,msg_ptr,timeout);
这里的msg_ptr实际上是一个指针,指向需要发送的信息,如果邮箱满了,进程会被阻断,进入WAIT_MBX状态,直到有空间才会返回就绪状态。 timeout的用法和前面的timeout一致。
同样地,在ISR中有一个相应版本:
isr_mbx_send(&mailbox,msg_ptr);
这会先调用isr_mbx_check(),去检查邮箱是否已经满了,如果满了就会放弃当前的信息,并且会被陷入os_error();
收信:
os_mbx_wait (&mailbox, &msg_ptr, timeout );
将收到的信息,存入msg_ptr指向的地址。如果邮箱是空的,进程则会被阻断,进入WAIT_MBX状态,直到有新的信息。
ISR的相应版本为:
isr_mbx_receive(&mailbox,&msg_ptr);
在用邮箱的过程中,会经常涉及到RTX的内存分配问题,如果是变长的内存分配,malloc() 和 free()这些标准函数可以胜任,但RTX另外提供了一种处理定长内存块的机制-BOX。
这里大致简单说一下,具体的用法请参考完整的邮箱例子。
_declare_box(box_name,block_size,block_count);
_init_box(box_name,box_size,block_size);
_alloc_box(box_name);
_calloc_box(box_name);
_free_box(box_name,)
基本上从名字就能知道其意义,和stdlib.h中的标准函数基本对应。
这个例子源于《RL-ARMUser's Guide》,小幅度修改:
当调度涉及优先度的时候,会出现不少问题,本文关于优先度调度的主要问题和一些应付的策略。主要有以下几个概念:优先度翻转(priority inversion),优先度继承(priority inheritance)策略和优先度天花板(priority ceiling)策略。
大部分的RTOS都支持给不同进程分配优先度,一定程度上能够让调度和时间管理灵活性更大。但是当涉及到一些代码临界区(critical section)的时候,可能就会出现问题。优先度翻转就是这样一个典型的问题。为了解释这个概念,我们先来看一个实际例子:
首先我们考虑三个进程,T1,T2,T3。T3的优先度大于T2,T2的优先度大于T1,采用的调度算法是优先度高的可以打断优先度低的进程,临界区等待可以阻断高优先度的进程。 考虑在一个时间轴上,以下事件依次发生:
光看文字可能会比较抽象,我们把每个事件都画在下图:
请先按照时间顺序从左到右看一下所有事件,有没有觉得有什么不妥的?
如果没有觉得不妥的,我们再按照每个进程来看。进程1首先被创建,然后进入了临界区执行,然后被阻断,然后重新进入临界区执行。中间的阻断时间是三个进程最长的。进程2第二被创建,一创建就打断了进程1,直到被阻断,然后恢复正常执行直到进程消灭。进程3最后被创建,一创建就打断了进程2,直到要求进入临界区被阻断,然后再恢复临界区执行,阻断时间是三个进程中第二长的。
问题恰恰出现在这里:进程3被什么阻断了?进程3因为要求资源R的临界区,而此时资源R临界区被进程1占用,理想状态我们应该让进程1尽快执行尽早退出临界区,以便进程3(当前最高优先度)能够及早运行。可实际上,因为进程1被进程2阻断(进程2优先度比进程1优先度高),所以进程2阻断了希望继续在临界区执行的进程1,直到进程2完成!
所以,进程3实际上是被进程2阻断了,低优先度的进程阻断了高优先度的进程,这就是优先度翻转问题。(临界区执行阻断不属于优先级阻断,而是资源阻断。)上面这个例子,如果我们把进程2的执行时间无限延长的话,进程3就完全没有办法继续执行了。也就是说,理想情况下,我们希望高优先度的进程的最大阻断时间受限于低优先度进程的临界区执行时间之和,实际上在这种最简单的设定下,高优先度的最大阻断时间是低优先度进程临界区执行时间之和加上优先度高于临界区执行进程的进程的执行时间之和。
一个简单的解决方案,就是在临界区时禁止中断,尽管这个确实能够解决优先度翻转问题,但我们如果考虑如下的情况:
这相当于无条件地阻断了进程2和进程3,显然不是我们想要的结果。
一个比较好的解决办法就是优先度继承。优先度继承的意思是,将临界区内的进程的优先度提高为该进程阻断的进程里的优先级最高进程的优先级。这么说可能还是很绕,我们看一下这个办法具体怎样解决我们上面提到的例子的:
当进程3要求进入R临界区执行时,因为被进程1阻断了,进程1此时在临界区内,而且它阻断的是进程3,那么此时它的优先度就会提升为进程3的优先度,也就是说继承了进程3的优先度。当退出临界执行区后,进程1优先度又会降为原先的优先度。
但这也会引起死锁的问题,我们看下面这个例子:
当进程2要求资源A临界区时,因为进程1正在资源A临界区内,所以其优先级继承了进程2的优先级,但进程1执行了一段时间后又要求进入资源B临界区以完成当前任务,退出临界区A。两个进程此时都在等待对方退出临界区,而自己却不会主动退出自己占有的临界区,所以死锁。
这个策略就是为了解决优先度继承的死锁问题。具体说来就是,为每个资源定义优先度天花板,资源的优先度天花板是所有可能要互斥使用该资源的进程的最高优先度。这种策略只允许满足一个条件的进程去进入临界区,而这个条件就是,该进程的优先度要大于(等于不足够),所有其他进程占用的资源的优先度天花板的最高优先度。同时保留优先度继承策略。 我们看一下上面这个例子,优先度天花板会怎样处理:
当进程2想进入资源B临界区时,被阻断了,因为它优先度并不大于其他进程(进程1)占用资源(A)的优先度天花板(进程2的优先度),被进程1阻断,与此同时,进程1的优先度提升为进程2的优先度,继续执行,直到退出两个临界区。
RTX的互斥锁和信号量有内嵌的优先度继承策略,但是没有内嵌的优先度天花板策略,所以如果你的代码涉及了2个以上独占资源,请留心避免死锁。
调试(debug)是软件开发的一个重要环节,对于嵌入式开发而言这个环节其实比较依赖一些硬件资源(硬件debugger)的支持。传统的嵌入式系统的调试比较依赖断点(breakpoint)和单步调试(single step through)。而 ARM cortex-M 系列的芯片其实有很强的CoreSight片上调试支持,实际上就是一个小的调试硬件,作为ARM的标准,内嵌在ARM的芯片里。在ARM自家的调试器ULINK-pro等的帮助下,可以实现代码覆盖率,代码剖析,代码性能分析等非常强大的调试功能。不同架构的Cortex-M系列的芯片支持不同的CoreSight部件,详见官方网站的说明。
作为ARM自家的软件开发工具,Keil自然也在调试方面有很多相应的支持。我们这里简单介绍一下Keil对RTX的支持,操作系统感知调试(OS-aware debugging)。主要有两个功能,一个是系统和进程观察窗口(System and Thread Viewer),另外一个是事件窗口(Event Viewer)。
这里以STM32F4Discovery板作为例子。
首先这个功能和其他Cortex-M芯片,查看内存值(Memory Window 或者 Watch window)的运作原理类似,RTOS会把相关的进程信息储存在内存里面,然后这个系统和进程观察窗口就会从这个内存区域里面提取信息,其他的RTOS开发者也可以利用这个功能。
用法具体说来,非常简单,首先进入调试模式
然后在View菜单中,确定Periodic Window Update被点选了:
最后,在Debug菜单中的OS Support栏中,点选System and Thread Viewer:
然后就可以看到这个窗口:
这就是系统和进程窗口,里面提供了RTX系统的基本设置,例如堆栈大小,时间片设置等和进程的基本信息,例如进程ID,进程优先度,当前进程状态,进程的delay时间,进程等待的事件值和当前的事件值和堆栈的适用程度。这个窗口还会实时更新各个进程的状态,非常好用。
举一个简单的例子,如果所有的进程都处于WAIT_SEM(等待信号量)的状态,而只有os_idle_demon处于Running状态,那么很可能就是进入了死锁的状态。调试时可以根据这里了解更多关于进程的实时情况。
可能在第一步配置系统和进程窗口时很多人都留意到了OS support中的,Event Viewer选项,没错,勾选它,就能启动这个调试功能。但这个调试功能需要SWV(Serial Wire Viewer)的支持。
首先要退出调试模式,然后点选Target Option
然后点选里面的Trace(跟踪)栏目,开启Trace,失能Periodic和EXCTRC选项,其余配置参考下图:
然后再进入调试模式,运行,就能看到这个事件窗口了:
清晰记录了具体某一时刻,哪个进程在运行,和进程运行之间的切换。
如果你勾选开启右上角的3个小功能:Task Info, Cursor和 Show Cycles,然后再把鼠标移至任一进程,就会出现进程信息框:
这些信息包括当前时间片里,进程开始的事件结束的事件,和该进程最大和最小的一段时间片。最为重要的是这个Called,这个数据是当前进程运行的次数,也就相当于这个是个profiler的数据,我们可以找出运行次数最高的进程,针对性地去优化程序。
RTX本身提供了一个功能,能够返回当前时间(以RTX设置的时间单位为单位,初始时间是RTX开始Initialize的时间,也就是os_sys_init()的那一刻):
current_time=os_time_get();
通过读取当前的操作系统时间,也能够实现一些性能方面的调试功能,但这需要代码里面额外写入这部分相关的调试代码。
联系客服