使用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除法錯誤FaultDIV 和 IDIV 指令。
01H#DB除錯Fault/Trap任何對程式或資料的參考、或是 INT 1 指令。
02HNMI 中斷Interrupt不可遮罩的外部中斷。
03H#BP中斷點TrapINT3 指令。
04H#OF溢出TrapINTO 指令。
05H#BR超出 BOUND 範圍FaultBOUND 指令。
06H#UD非法的指令FaultUD2 或未定義的指令碼。
07H#NM沒有 FPUFault浮點運算指令或 WAIT/FWAIT 指令。
08H#DF雙重錯誤Fault任何會產生例外的指令。
09H保留Fault386 以後的處理器不產生此例外。
0AH#TS不合法的 TSSFault工作切換、或存取 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)用來捕獲每一個異常,
TypeNameDescription
u16Function Pointer [0:15]處理函數地址的低位(最後16位)
u16GDT selector全局描述符表中的代碼段標記。
u16Options(如下所述)
u16Function Pointer [16:31]處理函數地址的中位(中間16位)
u32Function Pointer [32:63]處理函數地址的高位(剩下的所有位)
u32Reserved
BitsNameDescription
0-2Interrupt Stack Table Index0: 不要切換stack, 1-7: 当處理函數被調用時,切换到中斷stack表的第n層。
3-7Reserved
80: Interrupt Gate, 1: Trap Gate如果該bit被設置為0,當處理函數被調用時,中斷會被禁用。
9-11must be one
12must be zero
13‑14Descriptor Privilege Level (DPL)執行函數處理所需最小權限
15Present
  • 通常而言,當異常發生時,CPU會執行如下步驟:
    1. 將一些寄存器數據入棧,包括指令指針以及 RFLAGS 寄存器。
    2. 讀取中斷描述符表(IDT)的對應條目,比如當發生 page fault 異常時,調用14號條目。
    3. 判斷該條目確實存在,如果不存在,則觸發 double fault 異常。
    4. 如果該條目屬於中斷門(interrupt gate,bit 40 被設置為0),則禁用硬件中斷。
    5. 將 GDT 選擇器載入代碼段寄存器(CS segment)。
    6. 跳轉執行處理函數。

中斷調用約定

  • 調用約定 指定了函數調用的詳細信息,比如可以指定函數的參數存放在哪裡(寄存器,或者棧,或者別的什麼地方)以及如何返回結果。在 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, r15rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11
callee-savedcaller-saved

保存所有寄存器數據

  • 不同於一班函數調用,異常在任何情況都有可能會發生無法預測,因此我們無法預先保留寄存器。所以有了保存所有寄存器的傳統
  • 這並不意味著所有寄存器都會在進入函數時備份入棧。編譯器僅會備份被函數覆寫的寄存器,繼而為只使用幾個寄存器的短小函數生成高效的代碼

中斷棧幀

  • 常規函數調用發生時(call 指令),CPU會在跳轉到目標函數之前,將返回地址入棧(ret 指令),CPU會在跳回目標函數之前彈出返回地址 function stack

  • 錯誤處理的方式會不一樣,因為牽扯到上下文關係(棧指針,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 以開始運行處理函數。 interrupt stack

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)]

載入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,這會是官方並不推存的代碼習慣

使用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 {}
}