WHCSRL 技术网

linux内核-中断的响应和服务

搞清了i386 CPU的中断机制和内核中有关的初始化以后,我们就可以从中断请求的发生到CPU的响应,再到中断服务程序的调用与返回,沿着CPU所经历的路线走一遍。这样,既可以弄清和理解linux内核对中断响应和服务的总体的格局和安排,还可以顺着这个过程介绍内核中的一些相关的基础设施。对此二者的了解和理解,有助于读者对整个内核的理解。

这里,我们假定外设的驱动程序都已经完成了初始化,并且把相应的中断服务程序挂入到特定的中断请求队列中,系统正在用户空间正常运行(所以中断必然是开着的),并且某个外设已经产生了一次中断请求。该请求通过中断控制器i8259A到达了CPU的中断请求引线INTR。由于中断时开着的,所以CPU在执行完当前指令后就来响应该次中断请求。

CPU从中断控制器取得中断向量,然后根据具体的中断向量从中断向量表IDT中找到相应的表项,而该表项应该是一个中断门。这样,CPU就根据中断门的设置而到达了该通道的总服务程序的入口,假定为IRQ0x03_interrupt。由于中断是当CPU在用户空间中运行时发生的,当前的运行级别CPL为3;而中断服务程序属于内核,其运行级别DPL为0,二者不同。所以,CPU要从寄存器TR所指的当前TSS中取出用于内核(0级)的堆栈指针,并把堆栈切换到内核堆栈,即当前进程的系统空间堆栈。应该指出,CPU每次使用内核堆栈时对堆栈所做的操作总是均衡的,所以每次从系统空间返回到用户空间时堆栈指针一定回到其原点,或曰堆栈底部。也就是说,当CPU从TSS中取出内核堆栈指针并切换到内核堆栈时,这个堆栈一定是空的。这样,当CPU进入IRQ0x013_interrupt时,堆栈中除寄存器EFLAGS的内容以及返回地址外就一无所有了。另外,由于所穿过的是中断门(而不是陷阱门),所以中断已被关闭,在重新开启中断之前再没有其他的中断可以发生了。

中断服务的总入口IRQ0xYY_interrupt的代码以前已经见到过了,但为方便起见再把他列出在这里。再说,我么你现在人事也可以更深入一些了。

如前所述,所有公用中断请求的服务程序总入口是由gcc的预处理阶段生成的,全部都具有相同的模式:

  1. asmlinkage void IRQ0x00_interrupt(void);
  2. __asm__(
  3. " "
  4. IRQ0x00_interrupt: "
  5. "pushl $0x03-256 "
  6. "jmp common_interrupt");

这段程序的目的在于将一个与中断请求号相关的数值压入堆栈,使得common_interrupt中可以通过这个数值来确定该次中断的来源。可是为什么要从中断请求号0x03中减去256使其变成负数呢?就用数值0x03不是更直截了当吗?这是因为,系统堆栈中的这个位置在因系统调用而进入内核时要用来存放系统调用号,而系统调用又与中断服务公用一部分子程序。这样,就要有个手段来加以区分。当然,要区分系统调用号和中断请求号并不非得把其中之一变成负数不可。例如,在中断请求号上加上一个常数,比方说0x1000,也可以达到目的。但是,如果考虑到运行时的效率,那么把其中之一变成负数无疑是效率最高的。将一个整数装入到一个通用寄存器之后,要判断它是否大于等于0是很方便的,只要一条寄存器指令就可以了,如orl %%%%eax,%%%%eax或testl %%%%eax,%%%%eax都可以达到目的。而如果要与另一个常数相比,那就至少要多访问一次内存。从这个例子也可以看出,内核中的有些代码看似简单,好像只是作者随意的决定,但实际上却是经过精心推敲的。

公共的跳转目标common_interrupt的定义:

IRQ0x03_interrupt=>common_interrupt

  1. #define BUILD_COMMON_IRQ()
  2. asmlinkage void call_do_IRQ(void);
  3. __asm__(
  4. " " __ALIGN_STR" "
  5. "common_interrupt: "
  6. SAVE_ALL
  7. "pushl $ret_from_intr "
  8. SYMBOL_NAME_STR(call_do_IRQ)": "
  9. "jmp "SYMBOL_NAME_STR(do_IRQ));

这里主要的操作时宏操作SAVE_ALL,就是所谓的保存现场,把中断发生前夕所有寄存器的内容都保存在堆栈中,待中断服务万变要返回之前再来恢复现场。SAVE_ALL的定义在arch/i386/kernel/entry.S中:

  1. #define SAVE_ALL
  2. cld;
  3. pushl %%es;
  4. pushl %%ds;
  5. pushl %%eax;
  6. pushl %%ebp;
  7. pushl %%edi;
  8. pushl %%esi;
  9. pushl %%edx;
  10. pushl %%ecx;
  11. pushl %%ebx;
  12. movl $(__KERNEL_DS),%%edx;
  13. movl %%edx,%%ds;
  14. movl %%edx,%%es;

这里要指出两点:第一是标志位寄存器EFLAGS的内容并不是在SAVE_ALL中保存的,这是因为CPU在进入中断服务时已经把它的内容连同返回地址一起压入堆栈了。第二是段寄存器DS和ES原来的内容被保存在堆栈中,然后就被改成指向用于内核的__KERNEL_DS。我们在内存管理博客中讲过,__KERNEL_DS和__USER_DS都指向从0开始的空间,所不同的只是运行级别DPL一个为0级,另一个为3级。至于原来的堆栈段寄存器SS和堆栈指针SP的内容,则或者已被压入堆栈(如果更换堆栈),或者继续使用而无需保存(如果不更换堆栈)。这样,在SAVE_ALL以后,堆栈中的内容就成为下图形式。

此时系统堆栈中各项相对于堆栈指针的位置如上图所示,而arch/i386/kernel/entry.S中也根据这些关系定义了一些常数:

  1. EBX = 0x00
  2. ECX = 0x04
  3. EDX = 0x08
  4. ESI = 0x0C
  5. EDI = 0x10
  6. EBP = 0x14
  7. EAX = 0x18
  8. DS = 0x1C
  9. ES = 0x20
  10. ORIG_EAX = 0x24
  11. EIP = 0x28
  12. CS = 0x2C
  13. EFLAGS = 0x30
  14. OLDESP = 0x34
  15. OLDSS = 0x38

 这里的EAX,举例来说,当出现在entry,S的代码中时并不是表示寄存器%%%%eax,而是表示该寄存器的内容在系统堆栈中的位置相对于此时的堆栈指针的位移。前面在转入common_interrupt之前压入堆栈的(中断调用号-256)所在位置称为ORIG_EAX,对中断服务程序而言它代表着中断请求号。

回到common_interrupt的代码。在SAVE_ALL以后,又将一个程序标号(入口)ret_from_intr压入堆栈,并通过jmp指令转入另一段程序do_IRQ。读者可能已注意到,IRQ0x03_interrupt和common_interrupt本质上都不是函数,它们都没有return相当的指令,所以从common_interrupt不能返回到IRQ0x03_interrupt,而从IRQ0x03_interrupt也不能执行中断返回。可是,do_IRQ却是一个函数。所以,在通过jmp指令转入do_IRQ之前将返回地址ret_from_intr压入堆栈就模拟了一次函数调用,仿佛对do_IRQ的调用就发生在CPU进入ret_from_intr的第一条志林前夕一样。这样,当从do_IRQ返回时就会返回到ret_from_intr继续执行。do_IRQ是在arch/i386/kernel/irq.c中定义的,我们先看开头几行:

IRQ0x03_interrupt=>common_interrupt=>do_IRQ

  1. /*
  2. * do_IRQ handles all normal device IRQ's (the special
  3. * SMP cross-CPU interrupts have their own specific
  4. * handlers).
  5. */
  6. asmlinkage unsigned int do_IRQ(struct pt_regs regs)
  7. {
  8. /*
  9. * We ack quickly, we don't want the irq controller
  10. * thinking we're snobs just because some other CPU has
  11. * disabled global interrupts (we have already done the
  12. * INT_ACK cycles, it's too late to try to pretend to the
  13. * controller that we aren't taking the interrupt).
  14. *
  15. * 0 return value means that this irq is already being
  16. * handled by some other CPU. (or is disabled)
  17. */
  18. int irq = regs.orig_eax & 0xff; /* high bits used in ret_from_ code */
  19. int cpu = smp_processor_id();
  20. irq_desc_t *desc = irq_desc + irq;
  21. struct irqaction * action;
  22. unsigned int status;

函数的调用参数是一个pt_regs数据结构。注意,这是一个数据结构,而不是指向数据结构的指针。也就是说,在堆栈中的返回地址以上的位置上应该是一个数据结构的映像。数据结构struct pt_regs是在include/asm-i386/ptrace.h中定义的:

  1. /* this struct defines the way the registers are stored on the
  2. stack during a system call. */
  3. struct pt_regs {
  4. long ebx;
  5. long ecx;
  6. long edx;
  7. long esi;
  8. long edi;
  9. long ebp;
  10. long eax;
  11. int xds;
  12. int xes;
  13. long orig_eax;
  14. long eip;
  15. int xcs;
  16. long eflags;
  17. long esp;
  18. int xss;
  19. };

相信读者一定会联想到前面讲过的系统堆栈的内容并且恍然大悟:原来前面所做的一切,包括CPU在进入中断时自动做的,实际上都是在为do_IRQ建立一个模拟的子程序调用环境,使得在do_IRQ中既可以方便地知道进入中断前夕各个寄存器的内容,又可以在执行完毕以后返回到ret_from_intr,并且从那里执行中断返回。可想而知,当do_IRQ调用具体的中断服务程序时也一定会把pt_regs数据结构的内容传下去,不过那时只要传递一个指针就够了。读者不妨回顾一下我们在内存管理中讲过的页面异常服务程序do_page_fault,其调用参数表为:

asmlinkage void do_page_fault(struct pt_regs *regs, unsigned long error_code);

第一个参数就是指向struct pt_regs的指针,实际上就是指向系统堆栈中的那个地方。当时我们无法将这一点讲清楚,所以略了过去。而现在结合进入中断的过程一看就清楚了。不过,页面异常并不属于通用的中断请求,而是为CPU保留专用的,所以中断发生时并不经过do_IRQ这条线路,但是对于系统堆栈的这种安排基本上是一致的。

以后读者还会看到,对系统堆栈的这种安排不光用于中断,还用于系统调用。

前面讲过,在IRQ0x03_interrupt中把数值(0x03-256)压入堆栈的目的是使得在公共的中断处理程序中可知道中断的来源,现在进入do_IRQ以后的第一件事情就是要弄清楚这一点。以IRQ3为例,压入堆栈的数值为0xffffff03,现在通过regs.orig_eax & 0xff读回来并且把高位屏蔽掉,就又得到0x03。由于do_IRQ仅用于中断服务,所以不需要顾及系统调用时的情况。

代码中561行的smp_processor_id是为多处理器SMP结构而设的,在单处理器系统中总是返回0。现在,既然中断请求号已经恢复,从数组irq_desc中找到相应的中断请求队列当然是轻而易举的了(562行)。下面就是对具体中断请求队列的操作了。我们继续在do_IRQ中往下看:

IRQ0x03_interrupt=>common_interrupt=>do_IRQ

  1. kstat.irqs[cpu][irq]++;
  2. spin_lock(&desc->lock);
  3. desc->handler->ack(irq);
  4. /*
  5. REPLAY is when Linux resends an IRQ that was dropped earlier
  6. WAITING is used by probe to mark irqs that are being tested
  7. */
  8. status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING);
  9. status |= IRQ_PENDING; /* we _want_ to handle it */
  10. /*
  11. * If the IRQ is disabled for whatever reason, we cannot
  12. * use the action we have.
  13. */
  14. action = NULL;
  15. if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) {
  16. action = desc->action;
  17. status &= ~IRQ_PENDING; /* we commit to handling */
  18. status |= IRQ_INPROGRESS; /* we are handling it */
  19. }
  20. desc->status = status;

当通过中断门金如意中断服务时,CPU的中断响应机制就自动被关闭了。既然已经关闭中断,为什么567行还要调用spin_lock加锁呢?这是为多处理的情况而设置的,我们将在多处理器SMP系统结构系列博客中讲述,这里暂时只考虑单处理器结构。

中断处理器(如i8259A)将中断请求上报到CPU以后,期待CPU给它一个确认(ACK),表示我已经在处理,这里的568行就是做这件事。对函数指针desc->handler->ack的设置前面已经讲过。从569行至586行主要是对desc->status,即中断通道状态的处理和设置,关键在于将其IRQ_INPROGRESS标志位设成1,而IRQ_PENDING标志位清0。其中IRQ_INPROGRESS主要是为多处理器设置的,而IRQ_PENDING的作用则下面就会看到:

IRQ0x03_interrupt=>common_interrupt=>do_IRQ

  1. /*
  2. * If there is no IRQ handler or it was disabled, exit early.
  3. Since we set PENDING, if another processor is handling
  4. a different instance of this same irq, the other processor
  5. will take care of it.
  6. */
  7. if (!action)
  8. goto out;
  9. /*
  10. * Edge triggered interrupts need to remember
  11. * pending events.
  12. * This applies to any hw interrupts that allow a second
  13. * instance of the same irq to arrive while we are in do_IRQ
  14. * or in the handler. But the code here only handles the _second_
  15. * instance of the irq, not the third or fourth. So it is mostly
  16. * useful for irq hardware that does not mask cleanly in an
  17. * SMP environment.
  18. */
  19. for (;;) {
  20. spin_unlock(&desc->lock);
  21. handle_IRQ_event(irq, &regs, action);
  22. spin_lock(&desc->lock);
  23. if (!(desc->status & IRQ_PENDING))
  24. break;
  25. desc->status &= ~IRQ_PENDING;
  26. }
  27. desc->status &= ~IRQ_INPROGRESS;
  28. out:
  29. /*
  30. * The ->end() handler has to deal with interrupts which got
  31. * disabled while the handler was running.
  32. */
  33. desc->handler->end(irq);
  34. spin_unlock(&desc->lock);

如果某一个中断请求队列的服务是关闭的(IRQ_DISABLED标志位为1),或者IRQ_INPROGRESS标志位为1,或者队列是空的,那么指针action为NULL(见580和582行),无法往下执行了,所以只好返回。但是,在这几种情况下desc->status中的IRQ_PENDING标志位为1(见574和583行)。这样,以后当CPU(在多处理器系统结构中有可能是另一个CPU)开启该队列的服务时,会看到这个标志位而补上一次中断服务,称为IRQ_REPLAY。而如果队列是空的,那么整个通道也必然是关着的,因为这是在将第一个服务程序挂入队列时才开启的。所以,这两种情形实际上相同。最后一种情况是服务已经开启,队列也不是空的,可是IRQ_INPROGRESS标志位为1。这只是在两种情形下才会发生。一种情形是在多处理器SMP系统结构中,一个CPU正在中断服务,而另一个CPU又进入了do_IRQ,这时候由于队列的IRQ_INPROGRESS标志位1而经595行返回,此时desc->status中的IRQ_PENDING标志位也是1.第二种情形是在单处理器系统中CPU已经在中断服务程序中,但是因某种原因又将中断开启了,而且在同一个中断通道中又产生了一次中断。在这种情形下后面发生的那个中断也会因为IRQ_INPROGRESS标志位为1,而经595行返回,但也是将desc->status的IRQ_PENDING置成为1。总之,这两种情形下最后的结果也是一样的,即desc->status中的IRQ_PENDING标志位为1。

那么,IRQ_PENDING标志位到底是怎样起作用的呢?请看612行和613行。这是在一个无限for循环中,具体的中断服务实在609行的handle_IRQ_event中进行的。在进入609行时,desc->status中的IRQ_PENDING标志位必然为0。当CPU完成了具体的中断服务返回到610行以后,如果这个标志位仍然为0,那么循环就在613行结束了。而如果变成了1,那就说明已经发生过前述的某种情况,所以又循环回到609行再服务一次。这样,就把本来可能发生的在同一通道上(甚至可能来自同一个中断源)的中断嵌套化解成一个循环。

这样,同一个中断通道上的中断处理就得到了严格的串行化。也就是说,对于同一个CPU而言不允许中断服务嵌套,而对于不同的CPU则不允许并发地进入同一个中断服务程序。如果不是这样处理的话,那就要求所有的中断服务程序都必须是可重入的纯代码,那样就使中断服务程序的设计和实现复杂化了。这么一套机制的设计和实现,不能不说是非常周到、非常巧妙的。而linux的稳定性和可靠性也正是植根与这种从Unix时代继承下来、并经过时间考验的设计中。当然,在极端的情况下,也有可能会发生这样的情景:中断服务程序总是把中断打开,而中断源又不断地产生中断请求,使得CPU每次从handle_IRQ_event返回时IRQ_PENDING标志位永远为1,从而使607行的for循环变成一个真正的无限循环,如果真的发生这种情况而得不到纠正的话,那么该中断服务程序的作者应该另请高就了。

还要指出,对desc->status的任何改变都是在加锁的情况下进行的,这也是出于对多处理器SMP系统结构的考虑。

最后,在循环结束以后,只要本队列的中断服务还是开着的,就要对中断控制器执行一次结束中断服务操作(622行),具体取决于中断控制器硬件的要求,所调用的函数也是在队列初始化时设置好的。

再看上面for循环中调用的handle_IRQ_event,这个函数依次执行队列中的各个中断服务程序,让它们辨认本次中断请求是否来自各自的服务对象,即中断源,如果是就进而提供相应的服务。其代码如下:

IRQ0x03_interrupt=>common_interrupt=>do_IRQ=>handle_IRQ_event

  1. /*
  2. * This should really return information about whether
  3. * we should do bottom half handling etc. Right now we
  4. * end up _always_ checking the bottom half, which is a
  5. * waste of time and is not what some drivers would
  6. * prefer.
  7. */
  8. int handle_IRQ_event(unsigned int irq, struct pt_regs * regs, struct irqaction * action)
  9. {
  10. int status;
  11. int cpu = smp_processor_id();
  12. irq_enter(cpu, irq);
  13. status = 1; /* Force the "do bottom halves" bit */
  14. if (!(action->flags & SA_INTERRUPT))
  15. __sti();
  16. do {
  17. status |= action->flags;
  18. action->handler(irq, action->dev_id, regs);
  19. action = action->next;
  20. } while (action);
  21. if (status & SA_SAMPLE_RANDOM)
  22. add_interrupt_randomness(irq);
  23. __cli();
  24. irq_exit(cpu, irq);
  25. return status;
  26. }

其中430行的irq_enter和446行的irq_exit只是对一个计数器进行操作,二者均定义于include/asm-i386/hardirq.h:

  1. #define irq_enter(cpu, irq) (local_irq_count(cpu)++)
  2. #define irq_exit(cpu, irq) (local_irq_count(cpu)--)

当这个计数器的值非0时就表示CPU正处于具体的中断服务程序中,以后读者会看到有些操作是不允许在此期间进行的。

一般来说,中断服务程序都是在关闭中断(不包括不可屏蔽中断NMI)的条件下执行的,这也是CPU在穿越中断门时自动关闭中断的原因。但是,关闭中断是个既不可不用,又不可滥用的手段,特别是当中断服务程序较长,操作比较复杂时,就有可能因关中断的时间持续太长而丢失其他的中断。经验表明,允许中断在同一个中断源或同一个中断通道嵌套是应该避免的,因此内核在do_IRQ中通过IRQ_PENDING标志位的运用来保证了这一点。可是,允许中断在不同的通道上嵌套,则只要处理得当就还是可行的。当然,必须十分小心。所以,在调用request_irq将一个中断服务程序挂入某个中断服务队列时,允许将参数irqflags中的一个标志位SA_SHIRQ置成0,表示该服务程序应该在开启中断的情况下执行。这里的434-435行和444行就是为此而设的(__sti为开中断,__cli为关中断)。

然后,从437行至行的do while循环就是实质性的操作了。它依次调用队列中的每一个中断服务程序。调用的参数有三:irq为中断请求号;action->dev_id是一个void指针,由具体的服务程序自行解释和运用,这是由设备驱动程序在调用request_irq时自己规定的;最后一个就是前述的pt_regs数据结构指针regs了。至于具体的中断服务程序,那是设备驱动范畴内的东西,这里就不讨论了。

。。。。。。