操作系统:保护模式下的中断和异常

博客的代码均节选自《Orange's一个操作系统的实现》

前置知识:

GDT的结构(最好是自己写过),实模式下的中断相关知识

正篇

在保护模式下,因为种种原因(比如实模式寻址方式的变化之类的),BIOS提供的中断服务是不可用的。但是中断还是非常重要的一个概念,以至于以后的任务切换,外设访问,异常处理都需要依赖于中断。此时变需要用户手动编写一些中断处理程序。

在正式开始编写中断机制前,先要了解一些基础知识。

异常

当我们程序把0当成除数,或者是代码段跳转的时候特权级(CPL,DPL,RPL)发生了问题,CPU便会抛出一个异常(exception)来告诉操作系统。

异常的分类有3种,分别是:

  1. Fault -> 可更正异常,一旦被更正,程序可以不失连续性地运行下去。当fault发生的时候,CPU会把fault之前的状态保存起来,异常处理程序的返回地址将会是产生fault的那一条指令而不是其之后的指令。
  2. Trap -> 当程序发生trap异常之后会被立即报告,和fault不同的是,处理程序返回的是发生trap的下一行指令(也就是说会跳过发生trap异常的指令)。
  3. Abort -> 是一种不总是报告精确位置的异常,发生的时候意味着CPU不允许程序继续执行下去。发生这个异常的时候意味着发生了严重的错误。

异常都是同步事件,并且都是无法屏蔽的,也就是说异常的接受与否和IF位是没有关系的。非常有意思的是,在程序中类似于int 10h的中断指令其实也是被当作异常来处理的。

下面是intel预设的异常列表:

向量号 助记符 类型 描述 来源
0 #DE 错误 除零错误 DVI和IDIV指令
1 #DB 错误/陷阱 调试异常,用于软件调试 任何代码或数据引用
2 中断 NMI中断 不可屏蔽的外部中断
3 #BP 陷阱 断点 INT 3指令
4 #OF 陷阱 溢出 INTO指令
5 #BR 错误 数组越界 BOUND指令
6 #UD 错误 无效指令(没有定义的指令) UD2指令(奔腾Pro CPU引入此指令)或任何保留的指令
7 #NM 错误 数学协处理器不存在或不可用 浮点或WAIT/FWAIT指令
8 #DF 终止 双重错误(Double Fault) 任何可能产生异常的指令、不可屏蔽中断或可屏蔽中断
9 #MF 错误 向协处理器传送操作数时检测到页错误(Page Fault)或段不存在,486及以后集成了协处理器,本错误就保留不用了 浮点指令
10 #TS 错误 无效TSS 任务切换或访问TSS
11 #NP 错误 段不存在 加载段寄存器或访问系统段
12 #SS 错误 栈段错误 栈操作或加载SS寄存器
13 #GP 错误 通用/一般保护异常,如果一个操作违反了保护模式下的规定,而且该情况不属于其他异常,CPU就是认为是该异常 任何内存引用或保护性检查
14 #PF 错误 页错误 任何内存引用
15 保留
16 #MF 错误 浮点错误 浮点或WAIT/FWAIT指令
17 #AC 错误 对齐检查 对内存中数据的引用(486CPU引入)
18 #MC 终止 机器检查(Machine Check) 错误代码和来源与型号有关(奔腾CPU引入)
19 #XF 错误 SIMD浮点异常 SIMD浮点指令(奔腾III CPU引入)
20~31 保留
32~255 用户自定义中断 中断 可屏蔽中断 来自INTR的外部中断或INT n指令

中断

中断其实是一种程序本身无法预料的外部设备信号(程序内部发生的异常和int指令在这里就理解成程序本身是可以预测的)。

除了程序内部的中断调用(int),我们最关心的就是外部设备的中断了。外部设备的中断也被分为两大类:

  1. 可屏蔽中断
  2. 不可屏蔽中断

不可屏蔽中断时由 #NMI 引脚传输,它的屏蔽与否和IF位的设置没啥关系。所以这里我们主要还是来关注可屏蔽中断

可屏蔽中断

可屏蔽中断通过#INTR 引脚来传输,CPU在 #INTR 上级联了两片8259A芯片,也就是可编程中断控制器8259A。可屏蔽中断和CPU的互通时通过控制8259A来实现的,不深究具体硬件细节的情况下可以把它理解成一种外部中断的统筹,可以通过对其进行设置来控制中断的接受与屏蔽。下面就是8259A的大概样子:

1

整个8259A芯片一共有15个接口,也就是说一共可以挂在15个外部设备。在BIOS加电的时候芯片的IRQ0 ~ IRQ7被设置成向量08H ~ 0FH。但是在之前的表中我们可以发现,08H ~ 0FH已经被占用了,所以这里我们还需要对主从8259A芯片重新设置。

8259A芯片的初始化

我们通常时通过写4个ICW(Initialization Command Word)来实现初始化的。加点开始的时候,主8256A芯片的端口号是20H和21H,从8259A芯片的端口号是A0H和A1H,我们通过向这几个端口写入ICW1,ICW2,ICW3,ICW4来初始化。特别要注意的是,这几个ICW必须以1 ~ 4的顺序来写入端口 ,不能颠倒顺序。下面是ICW的组成:

2

下面是用ICW初始化芯片的代码:

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

8259A芯片的设置

初始化完成后,我们就可以对芯片进行设置了。对芯片的设置是通过对端口写入OCW(Operation Command Word)来实现的。

OCW的组成非常简单,一共有8位,每一位代表了相应中断的开关与否(1是关闭,0是打开)。OCW实际上被写入了中断屏蔽寄存器IMR(Interrupt Mask Register)中,当设备发来中断信号的时候IMR会判断是否抛弃这个信号。

下面是一个OCW使用的例子,例子中我们屏蔽了除了时钟中断以外的所有中断。

	mov	al, 11111110b	; 仅仅开启定时器中断
	out	021h, al	; 主8259, OCW1.
	call	io_delay

	mov	al, 11111111b	; 屏蔽从8259所有中断
	out	0A1h, al	; 从8259, OCW1.
	call	io_delay

io_delay是一个延时函数,代码在这里:

io_delay:
	nop
	nop
	nop
	nop
	ret

IDT

一个中断的正常发生包含了两个部分:

  1. 从设备要能够传递到CPU
  2. CPU要能找到中断号对应的代码

上面对8259芯片的设置解决了第一个问题,现在我们要来解决第二个问题。

中断描述符表IDT(Interrupt Descriptor Table),是一种用来存储中断向量对应的中断描述符的表。IDT存储的是中断向量和处理程序选择子+偏移的对应,也就是说如果我们在程序内部,从这个角度上来看其实IDT和实模式下的中断向量表是一样的概念。IDT描述符包含以下3种:

  1. 中断门
  2. 陷阱门
  3. 任务门

一个IDT描述符包含了中断处理程序的选择子,偏移,属性等信息。中断号是从0开始连续升序的,所以没必要在描述符中包含中断号。

中断门/陷阱门描述符结构如下图:

3

其实中断门和陷阱门还是有区别的。通过中断门进行中断调用的时候会对IF进行复位,所以会防止其他中断对当前中断的干扰,但是陷阱门并不会。

IDT的简单定义:

[SECTION .idt]
ALIGN	32
[BITS	32]
LABEL_IDT:
; 门                        目标选择子,            偏移, DCount, 属性
%rep 255
		Gate	SelectorCode32, SpuriousHandler, 0, DA_386IGate
%endrep;通过宏来使得所有IDT描述都都一样,反正也只是测试用

IdtLen		equ	$ - LABEL_IDT
IdtPtr		dw	IdtLen - 1	; 段界限
		dd	0		; 基地址

Gate宏定义(实际上就是让程序长得稍微好看了一点):

; usage: Gate Selector, Offset, DCount, Attr
;        Selector:  dw
;        Offset:    dd
;        DCount:    db
;        Attr:      db
%macro Gate 4
	dw	(%2 & 0FFFFh)				; 偏移 1				(2 字节)
	dw	%1					; 选择子				(2 字节)
	dw	(%3 & 1Fh) | ((%4 << 8) & 0FF00h)	; 属性					(2 字节)
	dw	((%2 >> 16) & 0FFFFh)			; 偏移 2				(2 字节)
%endmacro ; 共 8 字节

这里偷懒把所有中断号对应的中断处理程序都初始化成同一个。

IDT也是描述符表,所以安装过程其实和GDT类似

	; 为加载 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
	lidt [IdtPtr]

一定要记得提前关中断

还要记得在进入32位代码段并初始化完成寄存器之后要调用8259A芯片的初始化函数

[SECTION .s32]; 32 位代码段. 由实模式跳入.
[BITS	32]

LABEL_SEG_CODE32:
	mov	ax, SelectorData
	mov	ds, ax			; 数据段选择子
	mov	es, ax
	mov	ax, SelectorVideo
	mov	gs, ax			; 视频段选择子

	mov	ax, SelectorStack
	mov	ss, ax			; 堆栈段选择子
	mov	esp, TopOfStack

	call	Init8259A

目前唯一的测试用中断处理程序:

_SpuriousHandler:
SpuriousHandler	equ	_SpuriousHandler - $$
	mov	ah, 0Ch				; 0000: 黑底    1100: 红字
	mov	al, '!'
	mov	[gs:((80 * 0 + 75) * 2)], ax	; 屏幕第 0 行, 第 75 列。
	jmp	$
	iretd

由于初始化IDT 需要的是中断处理程序起始位置的偏移,所以才会有

SpuriousHandler	equ	_SpuriousHandler - $$

这句话(其实就是相对于32位代码段开头的偏移)

接着就可以随便发生一个中断来测试了。结果应该会在屏幕上显示一个“!”

如果打开了8259A芯片并启用了时钟中断,再在IDT中注册了对应的中断处理程序,我们就可以在32位下使用时钟中断了。

posted @ 2020-11-05 12:57  菜鸡mk  阅读(945)  评论(0编辑  收藏  举报