使用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
嘗試啟動kernel發現陷入崩潰和無限循環,原因如下
- CPU試圖向
0xdeadbeef
寫入數據導致page fault異常 - CPU沒有在IDT找到對應處理函數,拋出double fault異常
- CPU再一次沒有在IDT上找到對應處理函數,拋出triple fault異常
- 由於QEMU面對這個致命級別的異常的處理方式就是重置系統,因此不斷重啟
處理 Double Fault
- 由於double fault是一個帶錯誤碼的常規錯誤,因此可以參考break point定義
- double fault和break point處理函數最大的差別就是double fault的處理函數為發散,因為
x86_64
架構不允許從double fault異常中返回任何東西
Double Faults的成因
- 但究竟什麼叫 調用失敗 ?沒有提供處理函數?處理函數被換出內存了?或者處理函數本身也出現了異常?
- 比如以下情況出現時:
- 如果 breakpoint 異常被觸發,但其對應的處理函數已經被換出內存了?
- 如果 page fault 異常被觸發,但其對應的處理函數已經被換出內存了?
- 如果 divide-by-zero 異常處理函數又觸發了 breakpoint 異常,但 breakpoint 異常處理函數已經被換出內存了?
- 如果我們的內核發生了棧溢出,意外訪問到了 guard page ?
AMD64手冊中的準確定義定義
- double fault異常 會 再執行主要(一層)異常函數時,處發二層異常時才會觸發
舉例來說
- Divide-by-zero異常處理函數觸發page fault的會 不會 調用double fault異常
- 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 Fault | Page Fault, Invalid TSS, Segment Not Present, Stack-Segment Fault, General Protection Fault |
- 那麼根據上表,我們可以回答剛剛的假設中的前三個:
- 如果 breakpoint 異常被觸發,但對應的處理函數被換出了內存,page fault 異常就會被觸發,並調用其對應的異常處理函數。
- 如果 page fault 異常被觸發,但對應的處理函數被換出了內存,那麼 double fault 異常就會被觸發,並調用其對應的處理函數。
- 如果 divide-by-zero 異常處理函數又觸發了 breakpoint 異常,但 breakpoint 異常處理函數已經被換出內存了,那麼被觸發的就是 page fault 異常。
- 在IDT裡找不到對應處理函數而拋出異常的機制
- 異常發生時,CPU會試圖讀取對應的IDT條目
- 如果該條目為無效條目,其值為0
- 觸發general protection fault異常
- 但同樣的沒有該異常的處理函數,因此又一個general protection fault被觸發
- 此時滿組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個確認可用的完好棧指針組成
- 對於每一個錯誤處理函數,都可以通過對應的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(以切換棧),所以不應該再對任何棧做密集型操作,以免污染到棧下方的內存區預
全局描述符表(GDT)
- GDT包含了程序 段訊息 的結構,在舊架構下起到了隔離程序執行環境的作用,而如今已不在64位元模式下運作,但還有兩個功能
- 切換內核空間和用戶空間
- 加載TSS結構
創建GDT
- 依然使用
lazy_static
宏創建TSS和GDT兩個結構
加載GDT
激活GDT
- 由於我們新宣告的GDT並未被激活,且代碼段寄存器和TSS實際上依然引用舊的GDT,並且也要修改double fault對應的IDT條目,使其使用新的棧
- 因此需要完成以下事情
- 重載代碼段寄存器:由於我們修改了GDT因此就需要重載代碼段寄存器(cs),這一步對於修改GDT訊息是必須的,例如覆寫TSS
- 加載TSS:由於加載了包含TSS訊息的GDT,因此需要告訴CPU使用新的TSS
- 更新IDT條目:當TSS加載完畢後,CPU就可以訪問到新的中斷棧表(IST),我們需要通過修改IDT條目告訴CPU使用新的double fault專屬棧
調用gdt::init
函數中的code_selector
和tss_selector
並且打包成Selectors
結構體使用
使用這輛個變量重載cs和TSS
- 通過
set_cs
覆寫了cs,然後使用load_tss
重載tss,這邊使用unsafe是必須的,由於通過這兩個函數加載了無效指針,很可能會破壞掉內存安全性
為IDT中的double fault對應的處理函數設置棧序號
set_stack_index
也是unsafe,因為棧序號的有效性和引用唯一性是需要調用者確保的