今天要刻意讓核心印出 PANIC。
預期的結束畫面會包含錯誤原因與呼叫位置,而且後面的 unreachable 永遠不該出現。
這個實驗讓核心有一條明確的失敗出口。
之後配置器發現重複釋放、Trap handler 遇到不認識的例外,都可以先留下資訊,再停在可除錯的位置。
《Operating System Concepts》第 10 版第 2.10.1 節討論失敗分析(Failure Analysis),包括記錄錯誤資訊與保留執行狀態。
核心出錯時,平常依賴的服務可能也已經受損,例如檔案系統本身失敗,就不能輕易假設還能正常寫入紀錄檔。
我們目前沒有檔案系統,因此先採用已有的序列埠輸出,並讓 QEMU 保留現場供 GDB 連線。
這只是最小的錯誤診斷路徑,還沒有磁碟 crash dump、堆疊損壞復原或多核心停機協調。
《OS in 1,000 Lines》的〈核心恐慌〉把檔案與行號加入 Panic 訊息。
我們沿用這個想法,另外用 assertion 檢查核心本來就應該成立的條件。
接續 Day 05,以下建檔與編譯都在 30-days-os-kernel/tiny-kernel/。
在 kernel.h 末尾加入:
__attribute__((noreturn))
void panic_at(const char *file, unsigned int line, const char *message);
#define PANIC(message) panic_at(__FILE__, __LINE__, (message))
#define KASSERT(condition) do { \
if (!(condition)) \
PANIC("assertion failed: " #condition); \
} while (0)
__FILE__ 與 __LINE__ 由前置處理器填入,因此對應的是呼叫巨集的位置。#condition 則把判斷式變成可讀的文字,省去每次另外撰寫錯誤訊息。
noreturn 告訴編譯器,這個函式不會把控制權交回呼叫端。
這項宣告必須和實作一致,不能標了 noreturn 卻仍然執行普通的 return。
新增 panic.c:
#include "kernel.h"
__attribute__((noreturn, noinline))
void panic_at(const char *file, unsigned int line, const char *message) {
__asm__ __volatile__("csrci sstatus, 2" : : : "memory");
printf("\nPANIC %s:%u: %s\n", file, line, message);
for (;;)
__asm__ __volatile__("wfi");
}
sstatus 的 SIE 位元是 bit 1,清除它會禁止目前 hart 接受一般可遮罩的 S-mode 中斷。
它不會阻止所有例外,也不是讓整台電腦的所有 CPU 都停住。
本系列目前固定使用單一 hart,也就是一個 RISC-V 硬體執行緒。
noinline 讓函式保持獨立,方便 GDB 直接對 panic_at 設斷點。
Panic 仍依賴有效的堆疊與 Console,這個階段先用受控的條件失敗來驗證它。
Makefile 的來源清單更新為:
SOURCES := kernel.c asm_probe.S boot.S console.c panic.c
將 kernel_main() 取代為:
void kernel_main(void) {
probe_result = asm_probe(17, 25);
KASSERT(probe_result == 42);
printf("normal check passed\n");
PANIC("day06 deliberate stop");
printf("unreachable\n");
}
在 tiny-kernel/ 執行:
make
make run
預期核心輸出先是 normal check passed,接著才出現:
PANIC kernel.c:行號: day06 deliberate stop
實際行號取決於檔案內容,應指向 PANIC(...) 那行,而不是 panic.c 內部的列印指令。unreachable 不應出現,模擬器也不會因為這個迴圈自行關閉。
結束普通的 QEMU 執行後,依 Day 03 的指令加入 -S -gdb tcp:127.0.0.1:1234 重新啟動。
第二個終端機仍在 tiny-kernel/,執行 gdb-multiarch build/kernel.elf,再輸入:
target remote 127.0.0.1:1234
hbreak *panic_at
continue
info registers pc sp ra a0 a1 a2
x/s $a0
p/u $a1
x/s $a2
RV32 的這三個參數分別透過 a0、a1 與 a2 傳入,所以這個斷點能直接看見檔名、行號與訊息。
使用 *panic_at 是為了在函式第一條指令前觀察參數,避免函式執行後暫存器已被拿去做其他用途。
可以額外執行 bt 查看回溯,但編譯最佳化可能省略部分 frame 或把呼叫轉成尾端呼叫。
若回溯資訊不完整,先以斷點、參數與反組譯為準,不要把每次 bt 的行數當成固定驗收結果。
結束這次 QEMU 後,把條件暫時改成 KASSERT(probe_result == 43),重新編譯執行。
這次應該在印出 normal check passed 之前停止,原因包含:
assertion failed: probe_result == 43
這兩種失敗對照的差別,是錯誤由哪個條件觸發,以及哪一段程式來得及執行。
完成後把條件恢復為 42,並移除故意觸發的 PANIC 與 unreachable,以等待迴圈結尾:
void kernel_main(void) {
probe_result = asm_probe(17, 25);
KASSERT(probe_result == 42);
printf("normal check passed\n");
for (;;)
__asm__ __volatile__("wfi");
}
這是留給 Day 07 接續的正常版本。
後面出現 Panic 時,我們才能把它歸因於新加入的測試,而非今天忘記移除的故意停止。
今天新增 panic.c,修改 kernel.h、kernel.c 與 Makefile,建議 commit 訊息為:
day06: add panic and gdb debugging
Day 07 要處理的失敗不再由 C 主動呼叫 PANIC,而是 CPU 因為一條違反權限的指令,主動跳進我們登錄的入口。