1. 程式人生 > 實用技巧 >作業系統:保護模式下的中斷和異常

作業系統:保護模式下的中斷和異常

部落格的程式碼均節選自《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的大概樣子:

整個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的組成:

下面是用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開始連續升序的,所以沒必要在描述符中包含中斷號。

中斷門/陷阱門描述符結構如下圖:

其實中斷門和陷阱門還是有區別的。通過中斷門進行中斷呼叫的時候會對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位下使用時鐘中斷了。