2.5 VxWorks中断处理——多层次的中断转移
中断是外设为得到CPU服务而向CPU发送的一个信号,CPU在接收到一个中断信号后,暂停当前执行的任务,转去执行中断处理程序,完成对外设的服务。为使一个中断能够得到服务,除了操作系统需要进行配合外,还必须配置CPU硬件寄存器。以ARM处理器为例,ARM处理器支持两个中断管脚。换句话说,ARM处理器可以从外界接收两个不同源产生的服务需求,IRQ为普通中断服务请求,FIQ为快速中断服务请求,其中,FIQ优先级较高,即当IRQ、FIQ同时有中断请求时,ARM处理器优先处理FIQ中断。VxWorks操作系统只使用了IRQ中断,FIQ闲置未用,“未用”的含义即负责FIQ的内核中断响应程序只是简单地返回,而不进行任何有效的操作。
实际上,对于一个平台而言,处理器提供的中断管脚根本无法满足外设的数量需求,故一般外设发出的中断都会通过一个中断控制器输入到CPU。中断控制器是可编程的,可接收多个中断源,并对这些中断源进行优先级排序的中断源控制设备,可以单独禁止和使能某个中断。一般而言,中断控制器具有多个中断源输入管脚,而只有一个中断输出管脚,与ARM处理器的IRQ或者FIQ直接相连。故而平台所有外设的中断都作为ARM处理器IRQ或者FIQ中断。由于VxWorks操作系统只支持IRQ中断管脚,故在VxWorks下,中断控制器的输出管脚必须与ARM处理器的IRQ中断输入管脚连接在一起,此时ARM处理器的FIQ管脚闲置不用。
由于此时所有的外设中断源都通过中断控制器的管理以处理器单一中断的形式而存在,故操作系统对中断的响应采用了多层分级查找的方式。首先,VxWorks内核提供一个IRQ中断总入口函数,由这个函数查询中断控制器得到当前请求服务的最高优先级中断号,进而由这个中断号调用对应该中断的具体的中断处理函数,完成一次中断的响应过程。为此,VxWorks内核单独维护一张外设中断程序表,该表实现上实际为一个结构数组,这个结构定义可以是如下形式:
typedef struct { /* VEC_ENTRY */ VOIDFUNCPTR routine; ULONG arg; } VEC_ENTRY;
根据特定平台总的外设中断源的数目,在VxWorks内核初始化过程中,将创建这样一个结构数组,外设中断源编号作为数组索引,而用户注册的中断处理程序则以中断源编号为索引,在数组对应的某项中填入中断函数地址和参数,完成中断程序的注册过程。当ARM处理器接收到一个IRQ中断后,首先由VxWorks内核提供的IRQ中断响应函数进行处理,该总入口函数作为IRQ处理函数注册到ARM处理器系统中断向量表中。IRQ总入口函数并不对具体的中断进行服务,其主要完成中断服务查询和转移的工作,具体如下:
● 读取中断控制器相关寄存器,获取当前中断号。注意,中断控制器自身具有中断源优先级排序功能,故当某个时刻有多个中断产生时,中断控制器将选择最高优先级的中断进行服务,此时中断控制器相关寄存器中存储的就是这个最高优先级中断的中断号。
● 根据获取的中断号,索引VxWorks自身维护的外设中断程序表,得到中断号对应的表项,调用表项中之前注册的中断处理程序,完成中断服务的转移。
● 用户注册的中断程序被调用,完成特定中断的服务。
基于此二级中断服务机制,需要注意以下几个方面。
首先,VxWorks内核提供了IRQ中断的总入口函数,所有外设中断的服务首先进入该总入口函数,之后总入口函数通过调用外设中断程序表中对应的中断函数,完成中断服务。ARM处理器为了完成中断服务,其要求在绝对地址0处必须建立中断向量表,称为系统中断向量表,以区别于VxWorks操作系统自身维护的用户中断向量表。
其次,为了使能第一层次中断响应,必须配置ARM处理器CPCR寄存器,使能IRQ中断,即将CPCR寄存器中I位清零。这一点很重要,在基于ARM处理器的平台初始化代码中,我们一般都会将CPCR寄存器中I、F位置1,禁止系统一切中断,并同时设置模式为系统特权模式,从而使具有足够的优先级进行ARM处理器其他寄存器的设置。
在VxWorks下,在响应外设中断之前,必须重新将I位清零,事实上,在此后的内核初始化代码中,并没有具体的代码完成I位清零的工作,这个工作是在任务创建时附带完成的。usrInit函数最后通过调用kernelInit函数创建一个内核任务完成余下的内核初始化工作,新创建的任务默认将I位清零,即usrRoot函数默认运行在系统中断使能的情况下,故此时为了完成外设中断的服务,只需使能二级中断即可,即配置中断控制器相关寄存器去使能某个中断。注意:在VxWorks内核初始化最初阶段,中断控制器被配置为禁止了所有的外设中断。以后将根据具体需要单个使能各个中断源。为了使能某个外设中断,BSP开发人员必须提供intEnable和intDisable的底层实现函数,这两个函数分别完成使能和禁止某个外设中断的目的。具体工作的完成是通过配置中断控制器的相关寄存器位实现的。intConnect函数完成外设中断函数的注册,即注册到VxWorks内核维护的外设中断程序表中。为了完成二级中断转移,BSP开发人员必须提供如下函数的底层实现。
● intEnable底层实现函数,对应sysIntLvlEnableRtn函数指针。
● intDisable底层实现函数,对应sysIntLvlDisableRtn函数指针。
● 中断号获取函数,对应sysIntLvlVecChkRtn函数指针。
● 中断应答函数,对应sysIntLvlVecAckRtn函数指针。
上述四个函数都将被VxWorks内核调用,而具体实现则由BSP开发人员提供,故内核提供函数指针的形式,由BSP开发人员进行初始化,而后由VxWorks通过函数指针的方式调用这些底层实现函数。其中中断号获取函数和中断应答函数将被IRQ中断总入口函数调用,完成中断转移和应答。这四个函数的实现都必须有具体的操作中断控制器,而中断控制器是与平台相关的,故必须作为BSP的一部分来实现。
2.5.1 VxWorks下中断转移过程详解(基于ARM平台)
VxWorks内核提供intEnt(intALib.s)作为IRQ中断总入口函数,该函数完成相关设置后,调用__func_armIrqHandler函数(excArchLib.c)指针指向的函数完成具体的中断服务。__func_armIrqHandler函数指针默认初始化为指向excIntHandle函数(excArchLib.c),excIntHandle函数只是调用excIntInfoShow(excArchShow.c)打印出相关的中断信息,并不服务中断,在VxWorks内核中断服务初始化过程中(sysHwInit2函数中通过调用intLibInit,sysHwInit2在sysClkConnect中被调用),__func_armIrqHandler函数指针被重新初始化为指向intIntRtnNonPreempt或者intIntRtnPreempt。其中intIntRtnPreempt函数支持中断嵌套,即允许高优先级的中断打断当前低优先级服务,转而服务高优先级中断;而intIntRtnNonPreempt则不支持中断嵌套,即当系统在服务低优先级中断时,不允许高优先级中断发生,此时ARM处理器中I位被置1。如果工作在允许中断嵌套的情况下,系统对于嵌套的层次有所限制。
当嵌套层次超过系统最大允许值时,将不再允许中断嵌套的发生。由于中断发生时,ARM处理器I位被置1(intEnt函数中完成),故实际上intIntRtnNonPreempt和intIntRtnPreempt的区别主要在于intIntRtnPreempt函数中重新将ARM处理器I位清零,从而允许其他高优先级中断在服务当前中断的过程中产生,即产生中断嵌套。通过以上分析,基于ARM处理器VxWorks的中断服务流程如下:中断产生→intEnt→intIntRtnNonPreempt或者intIntRtnPreempt→用户注册的具体的中断函数。
注意
以上中断服务流程中,具体完成中断服务的是最后被调用的用户注册的中断函数,intEnt以及intIntRtnNonPreempt或者intIntRtnPreempt函数都是做辅助性的工作,当然这些辅助性的工作对于内核而言是至关重要的。
由于ARM处理器只有两个中断管脚,而VxWorks操作系统只使用了IRQ普通中断管脚,故平台下所有的外设中断都是通过一个中断控制器外部设备进行管理,包括系统时钟中断也是通过中断控制器向ARM处理器请求中断服务。系统时钟中断是整个VxWorks操作系统的脉搏,也是任务调度的固定触发点,同时还是系统各种定时器的源,对于整个系统而言,具有至关重要的作用。下面我们以系统时钟中断的初始化和响应过程为例介绍VxWorks下中断注册和服务过程。
VxWorks系统时钟中断用户层注册函数为sysClkInt。系统时钟中断的注册在sysClkConnect中完成。sysClkConnect被usrRoot调用专门负责系统时钟的初始化。该函数被调用情景的代码如下。
sysClkConnect ((FUNCPTR) usrClock, 0); /* connect clock interrupt routine */ sysClkRateSet (SYS_CLK_RATE); /* set system clock rate */ sysClkEnable (); //配置外设时钟,使其正常工作,按固定时间间隔产生时钟中断
注意
sysClkConnect的第一个输入参数为系统时钟中断的服务例程,然而这并非直接中断服务例程。换句话说,从上文分析的中断服务流程来看,其还不属于用户注册的中断函数,而是更低一级,由用户注册的中断函数调用。事实上,对于系统时钟中断而言,用户注册的中断函数是sysClkInt,这是在sysHwInit2中注册完成的。sysHwInit2由sysClkConnect函数调用,如下为sysClkConnect函数(xxx_timer.c中“xxx”表示相关平台)实现的代码。
STATUS sysClkConnect ( FUNCPTR routine, /* routine to be called at each clock interrupt */ int arg /* argument with which to call routine */ ) { static BOOL beenHere = FALSE; if (!beenHere) { beenHere = TRUE; sysHwInit2 (); } sysClkRoutine = NULL; sysClkArg = arg; sysClkRoutine = routine; return (OK); }
作为参数传入的usrClock函数地址被存储在sysClkRoutine函数指针中,而不是作为intConnect的函数注册到外设中断程序表中。其中调用的sysHwInit2完成系统时钟中断程序的注册,代码如下。
/*sysLib.c*/ void sysHwInit2 (void) { static BOOL initialised = FALSE; if (initialised) return; /* initialise the interrupt library and interrupt driver */ intLibInit (NUM_OF_INTERRUPT,NUM_OF_INTERRUPT, INT_NON_PREEMPT_MODEL ); xxxIntDevInit (); /* connect sys clock interrupt */ (void)intConnect ( INUM_TO_IVEC(INT_TINT0), sysClkInt, 0); intEnable ( INT_TINT0 ); … initialised = TRUE; }
sysHwInit2首先调用intLibInit完成内核中断服务的相关初始化,其中最重要的是重新初始化__func_armIrqHandler函数指针,intLibInit函数的第三个参数指定是否允许中断嵌套,此处调用中不允许中断嵌套,故__func_armIrqHandler函数指针被初始化为指向intIntRtnNonPreempt函数;继而调用xxxIntDevInit函数完成前文所述的四个中断底层实现函数的注册,即初始化sysIntLvlEnableRtn、sysIntLvlDisableRtn、sysIntLvlVecChkRtn、sysIntLvlVecAckRtn四个函数指针(这四个函数指针的含义见上文分析);而后调用intConnect进行外设中断程序的注册,即将sysClkInt函数作为系统时钟中断的服务程序注册到内核维护的外设中断程序表中,最后调用intEnable使能该中断,intEnbale实现上调用sysIntLvlEnableRtn指向的函数(BSP开发人员实现)完成中断控制器的配置,从而使能系统时钟中断。
注意
可以将此处的intEnable函数放入usrRoot中调用的sysClkEnable函数(xxx_timer.c)中,如果此处进行了intEnable的调用,则无须在sysClkEnable中再次进行调用,此时sysClkEnable只需进行定时器外设的硬件配置即可。所以,单从系统时钟中断的角度来看,sysHwInit2将sysClkInt函数注册到内核中作为系统时钟中断的服务程序。sysClkInt定义在xxx_timer.c中,其代码结构如下。
void sysClkInt (void) { if( ( sysClkRunning == TRUE ) && ( sysClkRoutine != NULL ) ) { /* call system clock service routine */ (* sysClkRoutine) (sysClkArg); } }
在sysClkConnect函数中,sysClkRoutine被设置为第一个参数指向的函数,即usrClock。由此,我们可以将系统时钟中断产生时整个系统的服务流程表示如下:
系统时钟中断产生→intEnt→intIntRtnNonPreempt→sysClkInt→usrClock→tickAnnounce
usrClock调用的tickAnnounce函数由VxWorks操作系统提供,tickAnnounce函数主要完成如下工作:
① 对vxTick变量做加1运算。vxTick表示系统自启动之时到现在的tick数,所以用vxTick乘以系统时钟间隔就是开机时间。
② 对处于等待状态的任务进行检查,将超时任务(那些调用taskDelay延迟的任务)重新设置为ready状态,并转移到调度队列中。
③ 遍历内核工作队列,对延迟的内核工作进行执行。
④ 进程调度。选择最高优先级任务作为当前任务运行。
系统时钟中断的服务过程相对其他中断较为“曲直”,其在用户层又进行了分层。对于其他硬件中断,通过intConnect函数注册到系统外设中断程序表中的中断函数一般作为服务具体完成的场合,而不再有进一步中断转移的过程。系统时钟中断的特殊之处在于其具体中断服务函数(tickAnnounce)是由内核提供的,但又必须在用户层对时钟进行管理,故必须经过中间一步转换的过程。
2.5.2 中断上下文中为何不可调用可引起睡眠的函数
无论是在阅读相关操作系统理论书籍还是阅读实际代码,基本都说到了在中断处理程序中,不可以调用可以引起睡眠或者阻塞的函数。经典的解释如下:
Code running in interrupt context is unable to sleep, or block, because interrupt context does not has a backing process with which to reschedule. Therefore, because interrupt handler is not associated with a process, there is nothing for the scheduler to put to sleep and, more importantly, nothing for the scheduler to wake up …
这是我们听到的最经典的解释:因为中断是运行在中断上下文中的,所以不可以调用可引起阻塞或者睡眠的函数。再进一步:因为调度必须是以进程上下文为条件的,而中断并没有这样一个对应的进程存在,所以中断不可调用可引起进程切换的函数(即引起阻塞或睡眠的函数)。
基于Linux内核代码的开源性,故在以下的讨论中,我们以Linux操作系统为例。我们知道,当前执行进程控制块由current指针指向,当进程调度时,current指针被赋值指向下一个即将运行的进程。当某个进程运行时,发生一个中断,此时CPU按照一定的流程从用户或者内核进程切换到中断处理程序运行。注意,此时current指针仍然指向被中断的进程。对于在用户态被中断的情况来看,其与系统调用的情景差不多,最大的差别在于中断时硬件自动完成全局中断的禁止(注意:这一点在下文的讨论中是至关重要的)。此时将从用户态切换到内核态,以下寄存器将被压入进程的内核堆栈:用户态SS、用户态ESP、EFLAGS、用户态CS、用户态EIP、出错码(若存在的话。但对于外部中断,都没有出错码)。中断处理程序将使用进程的内核堆栈。
对于在内核态被中断的情景,此时不发生堆栈的切换,其他相同。以从用户态被中断的情景为依据进行分析,我们可以分析得出,其与用户进程进行系统调用时发生的情况几乎完全相同。一个最本质的区别是中断时硬件自动关闭了系统中断使能位,故直到中断处理程序退出或者其主动开启使能位之前,所有其他的系统中断都将被屏蔽,包括timer interrupt(系统时钟终端),这个进程调度的脉搏和激励源。由此不同之处,我们可以分析出为何一般的系统调用可以进行睡眠,而由中断引起的进程却不可以进行睡眠。
首先需要提及的一点是,系统调用一般是为进行系统调用的进程服务的,或者说,就是我们通常所说的服务于用户进程,即此时的内核执行代码与当前current指向的进程是相互绑定的。如果内核执行过程中某个条件不满足,需要进行等待,那么其绑定的进程进入睡眠状态是合乎情理的。对于中断的情况,则完全不同,中断的发生完全是异步的,这就是说,当中断发生时,我们无法预知当前执行的是哪一个进程。换句话说,单从上下文来看,中断发生时,当前执行进程被这个中断强行“绑架”了。或者说,中断处理程序的执行并不是以当前进程的“利益”为其首要准则,它是一个显然的侵犯者,它为着自身的利益强行中断了当前进程的执行。此时除了全局中断被硬件处理流程关闭外,系统所有的上下文环境与普通的系统调用没有任何差异。
对于中断处理程序(或者说中断上下文中)不可以调用可引起睡眠或者阻塞的函数的传统经典解释中,仅仅表达了“因为是中断上下文,不是进程上下文,所以不可调用。”从以上的说明可以看出,中断就是执行在进程上下文中,current指针依然指向一个有效的位置,就是当前被中断的进程(无论这个进程与这个中断相关还是不相关)。所以,如果此时中断处理程序中调用一个可引起睡眠(即引起进程调度)的函数,那么switch_to仍然可以毫无困难地执行,至多只是将一个被绑架的进程调度了出去。当这个被绑架的进程被调度回来后(暂不考虑是如何被调度回来的,下文讨论这一点),那么它将仍然继续之前的处理:先执行完尚未执行完的中断处理代码,而后退出,从被绑架进程内核堆栈中弹出CS、EIP等,恢复这个被绑架的可怜的进程的执行,有何不可?
以上讨论中跳过了一点,即被绑架进程被调度出去后,是如何被调度回来的。由于作为反方进行论证,所以只需给出一种可能的情况即可,只要能被调度回来就可以。例如,中断程序调用kmalloc函数引起睡眠,那么此时被绑架进程将被挂入到一个内核指定的队列中,当系统又有可用内存时,内核相关代码会唤醒由于kmalloc调用而睡眠的进程,当然包括这个可怜的被中断绑架的进程。在某个时刻这个被绑架进程重新被调度回来进行执行,有何不可?
一些读者可能已经意识到了,以上的讨论中省略了最关键的一点,即调度器要在工作着。换句话说,系统的脉搏要在搏动。但是注意到当中断发生时,硬件处理流程关闭了系统中断,包括系统脉搏中断timer interrupt。
首先我们讨论一下没有timer interrupt进程调度的可能性。没有了timer interrupt,系统能否工作?回答是:不能。如此情况下,进程的调度将完全依赖于一个执行的进程主动调用schedule函数。对于某些进程,这一点是可以满足的,但是对于Linux内核中绝大多数进程,这一点是不满足的,只要当前被调用执行的进程不曾主动调用schedule函数,那么我们只能等待它执行到死。对于用户而言,此时将表现为系统死机。因为用户判断系统是死是活,通常是通过界面是否响应用户的命令,对于一个一直在执行某特定单一进程的操作系统而言,这一点是无法完成的,那就是我们无论干什么,系统都没有响应,即系统死了。(此处的讨论不很深入,不过到此已经达到了说明的目的了。)
以上是最平常的情况,即中断处理程序一般的工作方式(禁止嵌套中断)。现在我们讨论另一种可能性:中断处理程序在一开始就将系统关闭着的中断开启,即允许中断嵌套。此时timer interrupt获得了执行的资格,系统重新拥有了脉搏。那么被中断绑架的进程将有希望被重新调度。问题是,如果这个中断的中断源自系统开机到关机之间只发出一个中断,那么将不会造成任何问题。这个中断绑架的进程最多会无故多延迟等待一会执行而已,不至于造成整个系统死掉。
问题的关键有两点:其一,中断会发生多次;其二,每发生一次,系统就多一个进程被强行绑架。第一点引起的问题是,几乎每次中断都会对同一个问题进行加重,如对同一个资源的重新申请;第二点引起的问题是,系统到最后可能所有的进程都被绑架,到最后系统无进程可以调度,因为所有的进程都在被绑架着睡大觉,甚至包括idle进程。这个陷阱的出路是:每次下一个中断来临前,前一次中断退出。但考虑到如果一旦允许中断处理程序中可以调用可引起阻塞或者睡眠的函数,那么引起该类问题的中断源就不只一个了,某个小中断源可以,串口可以否,硬盘可以否,网口可以否,继而timer interrupt可以否?显然不可以。既然有不可以的,那么大家就都不可以。这一点不是可以商量的,那是必需的。所以就有中断处理程序中(或者说中断上下文中)不可以调用可引起阻塞或者睡眠的函数。这是一条必须严格遵守的游戏规则。但是如果强行不遵守它,是不是一定出问题?不一定,如果中断频率不高,而调用的可引起阻塞的函数不是VIP高级会员,如kmalloc(在系统内存足够时),那么调用一下不会引起什么问题,但是这是一个错误,是一个潜在的危险源,如果有一天系统死机了,那么这可能就是罪魁祸首。