深入理解 x86/x64 的中断体系
1. 实模式下的中断机制
x86 processor 在加电后被初始化为 real mode 也称为 real-address mode,关于实模式请详见文章:http://www.mouseos.com/arch/001.html
processor 执行的第一条指针在 0xFFFFFFF0 处,这个地址经过 North Bridge(北桥)和 South ridge(南桥)芯片配合解码,最终会访问到固化的 ROM 块,同时,经过别名机制映射在地址空间低端,实际上等于 ROM 被映射到地址空间最高端和低端位置。
此时在系统的内存里其实并不存在 BIOS 代码,ROM BIOS 的一部分职责是负责安装 BIOS 代码进入系统内存。
jmp far f000:e05b |
典型是这条指令就是 0xFFFFFFF0 处的 ROM BIOS 指令,执行后它将跳到 0x000FE05B 处,这条指令的作用很大:
- 更新 CS.base 使 processor 变成纯正的 real mode
- 跳转到低端内存,使之进入 1M 低端区域
前面说过,此时内存中也不存在 BIOS,也就是说 IVT(中断向量表)也是不存在的,中断系统此时是不可用的,那么由 ROM BIOS 设置 IVT 。
1.1 中断向量表(IVT)
IDTR.base 被初始化为 0,ROM BIOS 将不会对 IDTR.base 进行更改,因此如果实模式 OS 不更改 IDTR.base 的值,这意味着 IVT 在 0 的位置上,典型的如: DOS 操作系统。
在保护模式下 IDTR.base 将向不再是中断向量表,而是中断描述符表。不再称为 IVT 而是 IDT。那是因为:
- 在实模式下,DITR.base 指向的表格项直接给出中断服务例程(Interrupt Service Routine)的入口地址。
- 在保护模式下,并不直接给出入口地址,而是门描述符(Interrupt/Trap/Task gate),从这些门描述符间接取得中断服务例程入口。
在 x86/x64 体系中允许有 256 个中断存在,中断号从 0x00 - 0xff,共 256 个中断,如图:
上面这个图是实模式下的 IVT 表,每个向量占据 4 个字节,中断服务例程入口是以 segment:offset 形式提供的,offset 在低端,segment 在高端,整个 IVT 表从地址 0x0 - 0x3FF,占据了 1024 个字节,即 1K bytes
1.2 改变中断向量表地址
事实上,我们完全可以在实模式下更改 IVT 的地址,下面的代码作为示例:
bits 16 org BOOT_SEG ; for int 19 sidt [old_IVT] ; save old IVT mov cx, [old_IVT] mov dword [new_IVT+2], 0x8000 ; base of new IVT mov si, [old_IVT+2] rep movsb lidt [new_IVT] ; set new IVT
;----------------------------------- print_loop: done:
old_IVT dw 0 ; limit of IVT new_IVT dw 0 ; limit of IVT
|
在 vmware 上这段代码的执行结果如图:
这段代码在实模式下将 IVT 表复制到 0x8000 位置上,然后将 IVT 地址设为 0x8000 上,这样完全可以正常工作。正如代码上看到的,我做:
- 使用 sidt 指令取得 IDTR 寄存器的值,即 IVT 的 limit 和 base 值,保存在 old_IVT 里
- 设置 new_IVT 值,limit 等于 old_IVT 的 limit,base 设为 0x8000
- 将 IVT 表复制到 0x8000 处
- 使用 lidt 指令加载 IDTR 寄存器,即设 IVT 表在 0x8000 处
1.3 设置自己的中断服务例程
在中断向量表里还有许多空 vector 是未使用的,我们可以在这些空白的向量里设置自己的中断服务例程,典型的如: DOS 操作系统中使用了 0x21 号向量作为 DOS 提供给用户的系统调用!
在这里我将展示,使用 0x40 向量作为自己的中断服务例程向量,我所需要做的是:
- 写一个自己的中断服务例程,在本例中的 my_isr
- 设置向量 0x40 的 segment 和 offset 值
- 调用 int 0x40 进行测试
中断服务例程 my_isr 很简单,仅仅是打印信息:
;------------------------------------------------ ; our Interrupt Service Routine: vector 0x40 ;------------------------------------------------- my_isr: mov si, msg3 call printmsg iret |
接下来设置 vector 0x40 的 segment 和 offset:
mov ax, cs mov bx, [new_IVT+2] ; base of IVT mov dword [bx+0x40*4], my_isr ; set offset 0f my_isr mov [bx+0x40*4+2], ax ; set segmet of my_isr |
记住 segment 在高位,offset 在低位,这个 segment 是我们当前的 CS,offset 是我们的 ISR 地址,直接写入 IVT 表中就可以了
现在我们可以测试了:
int 0x40 |
结果如下:
我们的 ISR 能正常工作了,我提供了完整的示例源码和磁盘映像下载:interrupt_demo
2. 保护模式下的中断机制
引入保护模式后,情形变得复杂多了,实施了权限控制机制,为了支持权限的控制增添了几个重要的数据结构,下面是与中断相关的结构:
- gate descriptor(门描述符):用于描述和控制 Interrupt Service Routine 的访问,中断可使用的 gate 包括:
- Interrupt-gate descriptor(中断门描述符)
- Trap-gate descriptor(陷井门描述符)
- Task-gate descriptor(任务门描述符)-- 在 64 位模式下无效
- interrupt descriptor table(中断描述符表):用于存在 gate descriptor 的表格
在 32 位保护模式下,每个 gate descriptor 是 8 bytes 宽,在 64 位模式下 gate descriptor 被扩充为 16 bytes, 同时 64 位模式下不存在 task gate descriptor,因此在 64 位下的 IDT 只允许存放 interrupt/trap gate descriptor。
当我们执行调用中断时,processor 会在 IDT 表中寻找相应的 descriptor,并通过了权限检查转入相应的 interrupt service routine(大多数时候被称为 interrupt handler),在中断体系里分为两种引发方式:
- 由硬件引发请求
- 由软件执行调用
然而软件上发起的中断调用还可分为:
- 引发 exception(异常) 执行 interrupt handler
- 软件请求执行 system service(系统服务),而执行 interrupt handler
硬件引发的中断请求还可分为:
- maskable interrupt(可屏蔽中断)
- non-maskable interrupt(不可屏蔽中断)
无论何种方式,进入 interrupt handler 的途径都是一样的。
2.1 查找 interrupt handler 入口
在发生中断时,processor 在 IDTR.base 里可以获取 IDT 的地址,根据中断号在 IDT 表里读取 descriptor,descriptor 的职责是给出 interrupt handler 的入口地址,processor 会检查中断号(vector)是否超越 IDT 的 limit 值。
上图是 interrupt handler 的定位查找图。在 32 位模式下的过程是:
- 从 IDTR.base 得到 IDT 表地址
- 从 IDTR.base + vector * 8(每个 descriptor 为 8 bytes)处读取 8 bytes 宽的 escriptor
- 对 descriptor 进行分析检查,包括:
- descriptor 类型的检查
- IDT limit 的检查
- 访问权限的检查
- 从 gate descriptor 里读取 selector
- 判断 code segment descriptor 是存放在 GDT 表还是 LDT 表
- 使用 selector 从 descriptor table 读取 code segment descriptor,当然这里也要经过对 code segment descriptor 的检查
- descriptor 类型检查
- GDT limit 检查
- 访问权限的检查
- 从 code segment descriptor 中读取 code segment base 值
- 从 gate descriptor 里读取 interrupt handler 的 offset 值
- 得取 interrupt handler 的入口地址:base + offset,转入执行 interrupt handler
它的逻辑用 C 描述,类似下面:
long IDT_address; /* address of IDT */
selector = gate_descriptor.selector; ((*(void))interrupt_handler)(); /* do interrupt_handler() */ |
上面的 C 代码显示了 processor 定位 interrupt handler 的逻辑过程,为了清楚展示这个过程,这里省略了各种的检查机制!
2.2 IDT 表中 descriptor 类型的检查
processor 会对 IDT 表中的 descriptor 类型进行检查,这个检查发生在:
当读取 IDT 表中的 descriptor 时 |
在 IDT 中的 descriptor 类型要属于:
- S = 0:属于 system 类 descriptor
- descriptor 的 type 域应属于:
- 1110:32-bit interrupt gate
- 1111:32-bit trap gate
- 0101:task gate
- 0110:16-bit interrupt gate
- 0111:16-bit trap gate
非上述所说的类型,都将会产生 #GP 异常。当 descriptor 的 S 标志为 1 时,表示这是个 user 的 descriptor,它们是:code/data segment descriptor。可以看到在 32 位保护模式下 IDT 允许存在 interrupt/trap gate 以及 task gate
2.3 使用 16-bit gate descriptor
在 32 位保护模式下 interrupt handler 也能使用 16-bit gate descriptor,包括:
- 16-bit interrupt gate
- 16-bit trap gate
这是一个比较特别的现象,假如使用 16-bit gate 来构建中断调用机制,实际上等于 interrupt handler 会从 32-bit 模式切换到 16-bit 模式执行。只要构建环境要素正确这样切换执行当然是没问题的。
这个执行环境要素需要注意的是:当使用 16-bit gate 时,也要相应使用 16-bit code segment descriptor。也就是在 gate descriptor 中的 selector 要使用 16-bit code segment selector。下面我写了个使用 16-bit gate 构建 interrupt 调用的例子:
; set IDT vector mov eax, BP_handler mov [IDT+3*8], ax ; set offset 15-0 mov word [IDT+3*8+2], code16_sel ; 16-bit code selector mov word [IDT+3*8+4], 0xc600 ; DPL=3, 16-bit interrupt gate shr eax, 16 mov [IDT+3*8+8], ax ; offset 31-16 |
上面这段代码将 vector 3 设置为使用 16-bit interrupt gate,并且使用了 16-bit selector
下面是我的 interrupt handler 代码:
bits 16 ;----------------------------------------------------- do_BP_handler: iret |
这个 interrupt handler 很简单,只是打印一条信息而已,值得注意的是,这里需要使用 bits 16 来指示编译为 16 位代码。
那么这样我们就可以使用 int3 指令来调用这个 16-bit 的 interrupt handler,执行结果如图:
完整的源代码和软盘映像下载:interrupt_demo1.rar
2.4 IDT 表的 limit 检查
在 IDT 表中查找索引 gate descriptor 时,processor 也会对 IDT 表的 limit 进行检查,这个检查的逻辑是:
gate_descriptor = IDTR.base + vector * 8; /* get gate descriptor */ if ((gate_descriptor + sizeof(DESCRIPTOR) - 1) > (IDTR.base + IDTR.limit)) |
我们看看下面这个图:
当我们设:
- IDTR.base = 0x10000
- IDTR.limit = 0x1f
那么 IDT 表的有效地址范围是:0x10000 - 0x1001f,也就是:IDTR.base + IDTR.limit 这表示:
- vector 0:0x10000 - 0x10007
- vector 1:0x10008 - 0x1000f
- vector 2: 0x10010 - 0x10017
- vector 3: 0x10018 - 0x1001f
上面是这 4 个 vector 的有效范围,因此:当设 IDTR.limit = 0x1e 时,如果访问 vector 3 时(调用中断3)processor 检测到访问 IDT 越界而出错!
因此:访问的 vector 地址在 IDTR.base 到 IDTR.base + IDTR.limit(含)之外,将会产生 #GP 异常。
2.5 请求访问 interrupt handler 时的权限检查
访问权限的检查是 x86/x64 体系中保护措施中非常重要的一环,它控制着访问者是否有权限进行访问,在访问 interrupt handler 过程权限控制中涉及 3 个权限类别:
- CPL:当前 processor 所处的权限级别
- DPLg:代表 DPL of gate,也就是 IDT 中 gate descriptor 所要求的访问权限级别
- DPLs:代表 DPL of code segment,也就是 interrupt handler 的目标 code segment 所要求的访问权限级别
CPL 权限级别代表着访问者的权限,也就是说当前正在执行代码的权限,要理解权限控制的逻辑,你需要明白下面两点:
- 要调用 interrupt handler 那么首先你必须要有权限去访问 IDT 表中的 gate,这表示:CPL 的权限必须不低于 DPLg (gate 所要求的权限),这样你才有权限去访问 gate
- interrupt handler 会在高权限级别里执行,也就是说 interrupt handler 会在特权级别里运行。这表示:interrupt handler 里的权限至少不低于访问者的权限
在调用 interrupt handler 中并不使用 selector 来访问 gate 而是使用使用 vector 来访问 gate,因此中断权限控制中并不使用 RPL 权限类别,我们可以得知中断访问权限控制的要素:
- CPL <= DPLg
- CPL >= DPLs
需同时满足上面的两个式子,在比较表达式中数字高的权限低,数字低的权限高!用 C 描述为:
DPLg = gate_descriptor.DPL; /* DPL of gate */ if ((CPL <= DPLg) && (CPL >= CPLs)) |
2.5.1 gate 的权限设置
对于 gate 的权设置,我们应考虑 interrupt handler 调用上的两个原则:
- interrupt handler 开放给用户调用
- interrupt handler 在系统内部使用
由这两个原则产生了 gate 权根设置的两个设计方案:
- gate 的权限设置为 3 级:这样可以给用户代码有足够的权限去访问 gate
- gate 的权限设置为 0 级:只允许内核代码访问,用户无权通过这个 gate 去访问 interrupt handler
这是现代操作系统典型的 gate 权限设置思路,绝大部分的 gate 都设置为高权限,仅有小部分允许用户访问。很明显:系统服务例程的调用入口应该设置为 3 级,以供用户调用。
下面是很典型的设计:
- #BP 异常:BreakPoint(断点)异常的 gate 应该设置为 3 级,使得用户程序能够使用断点调试程序。
- 系统调用:系统调用是 OS 提供给用户访问 OS 服务的接口,因此 gate 必须设置为 3 级。
系统调用在每个 OS 实现上可能是不同的,#BP 异常必定是 vector 3,因此对于 vector 3 所使用的 gate 必须使用 3 级权限。
下面是在 windows 7 x64 操作系统上的 IDT 表的设置:
<bochs:2> info idt ... ... IDT[0x29]=64-Bit Interrupt Gate target=0x0010:fffff80003bf2290, DPL=0 |
上面的粗体显示 interrupt gate 被设置为 3 级,在 windows 7 x64 下 vector 0x2c 和 0x2d 被设置为系统调用接口。实际上这两个 vector 的入口虽然不同,但是代码是一样的。你可以通过int 0x2c 和 int 0x2d 请求系统调用。
那么对于系统内部使用的 gate 我们应该保持与用户的隔离,绝大部分 interrupt handler 的 gate 权限都是设置为 0 级的。
2.5.2 interrupt handler 的 code segment 权限设置
前面说过:interrupt handler 的执行权限应该至少不低于调用者的权限,意味着 interrupt handler 需要在高权限级别下运行。无论是系统提供给用户的系统服务例程还是系统内部使用的 interrupt handler 我们都应该将 interrupt handler 设置为 0 级别的运行权限(最高权限),这样才能保证 interrupt handler 能访问系统的全部资源。
在权限检查方面,要求 DPLs 权限(interrupt handler 的执行权限)要高于或等于调用者的权限,也就是 CPL 权限,当数字上 DPLs 要小于等于 CPL(DPLs <= CPL)。
2.6 使用 interrupt gate
使用 interrupt gate 来构造中断调用机制的,当 processor 进入 interrupt handler 执行前,processor 会将 eflags 值压入栈中保存并且会清 eflags.IF 标志位,这意味着进入中断后不允许响应 makeable 中断(可屏蔽中断)。它的逻辑 C 描述为:
*(--esp) = eflags; /* push eflags */ if (gate_descriptor.type == INTERRUPT_GATE) |
interrupt handler 使用 iret 指令返回时,会将栈中 eflags 值出栈以恢复原来的 eflags 值。
下面是 interrupt gate 的结构图:
可以看到 interrupt gate 和 trap gate 的结构是完全一样的,除了以 type 来区分 gate 外,interrupt gate 的类型是:
- 1110:32-bit interrupt gate
- 0110:16-bit interrupt gate
32 位的 offset 值提供了 interrupt handler 的入口偏移地址,这个偏移量是基于 code segment 的 base 值,selector 域提供了目标 code segment 的 selector,用来在 GDT 或 LDT 进行查找 code segment descriptor。这些域的使用描述为:
if (gate_descriptor.selector.TI == 0)
|
注得注意的是:在 interrupt gate 和 trap gate 中的 selector 它的 RPL 是不起作用的,这个 selector.RPL 将被忽略。
在 OS 的实现中大部分的 interrupt handler 都是使用 interrupt gate 进行构建的。在 windows 7 x64 系统上全部都使用 interrupt gate 并没有使用 trap gate
2.7 使用 trap gate
trap gate 在结构上与 interrupt gate 是完全一样的,参见节 2.6 的那图,trap gate 与 interrupt gate 不同的一点是:使用 trap gate 的,processor 进入 interrupt handler 前并不改变 eflags.IF 标志,这意味着在 interrupt handler 里将允许可屏蔽中断的响应。
*(--esp) = eflags; /* push eflags */ if (gate_descriptor.type == TRAP_GATE) { |
2.8 使用 task gate
在使用 task gate 的情形下变得异常复杂,你需要为 new task 准备一个 task 信息的 TSS,然而你必须事先要设置好当前的 TSS 块,也就是说,系统中应该有两个 TSS 块:
- current TSS
- TSS of new task
当前的 TSS 是系统初始化设置好的,这个 TSS 的作用是:当发生 task 切换时保存当前 processor 的状态信(当前进程的 context 环境),新任务的 TSS 是通过 task gete 进行切换时使用的 TSS 块,这个 TSS 是存放新任务的入口信息。
tss_desc dw 0x67 ; seletor.SI = 3 tss_gate_desc dw 0x67 ; selector.SI = 4 |
在上面的示例代码中,设置了两个 TSS descriptor,一个供系统初始化使用(tss_desc),另一个是为新任务而设置(tss_task_gate),代码中必须设置两个 TSS 块:
- TSS
- TSS_TASKGATE
TSS 块的内容是什么在这个示例中无关紧要,然而 TSS_TASKGATE 块中应该设置新任务的入口信息,其中包括:eip 和 cs 值,以后必要的 DS 与 SS 寄存器值,还有 eflags 和 GPRs 值,下面的代码正是做这项工作:
; set TSS for task-gate mov dword [TSS_TASKGATE+0x20], BP_handler32 ; tss.EIP mov dword [TSS_TASKGATE+0x4C], code32_sel ; cs mov dword [TSS_TASKGATE+0x50], data32_sel ; ss mov dword [TSS_TASKGATE+0x54], data32_sel ; ds mov dword [TSS_TASKGATE+0x38], esp ; esp pushf pop eax mov dword [TSS_TASKGATE+0x24], eax ; eflags |
我将新任务的入口点设为 BP_handler32(),这个是 #BP 断点异常处理程序,保存当前的 eflags 值作为新任务的 eflags 值。
我们必须为 task gate 设置相应的 IDT 表项,正如下面的示例代码:
; set IDT vector: It's a #BP handler mov word [IDT+3*8+2], tss_taskgate_sel ; tss selector |
示例代码中,我为 vector 3(#BP handler)设置为 task-gate descirptor,当发生 #BP 异常时,就会通过 task-gate 进行任务切换到我们的新任务(BP_handler32)。
; load IDT into IDTR
|
当然我们应该先设置好 IDT 表和加载当前的 TSS 块,这个 TSS 块就是我们所定义的第1个 TSS descirptor (tss_desc),这个 TSS 块里什么内容都没有,设置它的目的是为切换到新任务时,保存当前任务的 context 环境,以便执行完新任务后切换回到原来的任务。
db 0xcc ; throw BreakPoint |
现在我们就可以测试我们的 BP_handler32(),通过 INT3 指令引发 #BP 异常,这个异常通过 task-gate 进行切换。
我们的 BP_handler32 代码是这样的:
;----------------------------------------------------- do_BP_handler32: mov edi, 10 clts ; clear CR0.TS flag iret |
它只是简单的显示一条信息,在这个 BP_handler32 中,我们应该要清 CR0.TS 标志位,这个标志位是通过 TSS 进行任务切换时,processor 自动设置的,然而 processsor 不会清 CR0.TS 标志位,需要代码中清除。
2.8.1 任务切换的情形
在本例中,我们来看看当进行任务切换时发生了什么,processor 会设置一些标志位:
- 置 CR0.TS = 1
- 置 eflags.NT = 1
设置 CR0.TS 标志位表示当前发生过任务切换,processor 只会置位,而不会清位,事实上,你应该使用 clts 指令进行清位工作。设置 eflags.NT 标志位表示当前任务是在嵌套层内,它指示当进行中断返回时,需切换回原来的任务,因此,请注意:
当执行 iret 指令时,processor 会检查 eflags.NT 标志是否置位 |
当 eflags.NT 被置位时,processor 执行另一个任务切换工作,从 TSS 块的 link 域中取出原来的 TSS selector 从而切换回原来的任务。这不像 ret 指令,它不会检查 eflags.NT 标志位。
processor 也会对 TSS descriptor 做一些设置标志,当进入新任务时,processor 会设置 new task 的 TSS descriptor 为 busy,当切换回原任务时,会置回这个任务的 TSS descriptor 为available,同时 processor 会检查 TSS 中的 link 域的 TSS selector(原任务的 TSS)是否为 busy,如果不为 busy 则会抛出 #TS 异常。
当然发生切换时 processor 会保存当前的 context 到 current TSS 块中,因此:
- 切换到目标任务时,processor 会保存当前的任务 context 到 TSS,读取目标任务的 TSS,加载相应的信息,然后进入目标任务
- 目标任务执行完后,切换回原任务时,processor 会保存目标任务的 context 到目标任务的 TSS 中,从目标任务的 TSS 块读取 link(原任务的 TSS selector),加载相应的信息,返回到原任务
当从目标任务返回时,processor 会清目标任务的 eflags.NT = 0,如前所述目标任务的 TSS descriptor 也会被置为 available。