使用RUST編寫OS(5)-Double Faults

學習RUST同時學習OS


何謂Double Faults

  • 當CPU執行錯誤處理函數失敗時拋出的特殊異常,比如沒有註冊在IDT上對應page fault的異常處理函數,而程序卻丟出一個page fault異常,這時候就會接著拋出page fault異常,這個異常處理函數就像一程式語言中的cache
  • double fault的行為我們也可以通過IDT註冊8byte的處理函數來攔截
  • double fault處理函數非常重要,如果不處理這個異常,CPU會直接拋出triple fault異常,triple fault無法被任何方式處理,且會導致大多數硬件強制重啟

捕捉Double Faults

嘗試觸發未註冊異常且不處理

  • 使用unsafe操作無效地址0xdeadbeef,由於該虛擬地址並沒有在page上映射物理位置,必然觸發page fault異常,且我們的IDT還沒有對應的處理器,所以就會接著拋出double fault
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");
 
    blog_os::init();
 
    // trigger a page fault
    unsafe {
        *(0xdeadbeef as *mut u8) = 42;
    };
 
    // as before
    #[cfg(test)]
    test_main();
 
    println!("It did not crash!");
    loop {}
}

嘗試啟動kernel發現陷入崩潰和無限循環,原因如下

  1. CPU試圖向0xdeadbeef寫入數據導致page fault異常
  2. CPU沒有在IDT找到對應處理函數,拋出double fault異常
  3. CPU再一次沒有在IDT上找到對應處理函數,拋出triple fault異常
  4. 由於QEMU面對這個致命級別的異常的處理方式就是重置系統,因此不斷重啟

處理 Double Fault

  • 由於double fault是一個帶錯誤碼的常規錯誤,因此可以參考break point定義
  • double fault和break point處理函數最大的差別就是double fault的處理函數為發散,因為x86_64架構不允許從double fault異常中返回任何東西
 lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt.double_fault.set_handler_fn(double_fault_handler); // new
        idt
    };
}
 
// new
extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame, _error_code: u64) -> !
{
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

Double Faults的成因

  • 但究竟什麼叫 調用失敗 ?沒有提供處理函數?處理函數被換出內存了?或者處理函數本身也出現了異常?
  • 比如以下情況出現時:
    1. 如果 breakpoint 異常被觸發,但其對應的處理函數已經被換出內存了?
    2. 如果 page fault 異常被觸發,但其對應的處理函數已經被換出內存了?
    3. 如果 divide-by-zero 異常處理函數又觸發了 breakpoint 異常,但 breakpoint 異常處理函數已經被換出內存了?
    4. 如果我們的內核發生了棧溢出,意外訪問到了 guard page ?

AMD64手冊中的準確定義定義

  • double fault異常 再執行主要(一層)異常函數時,處發二層異常時才會觸發

舉例來說

  1. Divide-by-zero異常處理函數觸發page fault的會 不會 調用double fault異常
  2. Divide-by-zero異常處理函數觸發 general-protection fault就一定 觸發double fault異常
一层异常二层异常
Divide-by-zero,
Invalid TSS,
Segment Not Present,
Stack-Segment Fault,
General Protection Fault
Invalid TSS,
Segment Not Present,
Stack-Segment Fault,
General Protection Fault
Page FaultPage Fault,
Invalid TSS,
Segment Not Present,
Stack-Segment Fault,
General Protection Fault
  • 那麼根據上表,我們可以回答剛剛的假設中的前三個:
    1. 如果 breakpoint 異常被觸發,但對應的處理函數被換出了內存,page fault 異常就會被觸發,並調用其對應的異常處理函數。
    2. 如果 page fault 異常被觸發,但對應的處理函數被換出了內存,那麼 double fault 異常就會被觸發,並調用其對應的處理函數。
    3. 如果 divide-by-zero 異常處理函數又觸發了 breakpoint 異常,但 breakpoint 異常處理函數已經被換出內存了,那麼被觸發的就是 page fault 異常。
  • 在IDT裡找不到對應處理函數而拋出異常的機制
    1. 異常發生時,CPU會試圖讀取對應的IDT條目
    2. 如果該條目為無效條目,其值為0
    3. 觸發general protection fault異常
    4. 但同樣的沒有該異常的處理函數,因此又一個general protection fault被觸發
    5. 此時滿組double fault異常觸發條件(一層異常觸發二層異常),因次double fault也被觸發了

內存棧溢出

  • guard page 是一類位於棧底部的特殊內存page,所以如果發生溢出,最典型的現象就是會訪問此page,而這類內存page不會映射到物理內存中,所以訪問這裏只會造成page fault異常,不會污染其他內存
  • 當page fault發生時,CPU會在IDT中尋找對應處理函數,並嘗試將中斷棧幀入棧,但此時棧指針指向的是一個不存在的guard page,因此第二層的page fault異常就被觸發,此時滿足double fault觸發條件,double fault也被觸發
  • 此時CPU會嘗試調用double fault對應的處理函數,然而CPU依然會試圖將錯誤棧入棧,由於棧指針依然指向guard,因次第次的page fault發生,最終導致triple fault異常拋出,系統重啟

切換棧解決

  • x86_64架構允許在異常發生時,將棧切會為一個預先定義的完好棧,因為切換是執行在硬件層次,因此會在CPU將異常棧幀入棧前執行
  • 切換機制由中斷棧表(IST)實現,IST為一個由7個確認可用的完好棧指針組成
 struct InterruptStackTable {
    stack_pointers: [Option<StackPointer>; 7],
}
  • 對於每一個錯誤處理函數,都可以通過對應的IDT條目中的 stack_pointers 條目指定IST中的一個棧
  • 比如我們可以讓double fault對應的處理函數使用IST中的第一個棧指針,則CPU會在異常發生時,自動將棧切換為該棧,由於切換行為會在所有入棧操作前執行,因此可以避免觸發triple fault異常

IST和TSS

  • 中斷棧表(IST)其實是任務狀態段(TSS)的一部分,在x86_64的架構下,TSS已經不再像以往儲存任何任務相關訊息,取二代之的是兩個棧表(IST為其中一個,另一個和IO有關)

創建TSS

  • 新增gdt模塊,並且使用x86_64中crate自帶的TaskStateSegment結構來映射TSS結構體
    • 這邊取名為gdt的原因是TSS使用到了分段系統,但我們可以在全局描述符表(GDT)添加一段描述符,接著可以通過ltr指令加上gdt序號加載TSS
  • 使用lazy_static是因為rust的靜態變量求值器(static ref)無法在編譯器執行初始化代碼
  • 並且將IST的0號位定義為double fault的專屬棧(其他IST序號也可以)
  • 接著將棧的高地址指針(usize)寫入0號位,之所以要這樣是因為x86_64的內存分配是由高往低地址分配
  • 由於還沒實現內存管理機制,因此使用static mut形式的數組模擬棧儲存區,宣告為可改寫的原因是為了壁面被bootloader分配到只讀頁面,當然unsafe也是必須的,因為編譯器認為這種可以被競爭的變量並不安全

注意事項

  • 由於double fault獲取的棧不再具有防止棧溢出的guard page(以切換棧),所以不應該再對任何棧做密集型操作,以免污染到棧下方的內存區預
// in src/lib.rs
 
pub mod gdt;
 
// in src/gdt.rs
 
use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;
 
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
 
lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5;
            static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
 
            let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
            let stack_end = stack_start + STACK_SIZE;
            stack_end
        };
        tss
    };
}

全局描述符表(GDT)

  • GDT包含了程序 段訊息 的結構,在舊架構下起到了隔離程序執行環境的作用,而如今已不在64位元模式下運作,但還有兩個功能
    1. 切換內核空間和用戶空間
    2. 加載TSS結構

創建GDT

  • 依然使用lazy_static宏創建TSS和GDT兩個結構
// in src/gdt.rs
 
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor};
 
lazy_static! {
    static ref GDT: GlobalDescriptorTable = {
        let mut gdt = GlobalDescriptorTable::new();
        gdt.add_entry(Descriptor::kernel_code_segment());
        gdt.add_entry(Descriptor::tss_segment(&TSS));
        gdt
    };
}

加載GDT

// in src/gdt.rs
 
pub fn init() {
    GDT.load();
}
 
// in src/lib.rs
 
pub fn init() {
    gdt::init();
    interrupts::init_idt();
}

激活GDT

  • 由於我們新宣告的GDT並未被激活,且代碼段寄存器和TSS實際上依然引用舊的GDT,並且也要修改double fault對應的IDT條目,使其使用新的棧
  • 因此需要完成以下事情
    1. 重載代碼段寄存器:由於我們修改了GDT因此就需要重載代碼段寄存器(cs),這一步對於修改GDT訊息是必須的,例如覆寫TSS
    2. 加載TSS:由於加載了包含TSS訊息的GDT,因此需要告訴CPU使用新的TSS
    3. 更新IDT條目:當TSS加載完畢後,CPU就可以訪問到新的中斷棧表(IST),我們需要通過修改IDT條目告訴CPU使用新的double fault專屬棧

調用gdt::init函數中的code_selectortss_selector並且打包成Selectors結構體使用

// in src/gdt.rs
 
use x86_64::structures::gdt::SegmentSelector;
 
lazy_static! {
    static ref GDT: (GlobalDescriptorTable, Selectors) = {
        let mut gdt = GlobalDescriptorTable::new();
        let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
        let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
        (gdt, Selectors { code_selector, tss_selector })
    };
}
 
struct Selectors {
    code_selector: SegmentSelector,
    tss_selector: SegmentSelector,
} 

使用這輛個變量重載cs和TSS

  • 通過set_cs覆寫了cs,然後使用load_tss重載tss,這邊使用unsafe是必須的,由於通過這兩個函數加載了無效指針,很可能會破壞掉內存安全性
// in src/gdt.rs
 
pub fn init() {
    use x86_64::instructions::tables::load_tss;
    use x86_64::instructions::segmentation::{CS, Segment};
    
    GDT.0.load();
    unsafe {
        CS::set_reg(GDT.1.code_selector);
        load_tss(GDT.1.tss_selector);
    }
}

為IDT中的double fault對應的處理函數設置棧序號

  • set_stack_index也是unsafe,因為棧序號的有效性和引用唯一性是需要調用者確保的
// in src/interrupts.rs
 
use crate::gdt;
 
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        unsafe {
            idt.double_fault.set_handler_fn(double_fault_handler)
                .set_stack_index(gdt::DOUBLE_FAULT_IST_INDEX); // new
        }
 
        idt
    };
}