操作系统的系统调用、硬件设备的事件处理都不开中断机制。XV6 在进入内核态后将切换到内核堆栈,从而与用户进程的执行流脱离关系。XV6 自带的英文文献使用 trap 来指代软中断 (INT 指令)、硬件中断和异常,这源于 Unix 系统中的术语。我们则使用中断一词来指代其他三者更符合中文资料的习惯,这有别于 XV6 的英文资料中使用 trap 来统一指代其他三者。而 X86 硬件相关的文献可能会将三者做明显区分。因此后续内容会混合使用陷阱、中断和异常三个术语,必要时请读者自行区分。
中断涉及到进入服务程序、执行服务代码以及返回三个环节。与函数的调用和返回有相似之处,但还涉及到保护级的改变、堆栈的切换等特有的问题。
在实地址模式中,CPU 把内存中从 0 开始的 1 K 字节作为一个中断向量表。表中的每个表项占 4 个字节,由两个字节(16 bit)的段基址和两个字节(16 bit)的偏移量组成,这样构成的地址便是相应中断处理程序的(20 bit)入口地址。在保护模式下,中断向量表中的表项由 8 个字节组成,中断向量表也改称为中断描述符表 IDT (Interrupt Descriptor Table)。其中的每个表项叫做一个门描述符(Gate Descriptor),“门” 的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。
有了 IDT 的概念后,我们可以给出 X86 保护模式下的地址相关的比较完整的系统框图,包含了 GDT、LDT、IDT,以及被它们所描述的代码段、数据段、堆栈段、TSS 段等。
处理器硬件根据中断号在中断向量表中查找相应的处理程序的入口,从而做出相应的响应。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 执行加 / 减操作时,如果有溢出产生,并且其后执行该指令时则会作出相应的处理操作。
在 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 表,和中断机制相联系,它们是中断门、陷阱门和任务门。
任务门
任务门(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 |
中断门和陷阱门
中断门(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 |
四种门的区别和联系
XV6 对门的处理比较简单,直接根据需要填写。而 Linux 代码则对这三类硬件类型的门进 一步细分(根据所填写的内容再作区分),分成了以下五种软件概念上的门:中断门(Interrupt Gate)、系统门(System Gate)、系统中断门(System Interrupt Gate)、陷阱门(Trap Gate)、 任务门(Task Gate)。
所有通过这些门进行的跳转都需经过严格的权限检查,否则用户态通过 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
去访问内核数据区。
CPL
CPL 是当前执行的程序或任务的特权级,有当前执行代码的 CS 低 2 bit 指出。它被存储在 CS 和 SS 的第 0 位和第 1 位上。通常情况下,CPL 代表代码所在的段的特权级。当程序转移到不同特权级的代码段时,处理器将改变 CPL。XV6 和 Linux 一样都只有 0 和 3 两个值,分别表示用户态和内核态。
DPL
DPL 是段描述符或门描述符中给出的特权级。它被存储在段描述符或者门描述符的 DPL 字段中,如果当前代码段试图 CALL 访问一个段(调用门)或者 INT(中断门、陷阱门、任 务门),DPL 将会和 CPL 以及段或者门选择子的 RPL 相比较,根据段或者门类型的不同,DPL 将会区别对待。
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。