使用RUST編寫OS(4)-CPU異常處理
學習RUST同時學習OS
簡單介紹
- CPU異常處理信號會在發生錯誤時觸發,並立即依照錯誤類型進行處理
錯誤類型
- Page Fault: 頁錯誤是被非法內存訪問觸發的,例如當前指令試圖訪問未被映射過的頁,或者試圖寫入只讀頁。 Invalid Opcode: 該錯誤是說當前指令操作符無效,比如在不支持SSE的舊式CPU上執行了 SSE 指令。
- General Protection Fault: 該錯誤的原因有很多,主要原因就是權限異常,即試圖使用用戶態代碼執行核心指令,或是修改配置寄存器的保留字段。
- Double Fault: 當錯誤發生時,CPU會嘗試調用錯誤處理函數,但如果 在調用錯誤處理函數過程中 再次發生錯誤,CPU就會觸發該錯誤。另外,如果沒有註冊錯誤處理函數也會觸發該錯誤。
- Triple Fault: 如果CPU調用了對應 Double Fault 異常的處理函數依然沒有成功,該錯誤會被拋出。這是一個致命級別的 三重異常,這意味著我們已經無法捕捉它,對於大多數操作系統而言,此時就應該重置數據並重啓操作系統。
中斷向量
- 中斷和例外是利用一個數字來區分不同的中斷或例外,這個數字稱為「中斷向量」(interrupt vector)。中斷向量是一個由 00H 到 FFH 的數字。其中,00H 到 1FH 的中斷向量是保留作系統用途的,不可任意使用;而其它的中斷向量則可以自由使用
- 保留的中斷向量如下表所示:
向量編號 | 助憶碼 | 說明 | 型態 | 錯誤碼 | 來源 |
---|---|---|---|---|---|
00H | #DE | 除法錯誤 | Fault | 無 | DIV 和 IDIV 指令。 |
01H | #DB | 除錯 | Fault/Trap | 無 | 任何對程式或資料的參考、或是 INT 1 指令。 |
02H | - | NMI 中斷 | Interrupt | 無 | 不可遮罩的外部中斷。 |
03H | #BP | 中斷點 | Trap | 無 | INT3 指令。 |
04H | #OF | 溢出 | Trap | 無 | INTO 指令。 |
05H | #BR | 超出 BOUND 範圍 | Fault | 無 | BOUND 指令。 |
06H | #UD | 非法的指令 | Fault | 無 | UD2 或未定義的指令碼。 |
07H | #NM | 沒有 FPU | Fault | 無 | 浮點運算指令或 WAIT/FWAIT 指令。 |
08H | #DF | 雙重錯誤 | Fault | 有 | 任何會產生例外的指令。 |
09H | - | 保留 | Fault | 無 | 386 以後的處理器不產生此例外。 |
0AH | #TS | 不合法的 TSS | Fault | 有 | 工作切換、或存取 TSS。 |
0BH | #NP | 分段不存在 | Fault | 有 | 載入或存取分段。 |
0CH | #SS | 堆疊分段錯誤 | Fault | 有 | 載入 SS 載存器或存取堆疊。 |
0DH | #GP | 一般性錯誤 | Fault | 有 | 存取記憶體或進行其它保護檢查。 |
0EH | #PF | 分頁錯誤 | Fault | 有 | 存取記憶體。 |
0FH | - | 保留 | |||
10H | #MF | 浮點運算錯誤 | Fault | 無 | 浮點運算指令或 WAIT/FWAIT 指令。 |
11H | #AC | 對齊檢查 | Fault | 有 | 存取記憶體。 |
12H | #MC | 機器檢查 | Abort | 無 | 和機器型號有關。 |
13H~1FH | - | 保留 | |||
20H~FFH | - | 自行使用 |
中斷描述表
- 在捕捉CPU異常時,會需要使用中斷描述表(Interrupt Description Table,IDT)用來捕獲每一個異常,
Type | Name | Description |
---|---|---|
u16 | Function Pointer [0:15] | 處理函數地址的低位(最後16位) |
u16 | GDT selector | 全局描述符表中的代碼段標記。 |
u16 | Options | (如下所述) |
u16 | Function Pointer [16:31] | 處理函數地址的中位(中間16位) |
u32 | Function Pointer [32:63] | 處理函數地址的高位(剩下的所有位) |
u32 | Reserved |
Bits | Name | Description |
---|---|---|
0-2 | Interrupt Stack Table Index | 0: 不要切換stack, 1-7: 当處理函數被調用時,切换到中斷stack表的第n層。 |
3-7 | Reserved | |
8 | 0: Interrupt Gate, 1: Trap Gate | 如果該bit被設置為0,當處理函數被調用時,中斷會被禁用。 |
9-11 | must be one | |
12 | must be zero | |
13‑14 | Descriptor Privilege Level (DPL) | 執行函數處理所需最小權限 |
15 | Present |
- 通常而言,當異常發生時,CPU會執行如下步驟:
- 將一些寄存器數據入棧,包括指令指針以及 RFLAGS 寄存器。
- 讀取中斷描述符表(IDT)的對應條目,比如當發生 page fault 異常時,調用14號條目。
- 判斷該條目確實存在,如果不存在,則觸發 double fault 異常。
- 如果該條目屬於中斷門(interrupt gate,bit 40 被設置為0),則禁用硬件中斷。
- 將 GDT 選擇器載入代碼段寄存器(CS segment)。
- 跳轉執行處理函數。
中斷調用約定
- 調用約定 指定了函數調用的詳細信息,比如可以指定函數的參數存放在哪裡(寄存器,或者棧,或者別的什麼地方)以及如何返回結果。在 x86_64 Linux 中,以下規則適用於C語言函數(指定於 System V ABI 標準):
- 前六個整型參數從寄存器傳入 rdi, rsi, rdx, rcx, r8, r9
- 其他參數從棧傳入
- 函數返回值存放在 rax 和 rdx
保留寄存器
- 保留寄存器應該在函數調用時保持不變,所以被調用的函數(callee)只有在保證 返回之前將這些寄存器的值回復到初始狀態下 才允許複寫這個寄存器的值
- 在函數開始前將這類寄存器的值存放到棧裡面,並在返回之前回覆到寄存器是最常見的作法
臨時寄存器
- 被調用的函數可以無限制的複寫入寄存器,如果操作者希望函數處理後寄存器值不變,需要自行處理備份或恢復(例如:存放棧),因此這類寄存器又被稱caller-saved
x86架構下的寄存器分類
保留寄存器 | 臨時寄存器 |
---|---|
rbp, rbx, rsp, r12, r13, r14, r15 | rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11 |
callee-saved | caller-saved |
保存所有寄存器數據
- 不同於一班函數調用,異常在任何情況都有可能會發生無法預測,因此我們無法預先保留寄存器。所以有了保存所有寄存器的傳統
- 這並不意味著所有寄存器都會在進入函數時備份入棧。編譯器僅會備份被函數覆寫的寄存器,繼而為只使用幾個寄存器的短小函數生成高效的代碼
中斷棧幀
-
常規函數調用發生時(call 指令),CPU會在跳轉到目標函數之前,將返回地址入棧(ret 指令),CPU會在跳回目標函數之前彈出返回地址
-
錯誤處理的方式會不一樣,因為牽扯到上下文關係(棧指針,Cpu flags ...),因此CPU處理如下
- 對齊棧指針: 任何指令都有可能觸發中斷,所以棧指針可能是任何值,而部分CPU指令(比如部分SSE指令)需要棧指針16字節邊界對齊,因此CPU會在中斷觸發後立刻為其進行對齊。
- 切換棧 (部分情況下): 當CPU特權等級改變時,例如當一個用戶態程序觸發CPU異常時,會觸發棧切換。該行為也可能被所謂的中斷棧表配置,在特定中斷中觸發
- 壓入舊的棧指針: 當中斷發生後,棧指針對齊之前,CPU會將棧指針寄存器(rsp)和棧段寄存器(ss)的數據入棧,由此可在中斷處理函數返回後,恢復上一層的棧指針。
- 壓入並更新 RFLAGS 寄存器: RFLAGS 寄存器包含了各式各樣的控制位和狀態位,當中斷發生時,CPU會改變其中的部分數值,並將舊值入棧。
- 壓入指令指針: 在跳轉中斷處理函數之前,CPU會將指令指針寄存器(rip)和代碼段寄存器(cs)的數據入棧,此過程與常規函數調用中返回地址入棧類似。
- 壓入錯誤碼 (針對部分異常): 對於部分特定的異常,比如 page faults ,CPU會推入一個錯誤碼用於標記錯誤的成因。
- 執行中斷處理函數: CPU會讀取對應IDT條目中描述的中斷處理函數對應的地址和段描述符,將兩者載入 rip 和 cs 以開始運行處理函數。
Rust中的x86-interrupt隱欌行為
- 傳遞參數: 絕大多數指定參數的調用約定都是期望通過寄存器取得參數的,但事實上這是無法實現的,因為我們不能在備份寄存器數據之前就將其復寫。x86-interrupt 的解決方案時,將參數以指定的偏移量放到棧上。
- 使用 iretq 返回: 由於中斷棧幀和普通函數調用的棧幀是完全不同的,我們無法通過 ret 指令直接返回,所以此時必須使用 iretq 指令。
- 處理錯誤碼: 部分異常傳入的錯誤碼會讓錯誤處理更加複雜,它會造成棧指針對齊失效,而且需要在返回之前從棧中彈出去。x86-interrupt 為我們擋住了這些額外的複雜度。但是它無法判斷哪個異常對應哪個處理函數,所以它需要從函數參數數量上推斷一些信息,因此程序員需要為每個異常使用正確的函數類型。而x86_64 crate 中的 InterruptDescriptorTable 已經幫助你完成了定義。
- 對齊棧: 對於一些指令(尤其是SSE指令)而言,它們需要提前進行16字節邊界對齊操作,通常而言CPU在異常發生之後就會自動完成這一步。但是部分異常會由於傳入錯誤碼而破壞掉本應完成的對齊操作,此時 x86-interrupt 會為我們重新完成對齊。
RUST實現
初始化中斷
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use x86_64::structures::idt::InterruptDescriptorTable;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
}
添加中斷處理函數
- 首先添加breakpoint exception,該異常會在int3指令執行時暫停程序運行
- breakpoint exception 通常被用在調試器中:當程序員為程序打上斷點,調試器會將對應的位置覆寫為 int3 指令,CPU執行該指令後,就會拋出 breakpoint exception 異常。在調試完畢,需要程序繼續運行時,調試器就會將原指令覆寫回 int3 的位置
// in src/interrupts.rs
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use crate::println;
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
}
extern "x86-interrupt" fn breakpoint_handler(
stack_frame: InterruptStackFrame)
{
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
- 執行發生錯誤是因為:
- 因為x86-interrupt不是穩定特性需要手動啟用,因此需要在 lib.rs 中加入
#![feature(abi_x86_interrupt)]
- 因為x86-interrupt不是穩定特性需要手動啟用,因此需要在 lib.rs 中加入
載入IDT
- 可以使用x86_64中InterruptDescriptorTable結構所提供的load函數實現
// in src/interrupts.rs
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt.load();
}
- 執行再次發生錯誤:
- load函數要求生命週期為
&'static self
,也就是這整個程序的生命週期,因為CPU在接收到下個IDT前會一直使用 這個 描述表,所以生命週期小於'static
時,很有可能會發生使用已釋放對象的問題- 目前idt創建在棧上,生命週期指小於init,之後這部分棧的內存會被其他函數調用,CPU再來訪問的話會獲得隨機數據,因此RUST編譯器會組檔這些淺在問題
- 如果使用
static mut
的話因為有可能造成數據競爭,因此需要使用unsafe,這會是官方並不推存的代碼習慣
- load函數要求生命週期為
使用lazy static避免上述問題
- lazy_static宏可以讓static變量在第一次取值時獲得值,所以不影響後續的取值
- 雖然lazy_static內部依然存在unsafe區塊,但已經抽象為了一個安全的接口
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
idt
};
}
pub fn init_idt() {
IDT.load();
}
執行
- 在lib中封裝一個init,這樣可以把所有初始化邏輯集中在一個函數,從而讓
main
,lib
和單元測試共享初始化邏輯
// in src/lib.rs
pub fn init() {
interrupts::init_idt();
}
在main函數中調用init併手動觸發breakpoint
// in src/main.rs
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::init();
// invoke a breakpoint exception
x86_64::instructions::interrupts::int3();
// as before
#[cfg(test)]
test_main();
println!("It did not crash!");
loop {}
}