说到中断,前面我们在实模式下获取内存信息时刚刚用过int 15H。之所以在实模式下进行,是因为在保护模式下中断机制将会发生很大变化。原来的中断向量表被IDT代替,实模式下能用的BIOS中断在保护模式下已经不能再用。其中IDT(Interrupt Descriptor Table)也是一个描述符表,它里面的描述符可以是中断门描述符、陷阱门描述符、任务门描述符中的任意一种。
IDT的作用是将每一个中断向量和一个描述符对应起来。从这个意义上说,IDT也是一种向量表,虽然它形式上跟实模式下的向量表非常不同。
中断向量到中断处理程序的对应过程如下:
联系调用门我们可以知道,其实中断门和陷阱门的作用机理几乎是一样的,只是使用调用门时用call指令,而这里我们使用int指令。
再来看一下中断门和陷阱门的结构如下:
其它的字段和调用门几乎都一样,只是BYTE4的低5位变成了保留位,而不再是ParamCount。而且,TYPE中的4位也将变为0XE(中断门)或0XF(陷阱门)。而S位将保持为0。
中断通常在程序执行时因为硬件而随机发生,它们通常用来处理处理器外部的事件,例如外围设备的请求。软件通过执行int n指令也可以产生中断。异常则通常在处理器执行指令过程中检测到错误时发生,例如遇到除零的情况。
每一种中断(异常)都会对应一个中断向量号,而这个向量号通过IDT就与相应的中断处理程序对应起来。其中异常主要分为如下三类:
◎Fault:一种可以被更正的异常,而且一旦被更正,程序可以不失连续性地继续执行。当一个Fault发生时,处理器会把产生Fault的指令之前的状态保存起来。异常处理程序的返回地址将会是产生Fault的指令,而不是其后的那条指令。
◎Trap:一种在发生Trap的指令执行之后立即被报告的异常,它也允许程序或任务不失连续性地继续执行。异常处理程序的返回地址将会是产生Trap的指令之后的那条指令。
◎Abort:一种不总是报告精确异常发生位置的异常,它不允许程序或任务继续执行,而是用来报告验证错误的。
其实也可以分别称呼它们为:错误、陷阱和终止。
中断产生的原因有两种:一种是外部中断;另一种是由指令int n产生的中断。其中int指令产生中断时的处理情形可以参考第一张图片给出的情形,n即为向量号,类似于调用门的使用。
外部中断的情况则复杂一些,因为需要建立硬件中断与向量号之间的对应关系。外部中断分为不可屏蔽中断(NMI)和可屏蔽中断两种,分别由CPU的两根引脚NMI和INTR来接收,如下图所示:
NMI不可屏蔽,因为它与IF是否被设置无关。NMI中断对应的中断向量号为2。可屏蔽中断与CPU的关系使通过对可编程中断控制器8259A建立起来的。可以认为8259A是中断机制中所有外围设备的一个代理,这个代理不但可以根据优先级在同时发生中断的设备中选择应该处理的请求,而且可以通过对其寄存器的设置来屏蔽或打开相应的中断。
有上面的控制器级联图可以知道,与CPU相连的是两片8259A,它们每个有8根中断信号线,于是两片级联总共可以挂接15个不同的外部设备。那么,这些设备发出的中断请求如何与中断向量对应起来呢?就是通过对8259A的设置完成的。在BIOS初始化它的时候,IRQ0~IRQ7被设置为对应向量号08H~0FH,而在保护模式下08H~0FH已经被占用,所以必须得重新设置主从8259A。
8259A是可编程中断控制器,对它的设置是通过向相应的端口写入特定的ICW(Initialization Command Word)来实现的。主8259A对应的端口地址是20H和21H,从8259A对应的端口地址是A0H和A1H。ICW共有4个,每一个都是具有特定格式的字节。先看一下初始化过程:
1. 往端口20H(主片)或A0H(从片)写入ICW1。
2. 往端口21H(主片)或A1H(从片)写入ICW2。
3. 往端口21H(主片)或A1H(从片)写入ICW3。
4. 往端口21H(主片)或A1H(从片)写入ICW4。
这4步的顺序是不能颠倒的。
看一下4个ICW的格式:
在ICW2中我们看到了中断向量号的对应位,这就是重点所在!
设置8259A的代码如下:
; Init8259A ---------------------------------------------------------
Init8259A:
mov al, 011h
out 020h, al ; 主8259, ICW1.
call io_delay
out 0A0h, al ; 从8259, ICW1.
call io_delay
mov al, 020h ; IRQ0 对应中断向量 0x20
out 021h, al ; 主8259, ICW2.
call io_delay
mov al, 028h ; IRQ8 对应中断向量 0x28
out 0A1h, al ; 从8259, ICW2.
call io_delay
mov al, 004h ; IR2 对应从8259
out 021h, al ; 主8259, ICW3.
call io_delay
mov al, 002h ; 对应主8259的 IR2
out 0A1h, al ; 从8259, ICW3.
call io_delay
mov al, 001h
out 021h, al ; 主8259, ICW4.
call io_delay
out 0A1h, al ; 从8259, ICW4.
call io_delay
mov al, 11111110b ; 仅仅开启定时器中断
;mov al, 11111111b ; 屏蔽主8259所有中断
out 021h, al ; 主8259, OCW1.
call io_delay
mov al, 11111111b ; 屏蔽从8259所有中断
out 0A1h, al ; 从8259, OCW1.
call io_delay
ret
; Init8259A ---------------------------------------------------------
这段代码分别往主、从8259A各写入了4个ICW。在写入ICW2时,我们设定了IRQ0对应中断向量号为20H,于是,IRQ0~IRQ7就对应中断向量20H~27H;类似地,IRQ8~IRQ15对应中断向量28H~2FH。
后半部分对端口21H和A1H的操作屏蔽了所有的外部中断,这次写入的不再是ICW,而是OCW(OperationControl Word)。OCW共3个,OCW1、OCW2、OCW3。基本上我们只在两种情况下用到它们,一是屏蔽或打开外部中断时;二是发送EOI给8259A以通知它中断处理结束时。
其中,io_delay的代码如下:
io_delay:
nop
nop
nop
nop
ret
若要屏蔽或打开外部中断,只需要网8259A中写入OCW1就可以了,OCW1的结构如下:
可见,若想要屏蔽某一个中断,将对应那一位设置成1就可以了。实际上,OCW1是被写入了中断屏蔽寄存器(IMR,Interrupt Mask Register)中,当一个中断到达,IMR会判断此中断是否应该被丢弃。
说起EOI,当每一次中断处理结束,需要发送一个EOI给8259A,以便继续接收中断。而发送EOI是通过端口20H或A0H写OCW2来实现的。OCW2的结果如下:
发送EOI给8259A可以由如下代码实现:
mov al, 20h
out 20h或A0, al
在相应的位置添加调用Init8259A的指令之后,对8259A的操作就结束了,我们接下来要做的就是建立一个IDT。为了方便操作,我们把IDT放进一个单独的段中:
; IDT
[SECTION .idt]
ALIGN 32
[BITS 32]
LABEL_IDT:
; 门 目标选择子, 偏移, DCount, 属性
%rep 255
Gate SelectorCode32, SpuriousHandler, 0, DA_386IGate
%endrep
IdtLen equ $ - LABEL_IDT
IdtPtr dw IdtLen - 1 ; 段界限
dd 0 ; 基地址
; END of [SECTION .idt]
上面的代码利用NASM中的%rep预处理指令,将每一个描述符都设置为指向SelectorCode32:SpuriousHandler的中断门。其中SpuriousHandler的实现如下:
_SpuriousHandler:
SpuriousHandler equ _SpuriousHandler - $$
mov ah, 0Ch ; 0000: 黑底 1100: 红字
mov al, '!'
mov [gs:((80 * 0 + 75) * 2)], ax ; 屏幕第 0 行, 第 75 列。
jmp $
iretd
接下来开始加载IDT:
; 为加载 IDTR 作准备
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_IDT ; eax <- idt 基地址
mov dword [IdtPtr + 2], eax ; [IdtPtr + 2] <- idt 基地址
•••
; 关中断
cli
; 加载 IDTR
lidt [IdtPtr]
在执行lidt之前用cli指令清IF位,暂时不响应屏蔽中断。
到此时为止,中断机制已经初始化完成了,不过此时运行的话,程序将无法正常回到实模式。因为IDTR以及8259A等内容已经被修改了,要想顺利跳回实模式还要将它们恢复原样才行。
现在,我们就可以利用保护模式下的中断异常处理机制来做一些事情。比较好玩的就是时钟中断,时钟中断属于可屏蔽中断。可屏蔽中断与NMI的区别在于是否收到IF位的影响,而8259A的中断屏蔽寄存器(IMR)也影响着中断是否会被响应。所以,外部可屏蔽中断的发送就受到两个因素的影响,只有当IF位为1,并且IMR相应位为0时才会发生。
所以,我们要打开时钟中断的话,不仅要设计一个中断处理程序,还有设置IMR,并且设置IF位。设置IMR可以通过写OCW2来完成,而设置IF可以通过指令sti来完成。
先看一下我们的时钟处理程序:
; int handler -------------------------------------------------------
_ClockHandler:
ClockHandler equ _ClockHandler - $$
inc byte [gs:((80 * 0 + 70) * 2)] ; 屏幕第 0 行, 第 70 列。
mov al, 20h
out 20h, al ; 发送 EOI
iretd
中断处理程序中,除了发送EOI的两行语句以及iretd之外,只有一条指令,该指令将屏幕第0行、第70列的字符增一,变成ASCII码表中位于它后面的字符。以后每发生一次时钟中断,字符就会变化一次,我们将看到不断变化的字符。
修改8259A的代码,开启时钟中断:
mov al, 11111110b ; 仅仅开启定时器中断
out 021h, al ; 主8259, OCW1.
call io_delay
mov al, 11111111b ; 屏蔽从8259所有中断
out 0A1h, al ; 从8259, OCW1.
call io_delay
ret
修改IDT代码,如下:
; IDT
[SECTION .idt]
ALIGN 32
[BITS 32]
LABEL_IDT:
; 门 目标选择子, 偏移, DCount, 属性
%rep 32
Gate SelectorCode32, SpuriousHandler, 0, DA_386IGate
%endrep
.020h: Gate SelectorCode32, ClockHandler, 0, DA_386IGate
%rep 95
Gate SelectorCode32, SpuriousHandler, 0, DA_386IGate
%endrep
.080h: Gate SelectorCode32, UserIntHandler, 0, DA_386IGate
IdtLen equ $ - LABEL_IDT
IdtPtr dw IdtLen - 1 ; 段界限
dd 0 ; 基地址
; END of [SECTION .idt]
现在调用80H号中断之后执行sti来打开中断,效果应该可以看到了。只是程序马上会继续执行,可能没等第一个中断发生程序就已经执行完并退出了,所以我们就愉快地让它进入了死循环:
int 080h
sti
jmp $
运行画面的抓拍结果如下:
画面中出现的是‘U’,不过不管怎么样,这已经足够说明我们已经可以正确地对8259A做出相应的设置了。