在 x86 架构中,trap(陷阱)是一种中断,通常由于程序执行错误或者需要操作系统服务而触发。当 CPU 执行到某些指令或者发生某些事件时,会触发 trap,CPU 会停止当前的任务,转而去执行一个预先设定的函数,这个函数通常被称为中断处理程序(Interrupt Handler)或者陷阱处理程序(Trap Handler)。
Trap
Trap 有两种类型:硬件陷阱和软件陷阱。硬件陷阱是由 CPU 硬件触发的,例如除以零、访问非法内存地址等。软件陷阱则是由程序主动触发的,例如系统调用。
在 x86 架构中,每种陷阱都有一个对应的陷阱号(Trap Number),这个陷阱号用于在中断描述符表(Interrupt Descriptor Table,简称 IDT)中查找对应的陷阱处理程序。IDT 是一个存储在内存中的表,每个表项包含一个陷阱处理程序的地址和一些属性。
当陷阱发生时,CPU 会自动保存当前的程序状态(包括各个寄存器的值和程序计数器的值等),然后跳转到对应的陷阱处理程序去执行。陷阱处理程序执行完毕后,CPU 会恢复之前保存的程序状态,然后继续执行被中断的任务。
在操作系统中,trap 机制是非常重要的,它是操作系统实现并发、保护和抽象等功能的基础。例如,操作系统可以通过 trap 机制来实现系统调用、任务切换、内存保护等功能。
示例代码(汇编语言):
; 调用中断向量号为0x80的中断
mov eax, 1 ; 系统调用号,例如1表示退出程序
int 0x80 ; 触发trap
; 中断服务例程
section .text
global _start
_start:
; 系统调用退出程序
mov eax, 1 ; 系统调用号1表示退出程序
xor ebx, ebx ; 返回码为0
int 0x80 ; 触发trap
在上述例子中,int 0x80
触发了一个系统调用,这是通过 trap 来实现的。在真实的操作系统中,中断向量号 0x80 通常用于系统调用。
异常和中断的区别
在计算机系统中,异常和中断是两种重要的机制,它们都可以使处理器从当前的执行流程中跳转出来,去处理某些特定的事件。然而,它们之间存在一些关键的区别:
-
触发方式:中断通常是由外部事件触发的,例如硬件设备发送的信号或者定时器的到期。这些事件与处理器执行的指令流无关,可以在任何时间发生。相反,异常是由处理器执行的指令流中的事件触发的,例如除以零、访问无效内存地址或者执行无效指令。
-
同步性:由于中断是由外部事件触发的,因此它是异步的,可以在任何时间发生。然而,异常是由处理器内部的事件触发的,因此它是同步的,只能在特定的指令执行时发生。
-
处理方式:当中断发生时,处理器会立即停止当前的指令流,保存当前的上下文,然后跳转到一个预定义的中断处理程序去处理这个中断。当中断处理程序完成后,处理器会恢复之前保存的上下文,然后继续执行被中断的指令流。然而,当异常发生时,处理器可能会尝试修复引起异常的问题,例如通过重新执行引起异常的指令或者通过执行一个错误处理程序。如果异常无法被修复,处理器可能会终止引起异常的程序。
-
优先级:中断和异常通常有不同的优先级。在多数系统中,中断的优先级高于异常。这意味着,如果在处理一个异常的过程中发生了一个中断,处理器会暂停异常的处理,去处理这个中断。然而,这种行为可能会根据具体的系统和配置而变化。
总的来说,虽然异常和中断在处理器级别有很多相似之处,但它们在触发方式、同步性、处理方式和优先级等方面有很大的不同。理解这些差异对于理解计算机系统的运行方式非常重要。
受保护的控制转移
"受保护的控制转移"是指处理器在执行过程中,由于某些特定的事件(如异常或中断),会从当前的执行流程中跳转出来,去处理这些事件。这种控制流的转移是受到严格保护的,以防止用户模式的代码干扰内核或其他环境的运行。
在 x86 架构中,这种保护主要通过两种机制来实现:中断描述符表(Interrupt Descriptor Table,IDT)和任务状态段(Task State Segment,TSS)。
通过这两种机制,处理器可以确保在发生中断或异常时,能够安全、可控地从用户模式切换到内核模式,从而实现"受保护的控制转移"。
中断描述符表
在 x86 架构中,中断描述符表(Interrupt Descriptor Table,简称 IDT)是一个特殊的数据结构,用于存储中断处理程序的地址和一些相关属性。当 CPU 接收到一个中断或者陷阱(trap)信号时,会根据信号的类型(也就是中断向量)在 IDT 中查找对应的中断处理程序,然后跳转到该程序去处理中断。
x86 允许最多 256 个不同的中断或异常入口点进入内核,每个入口点都有一个不同的中断向量。向量是一个介于 0 和 255 之间的数字。中断的向量由中断的来源确定:不同的设备、错误条件和应用程序对内核的请求会生成具有不同向量的中断。CPU 使用向量作为处理器的中断描述符表(IDT)的索引,内核在内核私有内存中设置这个表,就像 GDT 一样。从这个表的适当条目中,处理器加载:
- 要加载到指令指针(EIP)寄存器的值,指向内核代码,该代码被指定为处理该类型的异常。
- 要加载到代码段(CS)寄存器的值,其中位 0-1 包含异常处理程序要运行的特权级别。在 JOS 中,所有异常都在内核模式(特权级别 0)下处理。
这种机制确保了中断和异常只能使内核在一些特定的、由内核自身确定的入口点被进入,而不是在中断或异常被接收时运行的代码。
任务状态段
任务状态段(Task State Segment,简称 TSS)是 x86 架构中的一个特殊数据结构,用于保存处理器的状态。当发生中断或异常,导致从用户模式切换到内核模式时,处理器需要一个地方来保存旧的处理器状态,例如在调用异常处理程序之前的 EIP 和 CS 的原始值,这样异常处理程序就可以恢复旧的状态,并从中断的地方继续执行代码。但是,这个用于保存旧处理器状态的区域必须受到保护,防止非特权的用户模式代码访问,否则可能会被有缺陷或恶意的用户代码破坏内核。
因此,当 x86 处理器接收到一个导致从用户模式切换到内核模式的中断或陷阱时,它也会切换到内核内存中的一个栈。任务状态段(TSS)指定了这个栈在哪里以及它的段选择子和地址。处理器在这个新栈上压入 SS、ESP、EFLAGS、CS、EIP 和一个可选的错误代码。然后它从中断描述符加载 CS 和 EIP,并设置 ESP 和 SS 指向新的栈。
虽然 TSS 很大并且可以用于各种目的,但是 JOS 只使用它来定义当从用户模式切换到内核模式时处理器应该切换到的内核栈。由于在 JOS 中,“内核模式”是 x86 上的特权级别 0,所以处理器在进入内核模式时使用 TSS 的 ESP0 和 SS0 字段来定义内核栈。JOS 不使用 TSS 的任何其他字段。
一个具体的例子
处理器在处理异常或中断时,需要保存当前的处理器状态,以便在处理完异常或中断后能够恢复并继续执行被中断的代码。这就需要一个安全的地方来保存这些状态,这个地方就是内核栈。
当处理器从用户模式切换到内核模式以处理异常或中断时,它会自动切换到内核栈,并在这个栈上保存当前的处理器状态。这样做的目的是为了保护内核的稳定性和安全性,因为内核栈位于内核空间,用户程序无法直接访问,这就避免了用户程序可能对内核栈的恶意修改。同时,通过保存和恢复处理器状态,确保了即使在发生异常或中断的情况下,处理器也能正确地执行程序。
假设处理器正在执行用户环境中的代码,并遇到一个试图除以零的除法指令。处理器切换到由 TSS 的 SS0 和 ESP0 字段定义的栈,它位于内核空间的顶部。这个栈的具体位置由 ESP0 字段指定,其值为 KSTACKTOP,这是内核栈顶的地址。SS0 字段保存的是内核数据段的段选择子 GD_K。处理器将异常参数推入内核栈,从地址 KSTACKTOP 开始:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20 <---- ESP
+--------------------+
这些参数是在处理器处理异常或中断时保存的当前处理器状态。它们的作用如下:
-
old SS
:旧的堆栈段选择器,用于在从用户模式切换到内核模式时更改堆栈段选择器,以使用内核栈。 -
old ESP
:旧的堆栈指针,用于在从用户模式切换到内核模式时更改堆栈指针,以使用内核栈。 -
old EFLAGS
:旧的状态寄存器,包含了许多重要的状态位,如中断使能位、方向位、溢出位等,用于在处理完异常或中断后恢复原来的状态。 -
old CS
:旧的代码段选择器,用于在从用户模式切换到内核模式时更改代码段选择器,以执行内核代码。 -
old EIP
:旧的指令指针,用于在处理异常或中断时更改指令指针,以执行异常或中断处理程序。
这些参数的保存和恢复是处理器处理异常或中断的重要步骤,它们确保了处理器能够在处理完异常或中断后正确地恢复到原来的状态,继续执行被中断的代码。
在处理除法错误(x86 上的中断向量 0)时,处理器会读取中断描述符表(IDT)的条目 0,并将代码段选择器(CS)和指令指针(EIP)设置为指向该条目描述的处理函数。处理函数接管控制并处理异常,例如可能会终止引起异常的用户环境。用户环境通常指的是运行在用户模式下的程序或进程。
错误码
对于某些类型的 x86 异常,除了保存的内容之外,处理器还会在栈上推入另一个包含错误代码的字。页面错误异常,编号 14,是一个重要的例子。当处理器推送错误代码时,从用户模式进入异常处理程序开始时,栈的布局如下:
+--------------------+ KSTACKTOP
| 0x00000 | old SS | " - 4
| old ESP | " - 8
| old EFLAGS | " - 12
| 0x00000 | old CS | " - 16
| old EIP | " - 20
| error code | " - 24 <---- ESP
+--------------------+
这里的error code
就是处理器推送的错误代码。在 x86 架构中,处理器会为某些类型的异常推送错误代码到栈上。这个错误代码可以帮助异常处理程序更准确地知道发生了什么异常,从而进行更准确的处理。JOS 中已经定义了一些错误代码:
// 页错误错误代码
#define FEC_PR 0x1 // 页错误由保护违规引起
#define FEC_WR 0x2 // 页错误由写操作引起
#define FEC_U 0x4 // 页错误在用户模式下发生
这些错误代码是用于页面错误异常(编号 14)的。当发生页面错误异常时,处理器会将这些错误代码推送到栈上。
嵌套异常和中断
当处理器从用户模式切换到内核模式时,x86 处理器会自动切换堆栈,然后将其旧的寄存器状态推送到堆栈上,并通过中断描述符表(IDT)调用相应的异常处理程序。然而,如果处理器已经处于内核模式,当中断或异常发生时(CS 寄存器的低 2 位已经为零,也就是说处理器当前正在运行的代码是在内核模式下执行的),CPU 只是在同一个内核堆栈上推送更多的值。
因此,当处理器已经处于内核模式时,如果发生中断或异常,处理器不需要从用户模式切换到内核模式,也就不需要切换堆栈。相反,它会在当前的内核堆栈上推送更多的值,以保存当前的执行环境。这样,内核就可以处理由内核自身代码引起的嵌套异常。这样,内核可以优雅地处理由内核自身代码引起的嵌套异常。这种能力是实现保护的重要工具,我们将在后面的系统调用部分看到。
如果处理器已经处于内核模式并且发生了嵌套异常,由于它不需要切换堆栈,因此它不会保存旧的 SS 或 ESP 寄存器。对于不推送错误代码的异常类型,内核堆栈在进入异常处理程序时看起来如下:
+--------------------+ <---- old ESP
| old EFLAGS | " - 4
| 0x00000 | old CS | " - 8
| old EIP | " - 12
+--------------------+
对于推送错误代码的异常类型,处理器在旧的 EIP 后立即推送错误代码,如前所述。
总结
本文介绍了在 x86 架构中中断描述符表(IDT)的作用和结构。Trap 是一种中断,有硬件陷阱和软件陷阱,每种陷阱对应一个陷阱号,在 IDT 中查找对应的陷阱处理程序。IDT 包含了处理程序的地址和属性,触发陷阱时 CPU 保存状态并跳转执行处理程序。文章还涵盖了异常和中断的区别,以及受保护的控制转移通过 IDT 和 TSS 实现。最后,通过具体示例和栈布局展示了异常处理的过程。