http://www.eefocus.com/article/09-06/74822s.html
http://www.openhw.org/html/09-06/74822.shtml
3.4.1中断和异常处理的硬件处理
首先,我们从硬件的角度来看CPU如何处理中断和异常。这里假定内核已被初始化,CPU已从实模式转到保护模式。
当CPU执行了当前指令之后,CS和EIP这对寄存器中所包含的内容就是下一条将要执行指令的逻辑地址。在对下一条指令执行前,CPU先要判断在执行当前指令的过程中是否发生了中断或异常。如果发生了一个中断或异常,那么CPU将做以下事情:
· 确定所发生中断或异常的向量i(在0~255之间)。
· 通过IDTR寄存器找到IDT表,读取IDT表第i项(或叫第i个门)。
· 分两步进行有效性检查:首先是“段”级检查,将CPU的当前特权级CPL(存放在CS寄存器的最低两位)与IDT中第i项段选择符中的DPL相比较,如果DPL(3)大于CPL(0),就产生一个“通用保护”异常(中断向量13),因为中断处理程序的特权级不能低于引起中断的程序的特权级。这种情况发生的可能性不大,因为中断处理程序一般运行在内核态,其特权级为0。然后是“门”级检查,把CPL与IDT中第i个门的DPL相比较,如果CPL大于DPL,也就是当前特权级(3)小于这个门的特权级(0),CPU就不能“穿过”这个门,于是产生一个“通用保护”异常,这是为了避免用户应用程序访问特殊的陷阱门或中断门。但是请注意,这种“门”级检查是针对一般的用户程序,而不包括外部I/O产生的中断或因CPU内部异常而产生的异常,也就是说,如果产生了中断或异常,就免去了“门”级检查。
检查是否发生了特权级的变化。当中断发生在用户态(特权级为3),而中断处理程序运行在内核态(特权级为0),特权级发生了变化,所以会引起堆栈的更换。也就是说,从用户堆栈切换到内核堆栈。而当中断发生在内核态时,即CPU在内核中运行时,则不会更换堆栈,如图3.5所示。
图3.5中断处理程序堆栈示意图
从图可以看出,当从用户态堆栈切换到内核态堆栈时,先把用户态堆栈的值压入中断程序的内核态堆栈中,同时把 EFLAGS寄存器自动压栈,然后把被中断进程的返回地址压入堆栈。如果异常产生了一个硬件错误码,则将它也保存在堆栈中。如果特权级没有发生变化,则压入栈中的内容如图3.4中‚。你可能要问,现在SS:ESP和CS:EIP 这两对寄存器的值分别是什么?SS:ESP的值从当前进程的TSS中获得,也就是获得当前进程的内核栈指针,因为此时中断处理程序成为当前进程的一部分,代表当前进程在运行。CS:EIP的值就是IDT表中第i项门描述符的段选择符和偏移量的值,此时,CPU就跳转到了中断或异常处理程序。
3.4.2 Linux对异常和中断的处理
上面给出了硬件对异常和中断进行处理的一般步骤,下面将概要描述Linux对异常和中断的处理,具体的实现过程将在后面介绍。
1.异常处理
Linux利用异常来达到两个截然不同的目的:
· 给进程发送一个信号以通报一个反常情况
· 管理硬件资源
对于第一种情况,例如,如果进程执行了一个被0除的操作,CPU则会产生一个“除法错误”异常,并由相应的异常处理程序向当前进程发送一个SIGFPE信号。当前进程接收到这个信号后,就要采取若干必要的步骤,或者从错误中恢复,或者终止执行(如果这个信号没有相应的信号处理程序)。
对于第二种情况,内核使用两种异常来有效地管理硬件资源,相应的处理程序也就更复杂。在这种情况下,异常并不表示一种错误情况:
· 用“设备不可用”异常来推迟装载浮点寄存器。
· 用“缺页”异常推迟把新页框分配给进程。
内核对异常处理程序的调用有一个标准的结构,它由以下三部分组成:
· 在内核栈中保存大多数寄存器的内容(由汇编语言实现)
· 调用C编写的异常处理函数
· 通过ret_from_exception()函数从异常退出。
关于内核对异常的具体处理在此不进行详细介绍,在第六章内存管理中我们将涉及到“缺页”异常处理程序,本节的重点放在中断处理。
2.中断处理
当一个中断发生时,并不是所有的操作都具有相同的急迫性。事实上,把所有的操作都放进中断处理程序本身并不合适。需要时间长的、非重要的操作应该推后,因为当一个中断处理程序正在运行时,相应的IRQ中断线上再发出的信号就会被忽略。更重要的是,中断处理程序是代表进程执行的,它所代表的进程必需总处于TASK_RUNNING状态,否则,就可能出现系统僵死情形。因此,中断处理程序不能执行任何阻塞过程,如I/O设备操作。因此,Linux把一个中断要执行的操作分为下面的三类:
(1)紧急的(Critical)
这样的操作诸如:中断到来时中断控制器做出应答,对中断控制器或设备控制器重新编程,或者对设备和处理器同时访问的数据结构进行修改。这些操作都是紧急的,应该被很快地执行,也就是说,紧急操作应该在一个中断处理程序内立即执行,而且是在禁用中断的状态下。
(2)非紧急的(Noncritical)
这样的操作如修改那些只有处理器才会访问的数据结构(例如,按下一个键后,读扫描码)。这些操作也要很快地完成,因此,它们由中断处理程序立即执行,但在启用中断的状态下。
(3)非紧急可延迟的(Noncritical deferrable)
这样的操作如,把一个缓冲区的内容拷贝到一些进程的地址空间(例如,把键盘行缓冲区的内容发送到终端处理程序的进程)。这些操作可能被延迟较长的时间间隔而不影响内核操作:有兴趣的进程会等待需要的数据。非紧急可延迟的操作由一些被称为“下半部分”(bottom halves)的函数来执行。我们将在后面讨论“下半部分”。
所有的中断处理程序都执行四个基本的操作:
· 在内核栈中保存IRQ的值和寄存器的内容。
· 给与IRQ中断线相连的中断控制器发送一个应答,这将允许在这条中断线上进一步发出中断请求。
· 执行共享这个IRQ的所有设备的中断服务例程(ISR)。
· 跳到ret_from_intr( )的地址后终止。
3.4.3 与堆栈有关的常量、数据结构及宏
在中断、异常及系统调用的处理中,涉及一些相关的常量、数据结构及宏,在此先给予介绍(大部分代码在arch/i386/kernel/entry.S中)。
1. 常量定义
下面这些常量定义了进入中断处理程序时,相关寄存器与堆栈指针(ESP)的相对位置,图3.6给出了在相应位置上所保存的寄存器内容:
EBX = 0x00 ECX= 0x04 EDX= 0x08 ESI= 0x0C EDI= 0x10 EB = 0x14 EAX= 0x18 DS= 0x1C ES = 0x20 ORIG_EAX = 0x24 EIP = 0x28 CS = 0x2C EFLAGS = 0x30 OLDESP= 0x34 OLDSS = 0x38 |
图3.6 进入中断理程序时内核堆栈示意图
其中,ORIG_EAX是Original eax之意,其具体含义将在后面介绍。
2.存放在栈中的寄存器结构pt_regs
在内核中,很多函数的参数是pt_regs数据结构,定义在include/i386/ptrace.h中:
struct pt_regs { long ebx; long ecx; long edx; long esi; long edi; long ebp; long eax; int xds; int xes; long orig_eax; long eip; int xcs; long eflags; long esp; int xss; }; |
把这个结构与内核栈的内容相比较,会发现堆栈的内容是这个数据结构的一个映象。
3.保存现场的宏SAVE_ALL
在中断发生前夕,要把所有相关寄存器的内容都保存在堆栈中,这是通过SAVE_ALL宏完成的:
#define SAVE_ALL \ cld; \ pushl %es; \ pushl %ds; \ pushl %eax; \ pushl %ebp; \ pushl %edi; \ pushl %esi; \ pushl %edx; \ pushl %ecx; \ pushl %ebx; \ movl $(__KERNEL_DS),%edx; \ movl %edx,%ds; \ movl %edx,%es; |
该宏执行以后,堆栈内容如图3.6所示。把这个宏与图3.5 结合起来就很容易理解图3.6,在此对该宏再给予解释:
· CPU在进入中断处理程序时自动将用户栈指针(如果更换堆栈)、EFLAGS寄存器及返回地址一同压入堆栈。
· 段寄存器DS和ES原来的内容入栈,然后装入内核数据段描述符__KERNEL_DS(定义为0x18),内核段的DPL为0。
4.恢复现场的宏RESTORE_ALL
当从中断返回时,恢复相关寄存器的内容,这是通过RESTORE_ALL宏完成的:
#define RESTORE_ALL \ popl %ebx; \ popl %ecx; \ popl %edx; \ popl %esi; \ popl %edi; \ popl %ebp; \ popl %eax; \ 1: popl %ds; \ 2: popl %es; \ addl $4,%esp; \ 3: iret; |
可以看出,RESTORE_ALL与SAVE_ALL遥相呼应。当执行到iret指令时,内核栈又恢复到刚进入中断门时的状态,并使CPU从中断返回。
5.将当前进程的task_struct 结构的地址放在寄存器中
#define GET_CURRENT(reg) \ movl $-8192, reg; \ andl %esp, reg |
从下一章“task_struct 结构在内存存放”一节我们将知道,当前进程的task_struct存放在内核栈的底部,因此,以上两条指令就可以把task_struct结构的地址放在reg寄存器中。
3.4.4 中断处理程序的执行
从前面的介绍,我们已经知道了 i386的中断机制及有关的初始化工作。现在,我们可以从中断请求的发生到CPU的响应,再到中断处理程序的调用和返回,沿着这一思路走一遍,以体会Linux内核对中断的响应及处理。
假定外设的驱动程序都已完成了初始化工作,并且已把相应的中断服务例程挂入到特定的中断请求队列。 又假定当前进程正在用户空间运行(随时可以接受中断),且外设已产生了一次中断请求。当这个中断请求通过中断控制器8259A到达CPU的中断请求引线INTR时(参看图3.1),CPU就在执行完当前指令后来响应该中断。
CPU从中断控制器的一个端口取得中断向量I,然后根据I从中断描述符表IDT中找到相应的表项,也就是找到相应的中断门。因为这是外部中断,不需要进行“门级”检查,CPU就可以从这个中断门获得中断处理程序的入口地址,假定为IRQ0x05_interrupt。因为这里假定中断发生时CPU运行在用户空间(CPL=3),而中断处理程序属于内核(DPL=0),因此,要进行堆栈的切换。也就是说,CPU从TSS中取出内核栈指针,并切换到内核栈(此时栈还为空)。当CPU进入IRQ0x05_interrupt时,内核栈如图3.5的,栈中除用户栈指针、EFLAGS的内容以及返回地址外再无其他内容。另外,由于CPU进入的是中断门(而不是陷阱门),因此,这条中断线已被禁用,直到重新启用。
我们用IRQn_interrupt来表示从IRQ0x01_interrupt 到IRQ0x0f_interrupt任意一个中断处理程序。这个中断处理程序实际上要调用do_IRQ(),而do_IRQ()要调用handle_IRQ_event()函数;最后这个函数才真正地执行中断服务例程(ISR)。图3.7给出它们的调用关系:
图3.7中断处理函数的调用关系
1.中断处理程序IRQn_interrupt
我们首先看一下从IRQ0x01_interrupt 到IRQ0x0f_interrupt的这16个函数是如何定义的,在i8259.c中定义了如下宏:
#define BI(x,y) \ BUILD_IRQ(x##y) #define BUILD_16_IRQS(x) \ BI(x,0) BI(x,1) BI(x,2) BI(x,3) \ BI(x,4) BI(x,5) BI(x,6) BI(x,7) \ BI(x,8) BI(x,9) BI(x,a) BI(x,b) \ BI(x,c) BI(x,d) BI(x,e) BI(x,f) BUILD_16_IRQS(0x0) |
经过gcc的预处理,宏定义BUILD_16_IRQS(0x0) 会被展开成BUILD_IRQ(0x00)至BUILD_IRQ(0x0f)。BUILD_IRQ宏是一段嵌入式汇编代码(在/include/i386/hw_irq.h中),为了有助于理解,我们把它展开成下面的汇编语言片段:
IRQn_interrupt: pushl $n-256 jmp common_interrupt |
把中断号减256的结果保存在栈中,这就是进入中断处理程序后第一个压入堆栈的值,也就是堆栈中ORIG_EAX的值,如图3.6。这是一个负数,正数留给系统调用使用。对于每个中断处理程序,唯一不同的就是压入栈中的这个数。然后,所有的中断处理程序都跳到一段相同的代码common_interrupt。这段代码可以在BUILD_COMMON_IRQ 宏中找到,同样,我们略去其嵌入式汇编源代码,而把这个宏展开成下列的汇编语言片段:
common_interrupt: SAVE_ALL call do_IRQ jmp ret_from_intr |
SAVE_ALL宏已经在前面介绍过,它把中断处理程序会使用的所有CPU寄存器都保存在栈中。然后,BUILD_COMMON_IRQ 宏调用do_IRQ( )函数,因为通过CALL调用这个函数,因此,该函数的返回地址被压入栈。当执行完do_IRQ( ),就跳转到ret_from_intr( )地址(参见后面的“从中断和异常返回)。
2. do_IRQ( )函数
do_IRQ()这个函数处理所有外设的中断请求。当这个函数执行时,内核栈从栈顶到栈底包括:
· do_IRQ( )的返回地址
· 由SAVE_ALL 推进栈中的一组寄存器的值
· ORIG_EAX(即n-256)
· CPU自动保存的寄存器
该函数的实现用到中断线的状态,下面给予具体说明:
#define IRQ_INPROGRESS 1 /* 正在执行这个IRQ的一个处理程序*/
#define IRQ_DISABLED 2 /* 由设备驱动程序已经禁用了这条IRQ中断线 */
#define IRQ_PENDING 4 /* 一个IRQ已经出现在中断线上,且被应答,但还没有为它提供服务 */
#define IRQ_REPLAY 8 /* 当Linux重新发送一个已被删除的IRQ时 */
#define IRQ_AUTODETECT 16 /* 当进行硬件设备探测时,内核使用这条IRQ中断线 */
#define IRQ_WAITING 32 /*当对硬件设备进行探测时,设置这个状态以标记正在被测试的irq */
#define IRQ_LEVEL 64 /* IRQ level triggered */
#define IRQ_MASKED 128 /* IRQ masked - shouldn't be seen again */
#define IRQ_PER_CPU 256 /* IRQ is per CPU */
这8个状态的前5个状态比较常用,因此我们给出了具体解释。另外,我们还看到每个状态的常量是2的幂次方。最大值为256(28), 因此可以用一个字节来表示这8个状态,其中每一位对应一个状态。
该函数在arch//i386/kernel/irq.c中定义如下:
asmlinkage unsigned int do_IRQ(struct pt_regs regs) { /* 函数返回0则意味着这个irq正在由另一个CPU进行处理, 或这条中断线被禁用*/ int irq = regs.orig_eax & 0xff; /* 还原中断号 */ int cpu = smp_processor_id(); /*获得CPU号*/ irq_desc_t *desc = irq_desc + irq; /*在irq_desc[]数组中获得irq 的描述符*/ struct irqaction * action; unsigned int status; kstat.irqs[cpu][irq]++; spin_lock(&desc->lock); /*针对多处理机加锁*/ desc->handler->ack(irq); /*CPU对中断请求给予确认*/ status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING); status |= IRQ_PENDING; /* we _want_ to handle it */ action = NULL; if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) { action = desc->action; status &= ~IRQ_PENDING; /* we commit to handling */ status |= IRQ_INPROGRESS; /* we are handling it */ } desc->status = status; if (!action) goto out; for (;;) { spin_unlock(&desc->lock); /*进入临界区* handle_IRQ_event(irq, ®s, action); spin_lock(&desc->lock); /*出临界区*/ if (!(desc->status & IRQ_PENDING)) break; desc->status &= ~IRQ_PENDING; } desc->status &= ~IRQ_INPROGRESS; out: /* * The ->end() handler has to deal with interrupts which got * disabled while the handler was running. */ desc->handler->end(irq); spin_unlock(&desc->lock); if (softirq_pending(cpu)) do_softirq(); /*处理软中断*/ return 1; } |
下面对这个函数进行进一步的讨论:
· 当执行到for (;;)这个无限循环时,就准备对中断请求队列进行处理,这是由handle_IRQ_event ()函数完成的。因为中断请求队列为一临界资源,因此在进入这个函数前要加锁。
· handle_IRQ_event ()函数的主要代码片段为:
if (!(action->flags & SA_INTERRUPT)) __sti(); /*关中断*/ do { status |= action->flags; action->handler(irq, action->dev_id, regs); action = action->next; } while (action); __cli(); /*开中断*/ |
这个循环依次调用请求队列中的每个中断服务例程。中断服务例程及其参数已经在前面进行过简单描 述,至于更具体的解释将在驱动程序一章进行描述。
· 这里要说明的是,中断服务例程都在关中断的条件下进行(不包括非屏蔽中断),这也是为什么CPU在穿过中断门时自动关闭中断的原因。但是,关中断时间绝不能太长,否则就可能丢失其它重要的中断。也就是说,中断服务例程应该处理最紧急的事情,而把剩下的事情交给另外一部分来处理。即后半部分(bottom half)来处理,这一部分内容将在下一节进行讨论。
· 经验表明,应该避免在同一条中断线上的中断嵌套,内核通过IRQ_PENDING标志位的应用保证了这一点。当do_IRQ()执行到for (;;)循环时,desc->status 中的IRQ_PENDING的标志位肯定为0(想想为什么?)。当CPU执行完handle_IRQ_event ()函数返回时,如果这个标志位仍然为0,那么循环就此结束。如果这个标志位变为1,那就说明这条中断线上又有中断产生(对单CPU而言),所以循环又执行一次。通过这种循环方式,就把可能发生在同一中断线上的嵌套循环化解为“串行”。
· 不同的CPU不允许并发地进入同一中断服务例程,否则,那就要求所有的中断服务例程必须是“可重入”的纯代码。可重入代码的设计和实现就复杂多了,因此,Linux在设计内核时巧妙地“避难就易”,以解决问题为主要目标。
· 在循环结束后调用desc->handler->end()函数,具体来说,如果没有设置IRQ_DISABLED标志位,就调用低级函数enable_8259A_irq()来启用这条中断线。
· 如果这个中断有后半部分,就调用do_softirq()执行后半部分。
3.4.5 从中断返回
从前面的讨论我们知道,do_IRQ()这个函数处理所有外设的中断请求。这个函数执行的时候,内核栈栈顶包含的就是do_IRQ()的返回地址,这个地址指向ret_from_intr。实际上,ret_from_intr是一段汇编语言的入口点,为了描述简单起见,我们以函数的形式提及它。虽然我们这里讨论的是中断的返回,但实际上中断、异常及系统调用的返回是放在一起实现的,因此,我们常常以函数的形式提到下面这三个入口点:
ret_from_intr()
终止中断处理程序
ret_from_sys_call( )
终止系统调用,即由0x80引起的异常。
ret_from_exception( )
终止除了0x80的所有异常
在相关的计算机课程中,我们已经知道从中断返回时CPU要做的事情,下面我们来看一下Linux内核的具体实现代码(在entry.S中):
ENTRY(ret_from_intr) GET_CURRENT(%ebx) ret_from_exception: movl EFLAGS(%esp),%eax # mix EFLAGS and CS movb CS(%esp),%al testl $(VM_MASK | 3),%eax # return to VM86 mode or non-supervisor? jne ret_from_sys_call jmp restore_all |
这里的GET_CURRENT(%ebx)将当前进程task_struct结构的指针放入寄存器EBX中,此时,内核栈的内容还如图3.6所示。然后两条“mov”指令是为了把中断发生前夕EFALGS寄存器的高16位与代码段CS寄存器的内容拼揍成32位的长整数,其目的是要检验:
· 中断前夕CPU是否够运行于VM86模式
· 中断前夕CPU是运行在用户空间还是内核空间。
VM86模式是为在i386保护模式下模拟运行DOS软件而设置的,EFALGS寄存器高16位中有个标志位表示CPU是否运行在VM86模式,我们在此不予详细讨论。CS的最低两位表示中断发生时CPU的运行级别CPL,若这两位为3,说明中断发生于用户空间。
如果中断发生在内核空间,则控制权直接转移到标号restore_all。如果中断发生于用户空间(或VM86模式),则转移到ret_from_sys_call:
ENTRY(ret_from_sys_call) cli # need_resched and signals atomic test cmpl $0,need_resched(%ebx) jne reschedule cmpl $0,sigpending(%ebx) jne signal_return restore_all: RESTORE_ALL reschedule: call SYMBOL_NAME(schedule) # test jmp ret_from_sys_call |
进入ret_from_sys_call后,首先关中断,也就是说,执行这段代码时CPU不接受任何中断请求。然后,看调度标志是否为非0,其中常量need_resched 定义为20,need_resched(%ebx)表示当前进程task_struct结构中偏移量need_resched处的内容,如果调度标志为非0,说明需要进行调度,则去调用schedule()函数进行进程调度,这将在第五章进行详细讨论。
同样,如果当前进程的task_struct结构中的sigpending标志为非0,就表示该进程有信号等待处理,要先处理完这些信号后才从中断返回,关于信号的处理将在“进程间通信”一章进行讨论。处理完信号,控制权还是返回到restore_all。RESTORE_ALL宏操作在前面已经介绍过,也就是恢复中断现场,彻底从中断返回。