作業系統:保護模式下的中斷和異常
部落格的程式碼均節選自《Orange's一個作業系統的實現》
前置知識:
GDT的結構(最好是自己寫過),真實模式下的中斷相關知識
正篇
在保護模式下,因為種種原因(比如真實模式定址方式的變化之類的),BIOS提供的中斷服務是不可用的。但是中斷還是非常重要的一個概念,以至於以後的任務切換,外設訪問,異常處理都需要依賴於中斷。此時變需要使用者手動編寫一些中斷處理程式。
在正式開始編寫中斷機制前,先要了解一些基礎知識。
異常
當我們程式把0當成除數,或者是程式碼段跳轉的時候特權級(CPL,DPL,RPL)發生了問題,CPU便會丟擲一個異常(exception)來告訴作業系統。
異常的分類有3種,分別是:
- Fault -> 可更正異常,一旦被更正,程式可以不失連續性地執行下去。當fault發生的時候,CPU會把fault之前的狀態儲存起來,異常處理程式的返回地址將會是產生fault的那一條指令而不是其之後的指令。
- Trap -> 當程式發生trap異常之後會被立即報告,和fault不同的是,處理程式返回的是發生trap的下一行指令(也就是說會跳過發生trap異常的指令)。
- 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),我們最關心的就是外部裝置的中斷了。外部裝置的中斷也被分為兩大類:
- 可遮蔽中斷
- 不可遮蔽中斷
不可遮蔽中斷時由 #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
一箇中斷的正常發生包含了兩個部分:
- 從裝置要能夠傳遞到CPU
- CPU要能找到中斷號對應的程式碼
上面對8259晶片的設定解決了第一個問題,現在我們要來解決第二個問題。
中斷描述符表IDT(Interrupt Descriptor Table),是一種用來儲存中斷向量對應的中斷描述符的表。IDT儲存的是中斷向量和處理程式選擇子+偏移的對應,也就是說如果我們在程式內部,從這個角度上來看其實IDT和真實模式下的中斷向量表是一樣的概念。IDT描述符包含以下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位下使用時鐘中斷了。