序
在第一讲中提到过异常的分类,根据同步或异步产生、无意或故意产生以及最终的的返回行为可以分为四类。但不管是哪种,CPU的响应过程基本一致。即CPU根据中断向量,在内存中找到相应的服务程序入口并调用该服务程序。中断向量和中断处理函数内内核定义。处理方法也不尽相同。
对于异常,一般是抛出一个信号给当前进程,因此在本讲的最后将涉及信号相关的内容。
x86对中断的硬件支持
中断和异常向量
x86中,每个中断和异常由0~255之间的一个数来标识,称为中断向量。非屏蔽中断和异常的向量是固定的,范围在0~31之间,而可屏蔽中断的向量可以通过对中断控制器的编程来改变,范围在32~255间。
在单处理器中,可编程中断控制器PIC来负责监视所有硬件并向CPU产生中断。在多处理器上,引入I/O高级可编程控制器(I/O APIC)的组件,形成一个多APIC的系统来将中断分发给所有CPU。这个分发过程既可以是静态的,即传递给指定的某个、某些或所有CPU;也可以是动态的,即分发给一个当前负荷最低的CPU上或通过仲裁确定分发到哪个CPU上。另外多APIC系统还可以在处理器间产生中断。
x86处理器有约20种不同的异常。内核必须为每种异常提供一个专门的异常处理程序。下面是一些重要的异常及向量、名称、类型和描述。
0 - “Divide error”故障:整数除0操作时产生。
1 - “Debug”陷阱或故障:调试程序时使用。
2 - 未用。
3 - “Breakpoint”陷阱:由int 3指令引起,通常由debugger程序插入。
4 - “Overflow”陷阱:计算溢出。
5 - “Bounds check”故障:地址边界有误。
6 - “Invalid opcode”:无效操作码。
7 - “Device not available”故障:
8 - “Double Fault”异常中止:少数情况下,异常处理过程中又出现异常。
10 - “Invalid TSS”故障:CPU试图让一个CPU切换到有无效TSS的进程。
13 - “General Protection”故障:违反了x86保护模式下的保护规则。
14 - “Page Fault”故障:寻址的页不在内存,或违反了一种页保护机制。
15 - 由Intel保留
16 - “Floating Point Error”故障:浮点单元出错。
17 - “Alignment Check”故障:操作数的地址没有正确对齐。
18 - “Machine Check”异常中止:机器检测到一个CPU错误或总线错误。
19 - “SIMD Floating Point Exception”故障:SSE或SSE2单元浮点操作异常。
20~31由Intel留作将来使用。
进入中断服务程序
我们接下来着重讨论CPU在得到中断向量后如何进入相应中断服务程序。
最早在实模式中,CPU把内存从0开始的1KB字节作为一个中断向量表,每个表项由两字节的段地址和两字节的位移组成一个四字节地址,即中断服务程序的入口地址。这与ARM等CPU处理方式一致。但是这个机制中并没有提供运行模式的转换,即ARM中PSW寄存器的功能。
后来Intel在实现保护模式时,对中断响应机制作了大幅修改。中断向量表变为中断描述符表IDT,其中的表项也从单纯的入口地址改成了一个描述符表项,称为“门”。门中附带一个段选择符,再按照段式寻址的方法找到中断服务程序的入口。
门的引入在增加了一个寻址步骤的同时实现了CPU运行优先级的切换。只要想切换CPU的运行状态,就必须通过一道门。按不同用途和目的,有任务门、中断门、陷阱门和调用门四种。
除任务门外,其余三种门的结构基本相同。区别是类型码不同。中断门和陷阱门在使用上的区别只在于进入中断服务程序时CPU是否自动关中断。
门中的段选择码字段就是段选择符,与位移字段一起决定了一个地址。可以参考第一讲中段式寻址的内容。
在Linux内核中,实际上只使用GDT。对于中断门、陷阱门和调用门来说,段描述表中的相应表项显然应该是一个代码段描述项,而任务门指向的描述项则是专门为TSS而设置的TSS描述项。
每个段描述符中都有一个两位的DPL位段,当CPU通过中断门找到一个代码段描述符,从而转入相应的服务程序时,就把这个代码段描述符装入CPU中,而描述项的DPL就变成CPU的当前运行级别,称为CPL。中断门中还有额外一个DPL字段,这种重复是由于x86出奇复杂的优先级别检验机制造成的。这里不详述,最终导致通过中断门时只允许保持或提升CPU的运行级别,而不允许降低其级别。如果出现错误,会产生一次全面保护异常。
进入中断服务程序时,CPU要将当前EFLAGS寄存器的内容以及返回地址压入堆栈,返回地址是由段寄存器CS的内容和取指令指针EIP的内容共同组成的。如果中断是由异常引起的,则还要将一个表示异常原因的出错代码也压入堆栈。进一步,如果中断前后运行级别不同,就要更换堆栈。
具体到Linux内核。当中断发生在用户态,中断后进入内核态,因此要引起堆栈的更换,即从用户栈更换到系统堆栈。而当中断发生在系统状态时,则不需要更换堆栈。
最后,在保护模式中,中断向量表在内存中的位置不再限于从地址0开始,而是像GDT和LDT一样可以放在内存的任意地方,由新增的寄存器IDTR指示。x86的256个中断向量一共需要占用2KB空间来存放IDT。Linux利用中断门处理中断,用陷阱门处理异常,因此IDT内存放的主要是中断门或陷阱门,还有特殊异常是任务门。
当中断发生后,根据上图中的方式,最终寻址到中断服务程序开始执行。当执行完毕退出时,需要产生一条IRET指令,使CPU进行一些硬件检查,具体的返回策略在后面从中断和异常返回中描述。
中断和异常的嵌套
每个中断或异常都会引起一个内核控制路径,即代表当前进程在内核态执行。
内核控制路径可以任意嵌套,即一个中断处理程序可以被另一个中断处理程序“中断”,如下图所示。
中断处理程序必须永不阻塞,即中断处理程序运行期间不能发生进程切换。整个过程中所需要的数据都存放在进程的内核态堆栈中。
中断处理程序可以抢占其他中断处理程序或异常处理程序,但异常处理程序从不抢占中断处理程序。在内核态唯一会触发的异常是缺页异常,其他所有异常都只发生在用户态。中断处理程序从不执行可以导致缺页的操作,因为这样的操作有可能引起进程切换。
初始化IDT
从上面可见,在允许中断前,内核必须适当初始化IDT。
当计算机还在实模式时,IDT被初始化并由BIOS例程使用。然而当Linux接管,IDT就被移到RAM的另一个区域,并进行第二次初始化。
IDT存放在idt_table表中,共有256个表项。6字节的idt_descr变量指定了IDT的大小和它的地址,内核用lidt汇编指令使用idt_descr变量初始化IDTR寄存器。
在内核初始化过程中,setup_idt()
汇编语言函数用同一个中断门来填充所有表项,该中断门指向ignore_int()
处理程序。这个程序什么都不做,也不应该被执行,否则就意味着出现了硬件或内核的问题。
然后IDT被第二次初始化,用有意义的陷阱和中断处理程序替换这个空处理程序。这个过程在start_kernel()
中调用trap_init()
和init_IRQ()
完成。
==================== arch/x86/kernel/traps.c 827 884 ====================
void __init trap_init(void)
{
int i;
#ifdef CONFIG_EISA
void __iomem *p = early_ioremap(0x0FFFD9, 4);
if (readl(p) == 'E' + ('I'<<8) + ('S'<<16) + ('A'<<24))
EISA_bus = 1;
early_iounmap(p, 4);
#endif
set_intr_gate(0, ÷_error);
set_intr_gate_ist(2, &nmi, NMI_STACK);
/* int4 can be called from all */
set_system_intr_gate(4, &overflow);
set_intr_gate(5, &bounds);
set_intr_gate(6, &invalid_op);
set_intr_gate(7, &device_not_available);
#ifdef CONFIG_X86_32
set_task_gate(8, GDT_ENTRY_DOUBLEFAULT_TSS);
#else
set_intr_gate_ist(8, &double_fault, DOUBLEFAULT_STACK);
#endif
set_intr_gate(9, &coprocessor_segment_overrun);
set_intr_gate(10, &invalid_TSS);
set_intr_gate(11, &segment_not_present);
set_intr_gate_ist(12, &stack_segment, STACKFAULT_STACK);
set_intr_gate(13, &general_protection);
set_intr_gate(15, &spurious_interrupt_bug);
set_intr_gate(16, &coprocessor_error);
set_intr_gate(17, &alignment_check);
#ifdef CONFIG_X86_MCE
set_intr_gate_ist(18, &machine_check, MCE_STACK);
#endif
set_intr_gate(19, &simd_coprocessor_error);
/* Reserve all the builtin and the syscall vector: */
for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++)
set_bit(i, used_vectors);
#ifdef CONFIG_IA32_EMULATION
set_system_intr_gate(IA32_SYSCALL_VECTOR, ia32_syscall);
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
#ifdef CONFIG_X86_32
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
set_bit(SYSCALL_VECTOR, used_vectors);
#endif
/*
* Should be a barrier for any external CPU state:
*/
cpu_init();
x86_init.irqs.trap_init();
}
忽略一开始的EISA设置,程序先设置中断向量表开头的19个陷阱门,我们注意到其中漏掉几个,这在之前其他函数如early_trap_init()中已经设置完毕。然后是对系统调用向量的初始化,常数SYSCALL_VECTOR定义为0x80。
这里一共使用了五个函数。
=========== arch/x86/include/asm/desc.h 330 334 =============
static inline void set_intr_gate(unsigned int n, void *addr)
{
BUG_ON((unsigned)n > 0xFF);
_set_gate(n, GATE_INTERRUPT, addr, 0, 0, __KERNEL_CS);
}
它插入一个中断门,段选择符指向内核代码段,DPL为0。
set_intr_gate_ist()
在32位系统上与set_intr_gate()
相同。
=============== arch/x86/include/asm/desc.h 359 363 ===============
static inline void set_system_intr_gate(unsigned int n, void *addr)
{
BUG_ON((unsigned)n > 0xFF);
_set_gate(n, GATE_INTERRUPT, addr, 0x3, 0, __KERNEL_CS);
}
它插入一个中断门,段选择符指向内核代码段,DPL为3。
================= arch/x86/include/asm/desc.h 377 381 =================
static inline void set_task_gate(unsigned int n, unsigned