5 中断处理
5.1 Interrupt!
在正式开始之前,笔者想说的是笔者把之前的打印函数用汇编写了一遍放在lib.asm里,居然bin文件的大小小了2KB!看来GCC在优化还是没人厉害。
切入正题。我们的内核,还不能称之为内核,因为我们的内核还欠缺了一个重要因素:中断处理。
中断,是一种从打断CPU顺序执行的方式,有外部中断也有内部中断。可以用来做异常报告、设备通讯之用途。我们先讲讲内部中断。
在Intel的处理器中,有一个表叫做中断向量表。在那之前,我们先看看从Intel手册中获得的中断异常一览表:
表格 5.1.1中断和异常列表
我们来认识一下这些异常的种类:
Fault: 错误,这种异常是可以更正的,一旦错误被更正以后,程序可以继续执行。更正后,程序将继续执行原先的指令。
Trap: 陷阱,这种异常也是可以更正的,一旦错误被更正以后,程序可以继续执行,但是会执行下一条指令。
Abort: 中止,这种异常不可以更正的,一般是严重的错误。
了解了这些含义之后,我们就要问了,怎么捕捉这些异常呢?
5.2 中断向量表
为了处理中断,我们先来介绍一下中断向量表。
中断向量表和GDT一样,是很多8字节的描述符构成的表。不过,和GDT不一样的是,第一项不一定是空项。不过,中断描述符和GDT就差很多了。中断描述符实质上是一个门描述符。
Intel大叔说,中断描述符可以是任务门,中断门或者陷阱门。这些门又是什么东西呢?
我们先除去任务门不说。
中断门和陷阱门,其实就是一个指向代码的地址。我们都知道操作系统和应用程序的权限不同。CPU中也有特权级的概念。运行应用程序的时候,如果中断产生了,我们势必要切换到内核。但是,我们还要转移特权级。于是,Intel设计了门这样一个机制。中断门和陷阱门的区别就是经过中断门后,可屏蔽中断就被屏蔽了。而过了陷阱门后中断不会被屏蔽。
图 5.2.1 门描述符
上图就是门描述符的结构。不是很麻烦。我来介绍一下:第5字节和段描述符一样,P
、DPL、S还有Type,我就不介绍了。对了,上图的D表示门的大小,1=32位。上面的保留部分实际上被某些别的门所用到,但是这两个门我们就把它置为0。
门的目标地址就是选择子:偏移。知道了这些以后,我们也可以开始写代码了。
我们先写一个宏初始化门:
;=====================================================
; GateDescriptor(u32 offset, u16 selector, u8 count, u8attr);初始化门描述符
;-----------------------------------------------------
; Entry:
; - arg0 -> 偏移
; - arg1 -> 选择子
; - arg2 -> 计数器
; - arg3 -> 属性
; Exit:
; - 填充一个段描述符
%macro GateDescriptor 4
dw %1 & 0FFFFh ; 门偏移低16位
dw %2 ; 选择子
dw (%3 & 1Fh) | ((%4 << 8) & 0FF00h) ; 计数器及属性
dw (%1 >> 16) & 0FFFFh ; 门偏移高16位
%endmacro
代码 5.2.1 门初始化(chapter5/a/boot/include/protect.inc)
由于IDTPointer和GDTPointer的结构相同,所以我们直接%define IDTPointer GDTPointer了。
接下来初始化IDTTable:
IDTTable:
GateDescriptor cont, SEL_FLAT_C, 0, DA_386IGate
IDT_END:
IdtPtr: IDTPointerIDTTable, IDT_END – IDTTable
代码 5.2.2 IDT(chapter5/a/kernel/entry.asm)
我们改写了_start函数,里面加入了IDT的加载以及测试代码。
_start:
mov esp, 0x9FC00 ; Set the stack
cli
lgdt [GdtPtr]
lidt [IdtPtr]
sti
jmp SEL_FLAT_C:gdtinit
gdtinit:
int 0
cli
hlt
_cont:
cont equ _cont-$$+KERNEL_ENTRY
call cstart
cli
hlt
代码 5.2.3 测试一下,你就知道(chapter5/a/kernel/entry.asm)
这两段代码吧0号中断映射到了cont函数,如果中断设置成功,通过int 0,应该能成功启动cstart,如果失败,那么虚拟机会崩溃。我们可以执行一下。完全没变化,说明我们的IDT设置成功了。
5.3 中断处理
世界上不可能存在一个只能处理一个中断的操作系统。我们势必需要扩展我们的OS。笔者打算把IDT专门放在一个文件中,我把它叫做interrupt.asm。需要注意的是,我们的中断处理函数采用了绝对定位,所以我们必须要能够静态地找出中断处理函数的地址。在NASM中,我们只能知道代码在段中的偏移地址,所以我们只要保证interrupt.asm的段在内存的最开始处就行了。为此,我们要修改ld参数的顺序,然后把_start放到interrupt.c中去。
这部分操作虽然复杂,但是没什么技术含量,读者可以自行去看chapter5/b中的源代码以及Makefile。
我们添加了所有的内部中断,目前有32项。
divide_error:
push -1
push 0
jmp exception
exception:
call exception_handler
add esp, 4*2
hlt
代码 5.3.1 中断处理程序(chapter5/b/interrupt.asm)
所有的中断处理程序都和这个一样,如果没有错误码就压栈一个-1。
我们创建了一个新文件int.c,然后增加了exception_handler函数。
void exception_handler(u_addr vec_no, u_addr err_code, u_addr eip, u_addr cs, u_addr eflags){
static char* err_msg[] = {
"#DEDivide Error",
"#DBDebug Error",
" NMI Interrupt",
"#BPBreakpoint",
"#OFOverflow",
"#BRBOUND Range Exceeded",
"#UDInvalid Opcode (Undefined Opcode)",
"#NMDevice Not Available (No Math Coprocessor)",
"#DFDouble Fault",
" Coprocessor Segment Overrun (reserved)",
"#TSInvalid TSS",
"#NPSegment Not Present",
"#SSStack-Segment Fault",
"#GPGeneral Protection",
"#PFPage Fault",
" Intel Reserved",
"#MFx87 FPU Floating-Point Error (Math Fault)",
"#ACAlignment Check",
"#MCMachine Check",
"#XFSIMD Floating-Point Exception",
"#VEVirtualization Exception"
};
if(vec_no==-1)vec_no=15;
puts(err_msg[vec_no]);
if(err_code!=-1){
puts("\r\n Error Code: ");
dispInt(err_code);
}
puts("\r\n Source: ");
dispInt(cs);
puts(" : ");
dispInt(eip);
puts("\r\n EFlags: ");
dispInt(eflags);
u8* addr;
for(addr=(u8*)0xB8001;((u_addr)addr)<0xB8FA0;addr+=2){
*addr=0x1F;
}
}
代码 5.3.2 异常处理(chapter5/b/int.c)
非常地简单,我们显示了错误名称、错误码、以及EFLAGS和出错地址。
为了有趣,我还加上了把整个屏幕变蓝的函数,并在cstart中清空了屏幕。这样我们就制造了一个蓝屏。
Make然后运行。
图 5.3.1 我们的OS在VMware下的效果
真是Perfect!
5.4 8259A
现在的处理器都有一个APIC(高级可编程中断控制器),APIC比较复杂,我们先从最早的中断机制看起。
最早的中断控制机制拥有2片级联的PIC(可编程中断控制器),也就是说最多可以支持
一个外部中断请求信号通过中断请求线IRQ,传输到IMR(中断屏蔽寄存器),IMR根据所设定的中断屏蔽字(OCW1),决定是将其丢弃还是接受。如果可以接受,则8259A将IRR(中断请求暂存寄存器)中代表此IRQ的位置位,以表示此IRQ有中断请求信号,并同时向CPU的INTR(中断请求)管脚发送一个信号,但CPU这时可能正在执行一条指令,因此CPU不会立即响应,而当这CPU正忙着执行某条指令时,还有可能有其余的IRQ线送来中断请求,这些请求都会接受IMR的挑选,如果没有被屏蔽,那么这些请求也会被放到IRR中,也即IRR中代表它们的IRQ的相应位会被置1。
INTR管脚会要求CPU处理中断是否有信号,如果发现有信号,就会转到中断服务,此时,CPU会立即向8259A芯片的INTA(中断应答)管脚发送一个信号。当芯片收到此信号后,它就在IRR中,挑选优先级最高的中断,将中断请求送到ISR(中断服务寄存器),也即将ISR中代表此IRQ的位置位,并将IRR中相应位置零,表明此中断正在接受CPU的处理。同时,将它的编号写入中断向量寄存器IVR的低三位,这时,CPU还会送来第二个INTA信号,当收到此信号后,芯片将IVR中的内容,也就是此中断的中断号送上通向CPU的数据线。
对8259A的编程是通过向其相应的端口发送一系列的ICW(初始化命令字)完成的。总共需要发送四个ICW,它们都分别有自己独特的格式,而且必须按次序发送,并且必须发送到相应的端口。在初始化命令字中有一些位是针对8080/8085CPU的,因为这两种CPU早已不用了,所以,在下面命令字的说明中对这些位不再作进一步的解释。在写命令字时这些位取0。
图 5.4.1 ICW1命令字
对于ICW1命令字,我们使用级联的PIC以及32位中断向量,我们会发送一个ICW4,所以我们应该设置ICW1为0b00010001=0x11。
ICW2用来设置IRQ对应的中断向量号。所有的IRQ最高5位都是一致的,被设置在ICW2中。ICW2的低三位任意,一般设置为0。
ICW3 只有在一个系统中包含多片8259A时才有意义。而系统中多片8259A是由ICW1的D1(SNGL)来指示的,所以只有当SNGL=0时,才设置ICW3 。并且ICW3 在主片和从片中的格式还不一样。在主片中,第几个端口接了从片就把第几位置为1。对于从片来说,ICW3的值等于接在主片上端口号。高5位被保留。
ICW4的结构如下图所示,也比较简单。
图 5.4.2 ICW4的结构
中断的操作不应该被缓冲,所以我们设置BUF和MS位为0。SDNM我们也置为0。EOI的处理我们将手动进行,所以AEOI也清除。我们的确在80x86模式下运行,因此我们设置μP为0。这样一来我们的ICW4设置就完成了,我们将ICW设置为1。
接下来是端口。从片和主片的端口是不同的。而且ICW1必须写入在偶数端口,而ICW2-4则写入在奇数端口。
一下是端口对照表:
名称 | 芯片 | 用途 | 端口 |
INT_MASTER_CTL | 主片 | ICW1 | 0x20 |
INT_MASTER_CTLMASK | 主片 | ICW2-4,OCW | 0x21 |
INT_SLAVE_CTL | 从片 | ICW1 | 0xA0 |
INT_SLAVE_CTLMASK | 从片 | ICW2-4,OCW | 0xA1 |
哦对了,OCW是中断设置的控制字,其中OCW1是屏蔽控制字,OCW2是优先级控制字,还有OCW3。。。这些我们就不管了。
OCW1其实很简单,第几位控制第几个端口,设为0的为表示允许中断,设为1的位表示禁止中断。譬如,0b11111101表示禁止第2号中断(主片IRQ2或者从片IRQ10)。
我们把一些宏定义在include/const.inc里面:
INT_MASTER_CTL equ 0x20
INT_MASTER_CTLMASK equ 0x21
INT_SLAVE_CTL equ 0xA0
INT_SLAVE_CTLMASK equ 0xA1
INT_VECTOR_IRQ0 equ 32
INT_VECTOR_IRQ8 equ INT_VECTOR_IRQ0 + 8
INT_8259A_M_ICW1 equ 0b00010001
INT_8259A_S_ICW1 equ 0b00010001
INT_8259A_M_ICW2 equ INT_VECTOR_IRQ0
INT_8259A_S_ICW2 equ INT_VECTOR_IRQ8
INT_8259A_M_ICW3 equ 0b00000100
INT_8259A_S_ICW3 equ 2
INT_8259A_M_ICW4 equ 0b00000001
INT_8259A_S_ICW4 equ 0b00000001
代码 5.4.1 8259A相关宏(chapter5/c/kernel/include/const.inc)
接下来我们初始化8259A:
init_8259A:
mov al,INT_8259A_M_ICW1
out INT_MASTER_CTL, al
mov al,INT_8259A_M_ICW2
out INT_MASTER_CTLMASK, al
mov al,INT_8259A_M_ICW3
out INT_MASTER_CTLMASK, al
mov al,INT_8259A_M_ICW4
out INT_MASTER_CTLMASK, al
mov al,INT_8259A_S_ICW1
out INT_SLAVE_CTL, al
mov al,INT_8259A_S_ICW2
out INT_SLAVE_CTLMASK, al
mov al,INT_8259A_S_ICW3
out INT_SLAVE_CTLMASK, al
mov al,INT_8259A_S_ICW4
out INT_SLAVE_CTLMASK, al
mov al, 0b11111101
out INT_MASTER_CTLMASK, al
mov al, 0b11111111
out INT_SLAVE_CTLMASK, al
ret
代码 5.4.2 初始化8259A(chapter5/c/kernel/interrupt.asm)
设置完中断后,我们简单处理一下IDT,添加一些表项,这部分代码比较烦,但是不难,不外乎是一些宏,添加方式与之前一样。我们依旧让exception_handler处理这些中断,并让它显示文字“Hardware IRQ”。
更改一下entry.asm的代码,不使用cli,使得我们有机会接受中断:
start:
mov esp, 0x9FC00 ; Set the stack
call init_8259A
call cstart
.loop:
hlt
jmp .loop
代码 5.4.3 允许接受外来中断(chapter5/b/kernel/entry.asm)
接下来Make一下并运行。好像什么反应都没有。对了,我们没有设备产生中断!其实,如果你仔细看了OCW1的设置,你就会发现我们允许了IRQ1:键盘。我们随便按下一个键。出现了!
至此我们IRQ的设置也完成了。
关于8259A的具体内容我们会在讨论进程时进一步讨论。
接下来,我们就要进入比较繁杂的一部分——内存管理了。