初识中断和异常

目录

中断信号的作用

中断和异常

IRQ和中断

高级可编程中断控制器

异常

中断描述符表

中断和异常的硬件处理

中断和异常处理程序的嵌套执行

初始化中断描述符表

中断门、陷阱门及系统门

IDT的初步初始化

异常处理

为异常处理程序保存寄存器的值

进入和离开异常处理程序

中断(interrupt)通常被定义为一个事件,该事件改变处理器执行的指令顺序。这样的事件与CPU芯片内外部硬件电路产生的电信号相对应。

中断通常分为同步(synchronous)中断和异步(asynchronous)中断:

  • 同步中断是当指令执行时由CPU控制单元产生的,之所以称为同步,是因为只有在一条指令终止执行后CPU才会发出中断。
  • 异步中断是由其他硬件设备依照CPU时钟信号随机产生的。

在Intel微处理器手册中,把同步和异步中断分别称为异常(exception)和中断(interrupt)。我们也采用这种分类,当然有时我们也用术语“中断信号”指这两种类型(同步及异步)。

中断是由间隔定时器和1/O设备产生的,例如,用户的一次按键会引起一个中断。

另一方面,异常是由程序的错误产生的,或者是由内核必须处理的异常条件产生的。第一种情况下,内核通过发送一个每个Unix程序员都熟悉的信号来处理异常。第二种情况下,内核执行恢复异常需要的所有步骤,例如缺页,或对内核服务的一个请求(通过一条int或sysenter指令)。

我们在下一节描述引入信号的动机,以此开始进行学习。然后,说明由I/0设备产生的著名lRQ(Interrupt ReQuest)如何引起中断,我们将详细讨论80×86微处理器如何在硬件级处理中断和异常。接下来,我们将在“初始化中断描述符表”一节阐明Linux如何初始化Intel中断结构必需的所有数据结构。剩余的3节描述Linux如何在软件级处理中断信号。

继续进行学习之前,需要值得注意的是:我们在本章仅涉及对所有PC都通用的“经典”中断,而不涉及一些体系结构的非标准中断。

中断信号的作用

顾名思义,中断信号提供了一种特殊的方式,使处理器转而去运行正常控制流之外的代码。当一个中断信号达到时,CPU必须停止它当前正在做的事情,并且切换到一个新的活动。为了做到这一点,就要在内核态堆栈保存程序计数器的当前值(即eip和cs寄存器的内容),并把与中断类型相关的一个地址放进程序计数器。

在本章,有些事情会使你想起在前一章描述的上下文切换,这发生在内核用一个进程替换另一个进程时。但是,中断处理与进程切换有一个明显的差异:由中断或异常处理程序执行的代码不是一个进程。更确切地说,它是一个内核控制路径,代表中断发生时正在运行的进程执行(参见本章“中断和异常处理程序的嵌套执行”一节)。作为一个内核控制路径,中断处理程序比一个进程要“轻”(light)(中断的上下文很少,建立或终止中断处理需要的时间很少)。

中断处理是由内核执行的最敏感的任务之一,因为它必须满足下列约束:

  • 当内核正打算去完成一些别的事情时,中断随时会到来。因此,内核的目标就是让中断尽可能快地处理完,尽其所能把更多的处理向后推迟。例如,假设一个数据块已到达了网线,当硬件中断内核时,内核只简单地标志数据到来了,让处理器恢复到它以前运行的状态,其余的处理稍后再进行(如把数据移入一个缓冲区,它的接收进程可以在缓冲区找到数据并恢复这个进程的执行)。因此,内核响应中断后露要进行的操作分为两部分:关键而紧急的部分,内核立即执行;其余推迟的部分,内核随后执行
  • 因为中断随时会到来,所以内核可能正在处理其中的一个中断时,另一个中断(不同类型)又发生了。应该尽可能多地允许这种情况发生,因为这能维持更多的I/O设备处于忙状态(参见“中断和异常处理程序的嵌套执行”一节)。因此,中断处理程序必须编写成使相应的内核控制路径能以嵌套的方式执行。当最后一个内核控制路径终止时,内核必须能恢复被中断进程的执行,或者,如果中断信号已导致了重新调度,内核能切换到另外的进程。
  • 尽管内核在处理前一个中断时可以接受一个新的中断,但在内核代码中还是存在一些临界区,在临界区中,中断必须被禁止。必须尽可能地限制这样的临界区,因为根据以前的要求,内核,尤其是中断处理程序,应该在大部分时间内以开中断的方式运行。

中断和异常

Intel文档把中断和异常分为以下几类:

  • 中断:
    • 可屏蔽中断(maskable interrupt):I/0设备发出的所有中断请求(lRQ)都产生可屏蔽中断。可屏蔽中断可以处于两种状态:屏蔽的(masked)或非屏蔽的(unmasked);一个屏蔽的中断只要还是屏蔽的,控制单元就忽略它。
    • 非屏蔽中断(nonmaskable Interrupt):只有几个危急事件(如硬件故障)才引起非屏蔽中断。非屏蔽中断总是由CPU辨认
  • 异常:
    • 处理器探测异常(processor-detected exception):当CPU执行指令时探测到的一个反常条件所产生的异常。可以进一步分为三组,这取决于CPU控制单元产生异常时保存在内核态堆栈eip寄存器中的值。
      • 故障(fault)
        通常可以纠正:一旦纠正,程序就可以在不失连贯性的情况下重新开始。保存在eip中的值是引起故障的指令地址因此,当异常处理程序终止时,那条指令会被重新执行。我们将在第九章的“缺页异常处理程序”一节中看到,只要处理程序能纠正引起异常的反常条件,重新执行同一指令就是必要的。
      • 陷阱(trap)
        在陷阱指令执行后立即报告;内核把控制权返回给程序后就可以继续它的执行而不失连贯性。保存在eip中的值是一个随后要执行的指令地址。只有当没有必要重新执行已终止的指令时,才触发陷阱。陷阱的主要用途是为了调试程序。在这种情况下,中断信号的作用是通知调试程序一条特殊指令已被执行(例如到了一个程序内的断点)。一旦用户检查到调试程序所提供的数据,她就可能要求被调试程序从下一条指令重新开始执行。
      • 异常中止(abort)
        发生一个严重的错误;控制单元出了问题,不能在eip寄存器中保存引起异常的指令所在的确切位置。异常中止用于报告严重的错误,如硬件故障或系统表中无效的值或不一致的值。由控制单元发送的这个中断信号是紧急信号,用来把控制权切换到相应的异常中止处理程序,这个异常中止处理程序除了强制受影响的进程终止外,没有别的选择。
    • 编程异常(programmed exception):在编程者发出请求时发生。是由int或int3指令触发的;当into(检查溢出)和bound(检查地址出界)指令检查的条件不为真时,也引起编程异常。控制单元把编程异常作为陷阱来处理。编程异常通常也叫做软中断(software interrupt)。这样的异常有两种常用的用途:执行系统调用及给调试程序通报一个特定的事件(参见第十章)。

每个中断和异常是由0~255之间的一个数来标识。因为一些未知的原因,Intel把这个8位的无符号整数叫做一个向量(vector)。非屏蔽中断的向量和异常的向量是固定的,而可屏蔽中断的向量可以通过对中断控制器的编程来改变(参见下一节)。

IRQ和中断

每个能够发出中断请求的硬件设备控制器都有一条名为IRQ(Interupt ReQuest)的输出线(注1)。所有现有的IRQ线(IRQ line)都与一个名为可编程中断控制器(Programmable Interret Controuer.PIC)的硬件电路的输入引脚相连。可编程中断控制器执行下列动作:

  • 监视IRQ线,检查产生的信号(raised signal)。如果有条或两条以上的IRQ线上产生信号,就选择引脚编号较小的IRQ线。
  • 如果一个引发信号出现在IRQ线上:
    • 把接收到的引发信号转换成对应的向量。
    • 把这个向量存放在中断控制的一个I/O端口,从而允许CPU通过数据总线读此向量。
    • 把引发信号发送到处理器的INTR引脚,即产生一个中断。
    • 等待,直到CPU通过把这个中断信号写进可编程中断控制器的一个I/O端口来确认它;当这种情况发生时,清INTR线。
  • 返回到第1步。

IRQ线是从0开始顺序编号的,因此,第一条IRQ线通常表示成IRQ0。与IRQn关联的Intel的缺省向量是n+32。如前所述,通过向中断控制器端口发布合适的指令,就可以修改IRQ和向量之间的映射。

可以有选择地禁止每条IRQ线。因此,可以对PIC编程从而禁止IRQ,也就是说,可以告诉PIC停止对给定的IRQ线发布中断,或者激活它们。禁止的中断是丢失不了的,它们一旦被激活,PIC就又把它们发送到CPU。这个特点被大多数中断处理程序使用,因为这允许中断处理程序逐次地处理同一类型的IRQ。

有选择地激活/禁止IRQ线不同于可屏蔽中断的全局屏蔽/非屏蔽。当eflags寄存器的IF标志被清0时,由PIC发布的每个可屏蔽中断都由CPU暂时忽略。cli和sti汇编指令分别清除和设置该标志。

传统的PIC是由两片8259A风格的外部芯片以“级联”的方式连接在一起的。每个芯片可以处理多达8个不同的IRQ输入线。因为从PIC的INT输出线连接到主PIC的IRQ2引脚,因此,可用IRQ线的个数限制为15。

高级可编程中断控制器

以前的描述仅涉及为单处理器系统设计的PIC。如果系统只有一个单独的CPU,那么主PIC的输出线以直截了当的方式连接到CPU的INTR引脚。然而,如果系统中包含两个或多个CPU,那么这种方式不再有效,因而需要更复杂的PIC。

为了充分发挥SMP体系结构的并行性,能够把中断传递给系统中的每个CPU至关重要。基于此理由,Intel从Pentiun III开始引入了一种名为l/O高级可编程控制器(/OAdvanced Programmable Interrupt Controller,I/0APIC)的新组件,用以代替老式的8259A可编程中断控制器。新近的主板为了支持以前的操作系统都包括两种芯片。此外,80x86微处理器当前所有的CPU都含有一个本地APIC。每个本地APIC都有32位的寄存器、一个内部时钟、一个本地定时设备及为本地APIC中断保留的两条额外的IRQ线LINTO和LINT1。所有本地APIC都连接到一个外部I/OAPIC,形成一个多APIC的系统。

图4-1以示意图的方式显示了一个多APIC系统的结构。一条APIC总线把“前端”1/O APIC连接到本地APIC。来自设备的IRQ线连接到I/OAPIC,因此,相对于本地APIC,1/0APIC起路由器的作用。在Pentium Ill和早期处理器的母板上,APIC总线是一个串行三线总线;从Pentium4开始,APIC总线通过系统总线来实现。不过,因为APIC总线及其信息对软件是不可见的,因此,我们不做进一步的详细讨论。

1/0APIC的组成为:一组24条IRQ线、一张24项的中断重定向表(Interrupt Redirection Table)、可编程寄存器,以及通过APIC总线发送和接收APIC信息的一个信息单元。与8259A的IRQ引脚不同,中断优先级并不与引脚号相关联:中断重定向表中的每一项都可以被单独编程以指明中断向量和优先级、目标处理器及选择处理器的方式。重定向表中的信息用于把每个外部IRQ信号转换为一条消息,然后,通过APIC总线把消息发送给一个或多个本地APIC单元。

来自外部硬件设备的中断请求以两种方式在可用CPU之间分发:

  • 静态分发
    • IRQ信号传递给重定向表相应项中所列出的本地APIC。中断立即传递给一个特定的CPU,或一组CPU,或所有CPU(广播方式)。 
  • 动态分发
    • 如果处理器正在执行最低优先级的进程,IRQ信号就传递给这种处理器的本地APIC。每个本地APIC都有一个可编程任务优先级寄存器(task priority register,TPR),TPR用来计算当前运行进程的优先级。lntel希望在操作系统内核中通过每次进程切换对这个寄存器进行修改。
    • 如果两个或多个CPU共享最低优先级,就利用仲裁(arbitration)技术在这些CPU之间分配负荷。在本地APIC的仲裁优先级寄存器中,给每个CPU都分配一个0(最低)~15(最高)范围内的值。
    • 每当中断传递给一个CPU时,其相应的仲裁优先级就自动置为0,而其他每个CPU的仲裁优先级都增加1。当仲裁优先级寄存器大于l5时,就把它置为获胜CPU的前一个仲裁优先级加l。因此,中断以轮转方式在CPU之间分发,且具有相同的任务优先级(注2)。

除了在处理器之间分发中断外,多APIC系统还允许CPU产生处理器间中断(interprocessor interrupt)。当一个CPU希望把中断发给另一个CPU时,它就在自己本地APIC的中断指令寄存器(Interrupt Command Register,ICR)中存放这个中断向量和目标本地APIC的标识符。然后,通过APIC总线向目标本地APIC发送一条消息,从而向自己的CPU发出一个相应的中断。

处理器间中断(简称IPl)是SMP体系结构至关重要的组成部分,并由Linux有效地用来在CPU之间交换信息(参见本章后面)。

目前大部分单处理器系统都包含一个I/OAPIC芯片,可以用以下两种方式对这种芯片进行配置:

  • 作为一种标准8259A方式的外部PIC连接到CPU。本地APIC被禁止,两条LINTO和LINT1本地IRQ线分别配置为INTR和NMI引脚。
  • 作为一种标准外部I/OAPIC。本地APIC被激活,且所有的外部中断都通过I/OAPIC接收。

异常

80x86微处理器发布了太约20种不同的异常(注3)。内核必须为每种异常提供一个专门的异常处理程序。对于某些异常,CPU控制单元在开始执行异常处理程序前会产生一个硬件出错码(hardware error code),并且压入内核态堆栈。

下面的列表给出了在80x86处理器中可以找到的异常的向量、名字、类型及其简单描述。更多的信息可以在Intel的技术文挡中找到。

  • 0-“Divide error”(故障)
    • 当一个程序试图执行整数被0除操作时产生。
  • 1-“Debug”(陷阱或故障)
    • 产生于:①设置eflags的TF标志时(对于实现调试程序的单步执行是相当有用的),②一条指令或操作数的地址落在一个活动debug寄存器的范围之内(参见第三章的“硬件上下文”一节)。
  • 2-未用
    • 为非屏蔽中断保留(利用NMI引脚的那些中断)。
  • 3-“Breakpoint”(陷阱)
    • 由int3(断点)指令(通常由debugger插入)引起。
  • 4-“Overflow”(陷阱)
    • 当ef1ags的OF(overflow)标志被设置时,into(检查溢出)指令被执行。
  • ...

20~3l这些值由Intel留作将来开发。如表4-1所示,每个异常都由专门的异常处理程序来处理(参见本章后面的“异常处理”一节),它们通常把一个Unix信号发送到引起异常的进程。

中断描述符表

中断描述符表(Interrupt Descriptor Table,IDT)是一个系统表,它与每一个中断或异常向量相联系,每一个向量在表中在相应的中断或呈常处理程序的入口地址。内核在允许中断发生前,必须适当地初始化IDT。

在第二章中,我们介绍了GDT和LDT,IDT的格式与这两种表的格式非常相似,表中的每一项对应一个中断或异常向量,每个向量由8个字节组成。因此,最多需要256×8=2048字节来在放IDT。

idtrCPU寄存器使IDT可以位于内存的任何地方,它指定IDT的线性基地址及其限制(最大长度)。在允许中断之前,必须用1idt汇编指令初始化idt工。

IDT包含三种类型的描述符,图4-2显示了每种描述符中的64位的含义。尤其值得注意的是,在40~43位的Type字段的值表示描述符的类型。

这些描述符是:

  • 任务门(task gate)
    • 当中断信号发生时,必须取代当前进程的那个进程的TSS选择符存放在任务门中。 
  • 中断门(interrupt gate)
    • 包含段选择符和中断或异常处理程序的段内偏移量。当控制权转移到一个适当的段时,处理器清IF标志,从而关闭将来会发生的可屏蔽中断。
  • 陷阱门(Trap gate)
    • 与中断门相似,只是控制权传递到一个适当的段时处理器不修改IF标志。

正如我们将在“中断门、陷阱门及系统门”一节中所看到的那样,Linux利用中断门处理中断,利用陷阱门处理异(注5)。

中断和异常的硬件处理

我们现在描述CPU控制单元如何处理中断和异常。我们假定内核已被初始化,因此,CPU在保护模式下运行。

当执行了一条指令后,cs和eip这对寄存器包含下一条将要执行的指令的逻辑地址。在处理那条指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常。如果发生了一个中断或异常,那么控制单元执行下列操作:

  1. 确定与中断或异常关联的向量i(0<i<255)。
  2. 读由idtr寄存器指向的IDT表中的第i项(在下面的描述中,我们假定IDT表项中包含的是一个中断门或一个陷阱门)。
  3. 从gdtr寄存器获得GDT的基地址,并在GDT中查找,以读取IDT表项中的选择符所标识的段描述符。这个描述符指定中断或异常处理程序所在段的基地址。
  4. 确信中断是由授权的(中断)发生源发出的。首先将当前特权级CPL(存放在cs寄存器的低两位)与段描述符(存放在GDT中)的描述符特权级DPL比较,如果CPL小于DPL,就产生一个“General protection”异常,因为中断处理程序的特权不能低于引起中断的程序的特权。对于编程异常,则做进一步的安全检查:比较CPL与处于IDT中的门描述符的DPL,如果DPL小于CPL,就产生一个“General protection”异常。这最后一个检查可以避免用户应用程序访问特殊的陷阱门或中断门。
  5. 检查是否发生了特权级的变化,也就是说,CPL是否不同于所选择的段描述符的DPL。如果是,控制单元必须开始使用与新的特权级相关的栈。通过执行以下步骤来做到这点:
    1. 读tr寄存器,以访问运行进程的TSS段。
    2. 用与新特权级相关的栈段和栈指针的正确值装载ss和esp寄存器。这些值可以在TSS中找到(参见第三章的“任务状态段”一节)
    3. 在新的栈中保存ss和esp以前的值,这些值定义了与旧特权级相关的栈的逻辑地址。
  6. 如果故障已发生,用引起异常的指令地址装载cs和eip寄存器,从而使得这条指令能再次被执行。
  7. 在栈中保存eflags、cs及eip的内容。
  8. 如果异常产生了一个硬件出错码,则将它保存在栈中。
  9. 装载cs和eip寄存器,其值分别是IDT表中第i项门描述符的段选择符和偏移量字段。这些值给出了中断或者异常处理程序的第一条指令的逻辑地址。

控制单元所执行的最后一步就是跳转到中断或者异常处理程序。换句话说,处理完中断信号后,控制单元所执行的指令就是被选中处理程序的第一条指令。

中断或异常被处理完后,相应的处理程序必须产生一条iret指令,把控制权转交给被中断的进程,这将迫使控制单元:

  1. 用保存在栈中的值装载cs、eip或eflags寄存器。如果一个硬件出错码曾被压入栈中,并且在eip内容的上面,那么,执行iret指令前必须先弹出这个硬件出错码。
  2. 检查处理程序的CPL是否等于cs中最低两位的值(这意味着被中断的进程与处理程序运行在同一特权级)。如果是,iret终止执行;否则,转入下一步。
  3. 从栈中装载ss和esp寄存器,因此,返回到与旧特权级相关的栈。
  4. 检查ds、es、fs及gs段寄存器的内容,如果其中一个寄存器包含的选择符是一个段描述符,并且其DPL值小于CPL,那么,清相应的段寄存器。控制单元这么做是为了禁止用户态的程序(CPL=3)利用内核以前所用的段寄存器(DPL=0)。如果不清这些寄存器,怀有恶意的用户态程序就可能利用它们来访问内核地址空间。

中断和异常处理程序的嵌套执行

每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。例如:当I/O设备发出一个中断时,相应的内核控制路径的第一部分指令就是那些把寄存器的内容保存在内核堆栈的指令,而最后一部分指令就是恢复寄存器内容并让CPU返回到用户态的那些指令。

内核控制路径可以任意嵌套;一个中断处理程序可以被另一个中断处理程序“中断”,因此引起内核控制路径的嵌套执行,如图4-3所示。其结果是,对中断进行处理的内核控制路径,其最后一部分指令并不总能使当前进程返回到用户态:如果嵌套深度大于1,这些指令将执行上次被打断的内核控制路径,此时的CPU依然运行在内核态。

 允许内核控制路径嵌套执行必须付出代价,那就是中断处理程序必须永不阻塞,换句话说,中断处理程序运行期间不能发生进程切换。事实上,嵌套的内核控制路径恢复执行时需要的所有数据都存放在内核态堆栈中,这个栈毫无疑义的属于当前进程。

假定内核没有bug,那么大多数异常就只在CPU处于用户态时发生。事实上,异常要么是由编程错误引起,要么是由调试程序触发。然而,“Page Fault(缺页)”异常发生在内核态。这发生在当进程试图对属于其地址空间的页进行寻址,而该页现在不在RAM中时。当处理这样的一个异常时,内核可以挂起当前进程,并用另一个进程代替它,直到请求的页可以使用为止。只要被挂起的进程又获得处理器,处理缺页异常的内核控制路径就恢复执行。

因为“Page Fault”异常处理程序从不进一步引起异常,所以与异常相关的至多两个内核控制路径(第一个由系统调用引起,第二个由缺页引起)会堆叠在一起,一个在另一个之上。

与异常形成对照的是,尽管处理中断的内核控制路径代表当前进程运行,但由I/O设备产生的中断并不引用当前进程的专有数据结构。事实上,当一个给定的中断发生时,要预测哪个进程将会运行是不可能的。

一个中断处理程库既可以论占其他的中断处理程庄,也可以枪占异常处理程序。相反,异常处理程序从不抢占中断处理程序。在内核态能触发的唯一异常就是刚刚描述的缺页异常。但是,中断处理程序从不执行可以导致缺页(因此意味着进程切换)的操作。

基于以下两个主要原因,Linux交错执行内核控制路径:

  • 为了提高可编程中断控制器和设备控制器的吞吐量。假定设备控制器在一条IRQ线上产生了一个信号,PIC把这个信号转换成一个外部中断,然后PIC和设备控制器保持阻塞,一直到PIC从CPU处接收到一条应答信息。由于内核控制路径的交错执行,内核即使正在处理前一个中断,也能发送应答。
  • 为了实现一种没有优先级的中断模型。因为每个中断处理程序都可以被另一个中断处理程序延缓,因此,在硬件设备之间没必要建立预定义优先级。这就简化了内核代码,提高了内核的可移植性。

在多处理器系统上,几个内核控制路径可以并发执行。此外,与异常相关的内核控制路径可以开始在一个CPU上执行,并且由于进程切换而移往另一个CPU上执行。

初始化中断描述符表

现在,我们知道了80×86微处理器在硬件级对中断和异常做了些什么,接下来,我们可以继续描述如何初始化中断描述符表。

内核启用中断以前,、必须把IDT表的初始地址装到idtr寄在器,并初始化表中的每一项。这项工作是在初始化系统时完成的(参见附录一)。

int指令允许用户态进程发出一个中断信号,其值可以是0~255的任意一个向量。因此,为了防止用户通过int指令模拟非法的中断和异常,IDT的初始化必须非常小心。这可以通过把中断或陷阱门描述符的DPL字段设置成0来实现。如果进程试图发出其中的一个中断信号,控制单元将检查出CPL的值与DPL字段有冲突,并且产生一个“General protection”异常。

然而,在少数情况下,用户态进程必须能发出一个编程异常。为此,只要把中断或陷阱门描述符的DPL字段设置成3,即特权级尽可能一样高就足够了。

现在,让我们来看一下Linux是如何实现这种策略的。

中断门、陷阱门及系统门

与在前面“中断描述符表”中所提到的一样,Intel提供了三种类型的中断描述符:任务门、中断门及陷阱门描述符。Linux使用与lntel稍有不同的细目分类和术语,把它们如下进行分类:

  • 中断门(interrupt gate)
    • 用户态的进程不能访问的一个lntel中断门(门的DPL字段为0)。所有的Linux中断处理程序都通过中断门激活,并全部限制在内核态。
  • 系统门(system gate)
    • 用户态的进程可以访问的一个Intel陷阱门(门的DPL字段为3)。通过系统门来激活三个Linux异常处理程序,它们的向量是4,5及128,因此,在用户态下,可以发布into、bound及int $0x80三条汇编语言指令。
  • 系统中断门(system interrupt gate)
    • 能够被用户态进程访问的Intel中断门(门的DPL字段为3)。与向量3相关的异常处理程序是由系统中断门激活的,因此,在用户态可以使用汇编语言指令int3。
  • 陷阱门(trap gate)
    • 用户态的进程不能访问的一个lntel陷阱门(门的DPL字段为0)。大部分Linux异常处理程序都通过陷阱门来激活。
  • 任务门((task gate)
    • 不能被用户态进程访问的Intel任务门(门的DPL字段为0)。Linux对“Double fault”异常的处理程序是由任务门激活的。

下列体系结构相关的函数用来在IDT中插入门:

  • set_intr_gate(n,addr)
    • 在IDT的第n个表项插入一个中断门。门中的段选择符设置成内核代码的段选择符,偏移量设置为中断处理程序的地址addr,DPL字段设置为0。
  • set_system gate(n,addr)
    • 在IDT的第n个表项插入一个陷阱门。门中的段选择符设置成内核代码的段选择符,偏移量设置为异常处理程序的地址addr,DPL字段设置为3。
  • set_system_intr_gate(n,addr)
    • 在IDT的第n个表项插入一个中断门。门中的段选择符设置成内核代码的段选择符,偏移量设置为异常处理程序的地址addr,DPL字段设置为3。
  • set_trap_gate(n,addr)
    • 与前一个函数类似,只不过DPL的字段设置成0。
  • set_task_gate(n,gdt)
    • 在IDT的第n个表项中插入一个中断门。门中的段选择符中存放一个TSS的全局描述符表的指针,该TSS中包含要被激活的函数。偏移量设置为0,而DPL字段设置为3。

IDT的初步初始化

当计算机还运行在实模式时,IDT被初始化并由BIOS例程使用。然而,一旦Linux接管,IDT就被移到RAM的另一个区域,并进行第二次初始化,因为Linux没有利用任何BIOS例程(参见附录一)。

IDT存放在idt_table表中,有256个表项。6字节的idt_descr变量指定了IDT的大小和它的地址,只有当内核用1idt汇编指令初始化idtr寄存器时才用到这个变量(注6)。

在内核初始化过程中,setup_idt()汇编语言函数用同一个中断门(即指向ignore_int()中断处理程序)来填充所有这256个idt_table表项:

用汇编语言写成的ignore_int()中断处理程序,可以看作一个空的处理程序,它执行下列动作:

  1. 在栈中保存一些寄存器的内容。
  2. 调用printk()函数打印“Unknown interrupt”系统消息。
  3. 从栈恢复寄存器的内容。
  4. 执行iret指令以恢复被中断的程序。

ignore_int()处理程序应该从不被执行,在控制台或日志文件中出现的“Unknowninterrupt”消息标志着要么是出现了一个硬件的问题(一个I/O设备正在产生没有预料到的中断),要么就是出现了一个内核的问题(一个中断或异常未被适当地处理)。

紧接着这个预初始化,内核将在IDT中进行第二遍初始化,用有意义的陷阱和中断处理程序替换这个空处理程序。一旦这个过程完成,对控制单元产生的每个不同的异常,IDT都有一个专门的陷阱或系统门,而对于可编程中断控制器确认的每一个IRQ,IDT都将包含一个专门的中断门。

在接下来的两节中,将分别针对异常和中断来详细地说明这个工作是如何完成的。

异常处理

CPU产生的大部分异常都由Linux解释为出错条件。当其中一个异常发生时,内核就向引起异常的进程发送一个信号向它通知一个反常条件。例如,如果进程执行了一个被0除的操作,CPU就产生一个“Divide error”异常,并由相应的异常处理程序向当前进程发送一个SIGFPE信号,这个进程将采取若干必要的步骤来(从出错中)恢复或者中止运行(如果没有为这个信号设置处理程序的话)。

但是,在两种情况下,Linux利用CPU异常更有效地管理硬件资源。第一种情况已经在第三章“保存和加载FPU、MMX及XMM寄存器”一节描述过,“Device not availeble”异常与cr0寄存器的TS标志一起用来把新值装入浮点寄存器。第二种情况指的是“Page Fault”异常,该异常推迟给进程分配新的页框,直到不能再推迟为止。相应的处理程序比较复杂,因为异常可能表示一个错误条件,也可能不表示一个错误条件(参见第九章“缺页异常处理程序”一节)。

异常处理程序有一个标准的结构,由以下三部分组成:

  1. 在内核堆栈中保存大多数寄存器的内容(这部分用汇编语言实现)。
  2. 用高级的C函数处理异常。
  3. 通过ret_from_exception()函数从异常处理程序退出。

为了利用异常,必须对IDT进行适当的初始化,使得每个被确认的异常都有一个异常处理程序。trap_init()函数的工作是将一些最终值(即处理异常的函数)插入到IDT的非屏蔽中断及异常表项中。这是由函数set_trap_gate()、set_intr_gate()、set_system_gate()、set_system_intr_gate()和set_task_gate()来完成的。

set_trap_gate(0,&divide_error);
set_trap_gate(1,&debug);
set_intr_gate(2,&nmi);
set_system_intr_gate(3,&int3);
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_task_gate(8,31);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_intr_gate(14,&page_fault);
set_trap_gate(16,&coprocessor_error);
set_trap_gate(17,&alignment_check);
set_trap_gate(18,&machine_check);
set_trap_gate(19,&simd_coprocessor_error);
set_system_gate(128,&system_call);

由于“Double fault”异常表示内核有严重的非法操作,其处理是通过任务门而不是陷阱门或系统门来完成的,因而,试图显示寄存器值的异常处理程序并不确定esp寄存器的值是否正确。产生这种异常的时候,CPU取出存放在IDT第8项中的任务门描述符,该描述符指向存放在GDT表第32项中的TSS段描述符。然后,CPU用TSS段中的相关值装载eip和esp寄存器,结果是:处理器在自己的私有栈上执行doublefault_fn()异常处理函数。

现在我们要考察一旦一个典型的异常处理程序被调用,它会做些什么。由于篇幅所限,我们对异常处理仅做粗略的描述,尤其是我们不涉及下面的内容:

  1. 由一些处理函数发送给用户态进程的信号码(见第十一章中的表11-8)。
  2. 内核运行在MS-DOS虚拟模式(VM86模式)时产生的异常,它们的处理是不同的。
  3. “Debug”异常。

为异常处理程序保存寄存器的值

让我们用handler_name来表示一个通用的异常处理程序的名字。(所有异常处理程序的实际名字都出现在前一部分的宏列表中。)每一个异常处理程序都以下列的汇编指令开始:

handler_name:
pushl $0 /* only for some exceptions */
pushl $do_handler_name
jmp error_code

当异常发生时,如果控制单元没有自动地把一个硬件出错代码插入到栈中,相应的汇编语言片段会包含一条pushl50指令,在栈中垫上一个空值。然后,把高级C函数的地址压进栈中,它的名字由异常处理程序名与do_前缀组成。

标号为error_code的汇编语言片段对所有的异常处理程序都是相同的,除了“Device not available”这一个异常(参见第三章的“保存和加载FPU、MMX及XMM寄存器”一节)。这段代码执行以下步骤:

  1. 把高级C函数可能用到的寄存器保存在栈中。
  2. 产生一条c1d指令来清eflags的方向标志DF,以确保调用字符串指令(注7)时会自动增加edi和esi寄存器的值。
  3. 把栈中位于esp+36处的硬件出错码拷贝到edx中,给栈中这一位置存上值-1,正如我们将在第十一章的“系统调用的重新执行”一节中所看到的那样,这个值用来把0×80异常与其他异常隔离开。
  4. 把保存在栈中esp+32位置的do_handler_name()高级C函数的地址装入edi寄存器中,然后,在栈的这个位置写入es的值。
  5. 把内核栈的当前栈顶拷贝到eax寄存器。这个地址表示内存单元的地址,在这个单元中存放的是第1步所保存的最后一个寄存器的值。
  6. 把用户数据段的选择符拷贝到ds和es寄存器中。
  7. 调用地址在edi中的高级C函数。

被调用的函数从eax和edx寄存器而不是从栈中接收参数。我们已经遇见过一个从CPU寄存器获取参数的函数_switchto(),在第三章“执行进程切换”一节我们讨论过这个函数。

进入和离开异常处理程序

如前所述,执行异常处理程序的C函数名总是由do_前缀和处理程序名组成。其中的大部分函数把硬件出错码和异常向量保存在当前进程的描述符中,然后,向当前进程发送一个适当的信号。用代码描述如下:

current->thread.error_code = error_code;
current->thread.trap_no = vector;
force_sig(sig_number, current);

异常处理程序刚一终止,当前进程就关注这个信号。该信号要么在用户态由进程自己的信号处理程序(如果存在的话)来处理,要么由内核来处理。在后面这种情况下,内核一般会杀死这个进程(参见第十一章)。异常处理程序发送的信号已在表4-1中列出。

异常处理程序总是检查异常是发生在用户态还是在内核态,在后一种情况下,还要检查是否由系统调用的无效参数引起。我们将在第十章“动态地址检查:修正代码”一节描述内核如何防御自己受无效的系统调用参数攻击。出现在内核态的任何其他异常都是由于内核的bug引起的。在这种情况下,异常处理程序认为是内核行为失常了。为了避免硬盘上的数据崩溃,处理程序调用die()函数,该函数在控制台上打印出所在CPU寄存器的内容(这种转储就叫做kernel oops),并调用 do_exit0来终止当前进程(参见第三章“进程终止”一节)。

当执行异常处理的C函数终止时,程序执行一条jmp指令以跳转到ret_from exception()函数。这个函数将在后面的“从中断和异常返回”一节中进行描述。

注:本文翻译自《UnderStanding The Linux Kernel 3rd Edition》

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值