XV6

操作系统的系统调用、硬件设备的事件处理都不开中断机制。XV6 在进入内核态后将切换到内核堆栈,从而与用户进程的执行流脱离关系。XV6 自带的英文文献使用 trap 来指代软中断 (INT 指令)、硬件中断和异常,这源于 Unix 系统中的术语。我们则使用中断一词来指代其他三者更符合中文资料的习惯,这有别于 XV6 的英文资料中使用 trap 来统一指代其他三者。而 X86 硬件相关的文献可能会将三者做明显区分。因此后续内容会混合使用陷阱、中断和异常三个术语,必要时请读者自行区分。

中断涉及到进入服务程序、执行服务代码以及返回三个环节。与函数的调用和返回有相似之处,但还涉及到保护级的改变、堆栈的切换等特有的问题。

1. 中断机制

在实地址模式中,CPU 把内存中从 0 开始的 1 K 字节作为一个中断向量表。表中的每个表项占 4 个字节,由两个字节(16 bit)的段基址和两个字节(16 bit)的偏移量组成,这样构成的地址便是相应中断处理程序的(20 bit)入口地址。在保护模式下,中断向量表中的表项由 8 个字节组成,中断向量表也改称为中断描述符表 IDT (Interrupt Descriptor Table)。其中的每个表项叫做一个门描述符(Gate Descriptor),“门” 的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。

有了 IDT 的概念后,我们可以给出 X86 保护模式下的地址相关的比较完整的系统框图,包含了 GDT、LDT、IDT,以及被它们所描述的代码段、数据段、堆栈段、TSS 段等。

2. 中断描述表 IDT

处理器硬件根据中断号在中断向量表中查找相应的处理程序的入口,从而做出相应的响应。X86 架构上的中断向量表称为中断描述符表(IDT,Interrupt Descriptor Table),中断描述符表是一个系统表,由 X86 处理器的 IDT 寄存器(IDTR,Interrupt Descriptor Table Register)给出具体信息。与 GDTR 的作用类似(结构也相同),IDTR 寄存器用于存放中断描述符表 IDT 的 32 位线性基地址和 16 位表长度值。指令 LIDT 和 SIDT 分别用于加载和保存 IDTR 寄存器的内容。在机器刚加电或处理器复位后,基地址被默认地设置为 0,而长度值被设置成 0xFFFF。

从 X86 硬件体系结构上说,中断描述符表 IDT 中的描述符可以有三种类型:任务门描述符、中断门描述符和陷阱门描述符。Linux 利用中断门描述符记录中断入口,利用陷阱门描述符记录异常处理代码入口,用任务门描述符记录 Double Fault 处理代码入口。

IDT 表的每一项都和一个中断(中断及异常)向量一一对应,发生中断或异常时 CPU 硬件将中断号作为访问该表的索引,自动找到相应的 “中断处理程序入口地址” 及相关的 “访问特权级” 有关的信息等,即中断描述符。每个描述符需要 8 个字 节,所以该表最多 256*8=2048 字节那么长。

i8086 中断系统中的前 5 个中断,系统已经给出了固定的定义和处理功能,故又称它们为专用中断,其中除了非屏蔽中断而外,其余的 4 个都属于内部中断。

0~5 6~31 32~255
专用中断 系统保留 用户自定义

除数为零中断:类型码 0;当执行除法时,如果除数为零,CPU 自动产生一个类型 0 中断。

单步中断 类型码 1;受 TF 为控制,当 TF 为 1 时,每执行一条指令,就产生一个单步中断,主要使用于程序调试过程中。

断点中断:类型码 3;主要用于程序调试中,即在指定位置设置断点,当 CPU 执行到此断点位置时将转入到断点服务子程序。

溢出中断(INTO):类型码 4;当 CPU 执行加 / 减操作时,如果有溢出产生,并且其后执行该指令时则会作出相应的处理操作。

2.1 中断门、陷阱门、任务门

在 IDT 表中存放的不是段描述符(存储段描述符和系统段描述符),而是四种门描述符。门描述符并不描述某种内存段,而是描述控制转移的入口点。这种描述符好比一个通向另一代码段的门。通过这种门,可实现任务内特权级的变换和任务间的切换。所以这种门描述符也称为控制门。

描述符的 s=0 时表示的不是代码或数据,而是系统相关的 LDT 或 4 种门描述符。其中 TSS 的 TYPE=1001 或 1011、LDT 的 TYPE=0010。 可以出现在 IDT 表中的有中断门(s=0,TYPE=D110)、陷阱门(s=0,TYPE=D111)和任务门(s=0,TYPE=0101),其中的中断门和陷阱门当 D=0 是 16 位而 D=1 是 32 位。

调用门不能出现在 IDT 表中,只能出现在 GDT 或 LDT 中。

IDT中的门(中断门、陷阱门、任务门)

X86 的四种门中,有三种可以出现在 IDT 表,和中断机制相联系,它们是中断门、陷阱门和任务门。

  1. 任务门

    任务门(Task Gate)用于指示某个任务,利用段间转移指令 JMP 和段间调用指令 CALL, 通过任务门可实现任务切换。任务门内的选择子必须指向 GDT 中的任务状态段 TSS 描述符,门中的偏移(低 16 bit)无意义。任务的入口点保存在 TSS 中,XV6 和 Linux 都不使用这种方式来进入内核代码。 Linux 利用 TSS 来进行不同运行级别的堆栈切换。

    任务门结构如下(63~32,31~0)

    31~16 15 14~13 12~8 7~0
    Reserved P DPL 00101 Reserved
    31~16 15~0
    TSS Segment Selector Reserved
  2. 中断门和陷阱门

    中断门(Interrupt Gate)和陷阱门(Trap Gate)描述中断和异常处理程序的人口点。中断门和陷阱门内的选择子必须指向代码段描述符,门内的偏移就是对应代码段的入口点的偏移。 中断门和陷阱门只有在中断描述符表 IDT 中才有效。Linux 的系统调用机制使用 INT 指令通过中断门来完成系统调用并进入内核态。

    中断门与陷阱门相似,不同之处在于,通过中断门后,处理器将清除 IF 标志从而屏蔽可屏蔽中断,但是不清除 IF。

    中断门结构如下(63~32,31~0),D=0 是 16 位,D=1 是 32 位

    31~16 15 14~13 12~8 7~5 4~0
    Offset 31:16 P DPL 0D110 000 Reserved
    31~16 15~0
    Segment Selector Offset 15:0

    任务门结构如下(63~32,31~0),D=0 是 16 位而 D=1 是 32 位

    31~16 15 14~13 12~8 7~5 4~0
    Offset 31:16 P DPL 0D111 000 Reserved
    31~16 15~0
    Segment Selector Offset 15:0

GDT/LDT 中的门(调用门)

调用门用于在不同特权级之间实现受控的程序控制转移,通常仅用于使用特权级保护机制的操作系统中。本质上,它只是一个描述符,一个不同于代码段和数据段的描述符,可以出现在 GDT 或者 LDT 中,但是不能出现在 IDT(中断描述符表)中。XV6 和 Linux 都没有用到调用门,读者如不感兴趣可以跳过本小节内容。

调用门描述某个子程序的入口。调用门内的选择子必须实现代码段描述符,调用门内的偏移是对应代码段内的偏移。利用段间调用指令 CALL,通过调用门可实现任务内从外层特权级变换到内层特权级。 XV6 和 Linux 并不使用 CALL 指令和调用门来执行内核代码(完成用户态到内核态的转换)。

门描述符内偏移 4 字节的位 0 至位 4 是双字计数字段,该字段只在调用门描述符中有效,在其它门描述符中无效。主程序通过堆栈把入口参数传递给子程序,如果在利用调用门调用子程序时引起特权级的转换和堆栈的改变,那么就需要将外层堆栈中的参数复制到内层堆栈。该双字计数字段就是用于说明这种情况发生时,要复制的双字参数的数量。

同一任务内部的代码分成不同的运行级别,这在常见的操作系统实现中并没有对应的模式和机制。也就是说现在商用的各种常见处理器和操作系统上,一个程序内部的优先级是固定且唯一的。因此 Linux 和 XV6 都没有使用调用门。

调用门的结构如下(63~32,31~0)

31~16 15 14~13 12 11~8 7~5 4~0
Offset 31:16 P DPL 0 1110 000 Param Count
31~16 15~0
Segment Selector Offset 15:0

四种门的区别和联系

  1. 任务门和其他三种门相比,在任务门中不需要用段内位移,因为任务门不指向某一个子程序的入口,TSS 本身是作为一个段来对待的(TSS 段内已经有任务的现场,含 EIP 等内容)。 而中断门、陷阱门和调用门则都要指向一个子程序,所以必须结合使用段选择码和段内位移。此外,任务门中相对于 D 标志位的位置永远是 0。
  2. 中断门和陷阱门在使用上的区别不在于中断是外部产生的还是有 CPU 本身产生的,而在于通过中断门进入中断服务程序时 CPU 会自动将中断关闭(将 EFLAGS 寄存器中 IF 标志位置 0), 以防止嵌套中断产生,而通过陷阱门进入服务程序时则维持 IF 标志位不变。这是二者唯一的区别。
  3. 调用门和其他三者区别在于不能出现在 IDT 表中。因为它和中断没有直接关系:通过调用门跳转到一个过程应该是在同一任务内的,没有发生任务切换,属于同一进程上下文。但 CPL 可能会改变,权限可能会更高。
  4. 任务门虽然可以位于 IDT 中,但是它区别于中断门和陷阱门在于:其目的是用新任务 (X86 TSS 定义的硬件任务)方式,而不是过程去处理中断或异常。

XV6 对门的处理比较简单,直接根据需要填写。而 Linux 代码则对这三类硬件类型的门进 一步细分(根据所填写的内容再作区分),分成了以下五种软件概念上的门:中断门(Interrupt Gate)、系统门(System Gate)、系统中断门(System Interrupt Gate)、陷阱门(Trap Gate)、 任务门(Task Gate)。

2.2 优先级变化

所有通过这些门进行的跳转都需经过严格的权限检查,否则用户态通过 INT 指令可以触发并执行中断向量号为 0~255 的任意一个中断。这正是 “门” 的本义,所以中断描述符不能简单看成跳转地址,还必须认识到它起到权限检查的关卡作用。

首先是发起调用的时候 CPU 特权级 CPL(其 CS 中最低 2 bit)不得低于(数值小于或等于) 门的特权级 DPL。例如外设中断的中断门描述符 DPL 为 0,从用户态 CPL=3 是无法直接调用中断处理代码的,因为此时 CPL 大于门描述符 DPL,即特权级不够高(特权级越高数值越小)。只有硬件中断发生后,CPU 特权级提升(提升到 0 级)后才能跳转去执行中断处理代码。而系统调用则可以在用户态发生,因为系统调用所使用的陷阱门的 DPL=3,因此 CPL 为任何特权级的代码都可以发出系统调用。

其次,即使通过了上面的检查,还需要检查跳转目标的代码段描述符的 DPL,如果目标代码段描述符 DPL 高于(数值小于或等于)被中断的代码 CPL,才可以执行。也就是中断代码可以打断用户代码,否则意味着不合理的,因为低优先级(CPL 数值大)的代码不可以打断高优先级 (CPL 数值小)的代码。

简单说,门的 DPL 用于控制是否允许执行这个门所对应的代码,而目标代码的 DPL 用于控制它所描述的代码能否打断现有任务。CPL 是当前进程的权限级别 (Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于 CS 寄存器的低两位。

如果有优先级提升,则需要设置 TSS(task segment descriptor)指定内核堆栈 %esp + 堆栈段选择子。

如果有优先级提升(从用户态进入到内核态),硬件自动地从 TSS(task segment descriptor)中获取相应运行级别的 ss / esp 值,并将当前进程的 ss / esp 保存到新堆栈里,从而完成堆栈切换。 否则,如果优先级不发生变化则不需要切换堆栈。如果优先级降低,也需要切换堆栈。

CPL、DPL 和 RPL

CPL 是当前进程的权限级别(Current Privilege Level),是当前正在执行的代码所在的段的特权级,存在于 cs 寄存器的低两位。

RPL 说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的, 每个段选择子有自己的 RPL,它说明的是进程对段访问的请求权限,有点像函数参数。而且 RPL 对每个段来说不是固定的,两次访问同一段时的 RPL 可以不同(用不同的选择子,例如 DS)。RPL 可能会削弱 CPL 的作用,例如当前 CPL=0 的进程要访问一个数据段,它把段选择符 (DS)中的 RPL 设为 3,这样虽然它对该段仍然只有特权为 3 的访问权限。

DPL 存储在段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的 DPL 固定。

一个段描述符的 DPL 是固定的,由段描述符的低 2 bit 指出。但是我们可以用不同的 RPL 来访问这个段,使用不同的段选择子。例如,内核代码是 0 级,可以同过 1 级或 3 级的 ds / es 去访问一个 3 级的数据。但是内核代码 0 级,不能通过 1 级或 3 级 的 ds 去访问内核数据区。

  1. CPL

    CPL 是当前执行的程序或任务的特权级,有当前执行代码的 CS 低 2 bit 指出。它被存储在 CS 和 SS 的第 0 位和第 1 位上。通常情况下,CPL 代表代码所在的段的特权级。当程序转移到不同特权级的代码段时,处理器将改变 CPL。XV6 和 Linux 一样都只有 0 和 3 两个值,分别表示用户态和内核态。

  2. DPL

    DPL 是段描述符或门描述符中给出的特权级。它被存储在段描述符或者门描述符的 DPL 字段中,如果当前代码段试图 CALL 访问一个段(调用门)或者 INT(中断门、陷阱门、任 务门),DPL 将会和 CPL 以及段或者门选择子的 RPL 相比较,根据段或者门类型的不同,DPL 将会区别对待。

  3. RPL

    RPL 是 “将要被访问” 的代码或数据的段选择子的第 0 和第 1 位表现出来的。RPL 是代码中根据不同段跳转而确定,以动态刷新 CS 里的 CPL,在代码段选择符中。而且 RPL 对每个段来说不是固定的,两次访问同一段时的 RPL 可以不同。操作系统往往用 RPL 来避免低特权级应用程序访问高特权级段内的数据,即便提出访问请求的段有足够的特权级,如果 RPL 不够也是不行的,当 RPL 的值比 CPL 大的时候,RPL 将起决定性作用。也就是说,RPL 相当于附加的一 个权限控制,只有当 RPL>DPL 的时候,才起到实际的限制作用。

Intel 设置 DPL、RPL、CPL 以实现分级和权限检查。

DPL:描述符特权(Descriptor Privilege Level)

存储在描述符中的权限位,用于描述代码的所属的特权等级,也就是代码本身真正的特权级。一个程序可以使用多个段(Data,Code,Stack),也可以只用一个 code 段等。正常的情况下, 当程序的环境建立好后,段描述符都不需要改变,当然 DPL 也不需要改变,因此每个段的 DPL 值是固定的。

RPL:请求特权级 RPL(Request Privilege Level)

RPL 保存在选择子的最低两位。 RPL 说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。RPL 的值由程序员自己来自由的设置,并不一定 RPL>=CPL,但是当 RPL<CPL 时,实际起作用的就是 CPL 了,因为访问时的特权检查是判断:EPL=max(RPL,CPL)<=DPL 是否 成立,所以 RPL 可以看成是每次访问时的附加限制,RPL=0 时附加限制最小,RPL=3 时附加限制最大。所以你不要想通过来随便设置一个 RPL 来访问一个比 CPL 更内层的段。

因为你不可能得到比自己更高的权限,你申请的权限一定要比你实际权限低才能通过 CPU 的审查,才能对你放行。所以实际上 RPL 的作用是程序员可以把自己的程序降级运行,有些时候为了更好的安全性,程序可以在适当的时机把自身降低权限(RPL 设成更大的值)。

网上许多人都说在问 RPL 的作用,我也很晕。Intel 的手册中对 RPL 的作用只是这样做的简短解释的:

The RPL can be used to insure that privileged code does not access a segment on behalf of an application program unless the program itself has access privileges for that segment.

后来找到了一些资料对这段话进行了扩充和举例,我才明白一些:

对于特权级高的进程 RPL 是作用是防止自己不小心访问到一些资料段。比方说,如果进程 A 的 CPL=0,它知道它的委托进程 B 的 DPL=3,也知道数据段 C 的 DPL=2,而这数据段是不能让 CPL>2 的进程访问的。

那么如果你是进程 A 的程序员根本不需要 RPL 的帮助,也不会试图让进程 A 访问数据段 C 的数据,因为这样做只会浪费时间。当然如果你一定要访问数据段 C 的数据然后把数据传给委托进程 B,这就是你的选择,你真的可以这样做,但后果自负。只是有时候要访问的数据段我们不知道它的 DPL 是什么,也不知道能不能让进程 B 访问,其中的一个解决方法就是把委托进程 B 的 DPL 以 RPL 的方法告诉数据段 C 让它决定接受或不接受。(我想应该是通过程序把 B 的 DPL 装入到 A 的选择子中,然后再由 A 去访问数据段 C)

CPL:当前任务特权(Current Privilege Level)

表示当前正在执行的代码所处的特权级。CPL 保存在 CS 中的最低两位,是针对 CS 而言的。 当选择子成功装入 CS 寄存器后,相应的选择子中的 RPL 就变成了 CPL。因为它的位置变了, 已经被装入到 CS 寄存器中了,所表达的意思也发生了变,原来的要求等级已经得到了满足, 就是当前自己的等级。

选择子可以有许多个,因此 RPL 也就有许多个。而 CPL 就不同了,正在执行的代码在某一时刻就只有这个值唯一的代表程序的 CPL。

另外特别要求 CS 与 SS 的特权级必须保持一致。对于装入 DS、ES、FS、GS 的选择子 INTEL 没有给它们起什么特殊的名称,我也不知道应该叫它们什么,也许可以仍然称它为 RPL。