昨天的加法函式可以讀寫暫存器,也可以使用堆疊,但一般應用程式同樣做得到這些事。
那麼,核心到底多了什麼權力?
答案不在函式名稱裡,把程式取名為 kernel_main() 並不會取得特殊待遇。
CPU 另外保存了權限模式(Privilege Mode),依這個狀態判斷某些操作能不能執行。
這篇先不故意讓程式出錯,而是停在一個確定的位置,檢查當時的權限與控制設定。
OpenSBI 的 Domain0 Next Mode : S-mode 是線索,但它說的是韌體準備怎麼交接。
我們還要看 CPU 進入核心後的狀態,才不會把開機訊息和當下執行情況混在一起。
《Operating System Concepts》第 10 版第 1.4.2 節,解釋作業系統如何藉由雙模式與多模式執行保護自己。
如果一般應用程式能任意修改中斷設定或其他程式的記憶體,即使作業系統訂出規則,也沒有可靠的執行方式。
硬體因此區分不同權限,限制某些指令或資源只能由適當模式存取。
程式違反限制時,會觸發例外,讓具有處理權限的軟體介入。
今天把這個概念對應到 RISC-V 的控制與狀態暫存器(Control and Status Registers,CSR)。
CSR 和 Day 02 用來算加法的 a0/a1 不同,它們用來控制或回報 CPU 的部分運作狀態。
例如核心將來需要告訴 CPU「例外發生時跳到哪裡」,就不能只寫一個普通 C 變數,還需要設定 CPU 會使用的控制暫存器。
不過 CSR 並非全部都只能由同一種模式存取,具體限制要看該 CSR 與相關控制設定。
本系列的單核心 QEMU 環境,使用以下分工:
| 模式 | 目前執行的軟體 | 本系列中的角色 |
|---|---|---|
| M-mode,Machine Mode | OpenSBI | 韌體與早期平台初始化 |
| S-mode,Supervisor Mode | Tiny Kernel | 核心服務與系統資源管理 |
| U-mode,User Mode | 尚未建立 | 後續的使用者程式 |
OpenSBI 的 SBI 是 Supervisor Binary Interface,提供 S-mode 軟體呼叫韌體服務的介面。
到了 Day 05,核心會用它輸出字元。
今天先觀察 S-mode 可讀取的狀態,不開啟中斷,也不切換到 U-mode。
M-mode 也不等於你在 Ubuntu 使用的 sudo。
sudo 改變的是主機作業系統授予行程的權限,這裡談的是 QEMU 所模擬 RISC-V CPU 的執行模式。
一般使用者啟動 QEMU,裡面的客體仍然可以執行 M-mode 韌體,兩者是不同層次。
所有操作接續 Day 02,在 30-days-os-kernel/tiny-kernel/ 進行。
新增 csr.h:
#pragma once
#define READ_CSR(name) ({ \
unsigned int value; \
__asm__ __volatile__("csrr %0, " #name : "=r"(value)); \
value; \
})
#define WRITE_CSR(name, value) do { \
unsigned int csr_value = (unsigned int) (value); \
__asm__ __volatile__("csrw " #name ", %0" \
: : "r"(csr_value) : "memory"); \
} while (0)
這裡使用 Clang 支援的 GNU C 擴充,#name 會把 sstatus 等名稱轉成組語中的文字。READ_CSR(sstatus) 回傳的是 CPU 狀態值,和讀一般 RAM 變數不同。
先把寫入工具一起備妥,等 Day 07 登錄陷阱處理入口時才使用。
讀取巨集最後一行的 value; 會成為整個 GNU statement expression 的結果,因此能放在等號右邊。
這不是純 ISO C 的一般區塊語法,使用本系列的 Clang 設定可以編譯,換成嚴格禁止擴充的工具設定時需要另外改寫。
WRITE_CSR 裡的 memory 是編譯器層面的限制,不要把它當成自動執行硬體記憶體屏障。
在 kernel.c 最上方加入 #include "csr.h",並在 kernel_main() 前加入:
volatile uint32_t csr_snapshot[3];
__attribute__((noinline))
void privilege_checkpoint(void) {
__asm__ __volatile__("nop");
}
把 kernel_main() 換成以下內容,其他既有函式與 Day 02 宣告保留:
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
probe_result = asm_probe(17, 25);
csr_snapshot[0] = READ_CSR(sstatus);
csr_snapshot[1] = READ_CSR(sie);
csr_snapshot[2] = READ_CSR(satp);
privilege_checkpoint();
for (;;)
__asm__ __volatile__("nop");
}
三個項目分別是監督者狀態、監督者中斷啟用設定,以及位址轉譯控制。satp 的全名是 Supervisor Address Translation and Protection,後續分頁實驗會再次使用它。
| 陣列位置 | 讀取來源 | 這次先回答的問題 |
|---|---|---|
[0] |
sstatus |
Supervisor 狀態有哪些位元? |
[1] |
sie |
哪些 Supervisor 中斷來源被啟用? |
[2] |
satp |
是否已啟用位址轉譯? |
快照是三次依序讀取的結果,不是硬體保證同一瞬間完成的原子擷取。
目前我們沒有主動改變這些設定,足以用來做這一階段的基本檢查,後面有中斷與多核心時就不能沿用相同假設。
privilege_checkpoint() 裡的 nop 幾乎沒有功能,真正用途是提供一個有名稱的停點。noinline 要求編譯器保留函式呼叫,不要直接把它揉進呼叫端,這樣 GDB 比較容易停在快照寫完之後。
為了讓修改標頭後也能重新編譯,在 Makefile 將核心目標的相依檔案更新為:
$(KERNEL): $(SOURCES) $(wildcard *.h) kernel.ld | $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ $(SOURCES)
在 Ubuntu 主機安裝支援跨架構除錯的 GDB:
sudo apt install -y gdb-multiarch
在 tiny-kernel/ 的終端機 A 編譯並啟動:
make
make firmware
qemu-system-riscv32 -machine virt -smp 1 -m 128M \
-bios opensbi-riscv32-generic-fw_dynamic.bin \
-kernel build/kernel.elf -nographic -serial mon:stdio \
-no-reboot -S -gdb tcp:127.0.0.1:1234
-S 讓 CPU 在開始執行前暫停,GDB 連線後再由我們放行。
QEMU 的除錯連接埠限定在本機 127.0.0.1,第二個終端機不用登入虛擬機。
此時終端機 A 沒有繼續印出開機內容是正常的,因為 CPU 還在等待放行。-S 和 -gdb 是兩件事:一個決定先暫停,一個提供除錯連線,只有開啟連線不保證能趕在核心執行前設好停點。
在同一目錄的終端機 B 執行:
gdb-multiarch -nx build/kernel.elf
接著在 GDB 輸入:
target remote 127.0.0.1:1234
hbreak privilege_checkpoint
continue
x/3wx &csr_snapshot
p/x $priv
-nx 不載入個人初始化檔,避免其他專案留下的設定影響這次操作。target remote 接上正在等待的 QEMU,hbreak 設定硬體型態的停點,continue 才讓 CPU 從啟動流程繼續往下走。
第一個連線位置不一定在我們的核心,因為 OpenSBI 還要先執行,不用看到韌體位址就認為 ELF 載入錯誤。
停止後先用 info symbol $pc 或 bt 確認位置,再解讀數字。
若 continue 尚未停住,請不要把快照陣列原本的零值當成 CSR 讀取結果。
本實驗預期 $priv 為 0x1,表示 QEMU 目前回報 Supervisor Mode。$priv 是除錯介面提供的模式資訊,不是 C 程式可以用 READ_CSR(priv) 讀取的標準 CSR。
若工具版本沒有提供它,可以查看 QEMU monitor 的 info registers 是否包含模式欄位,同時保持 GDB 停在檢查點。
若兩邊都沒有顯示,就記錄「本工具版本未直接取得目前模式」,不要只靠 sstatus 的某一個位元補猜答案。
satp 預期為零,代表目前使用 Bare 模式,尚未啟用分頁位址轉譯。
不要把整個 sstatus 寫死成某個數字,其他位元會受 CPU 與韌體設定影響。
x/3wx &csr_snapshot 會讀出三個四-byte word,依序對應剛才表格的三列。
它讀的是核心已保存的 RAM 資料,與 p/x $priv 直接詢問除錯介面的模式資訊不同。
分清楚「程式自己記下的值」和「除錯器當下看到的值」,後面分析 Trap 才不容易混淆時間點。
讀得到 sstatus,只代表目前權限允許該次存取,M-mode 也能讀它。
所以我們同時檢查停住的位置、OpenSBI 的交接資訊,以及 QEMU 提供的目前模式。
另一個容易誤讀的是 sstatus.SPP。
它記錄的是進入 S-mode trap 前的權限狀態,供返回時使用,不是「現在模式」的查詢欄位。
剛開機就看 SPP 判斷自己位於 U-mode 或 S-mode,會把不同時間點的狀態混在一起。
sie 也不能單獨回答「中斷現在一定會進來嗎」。
它控制各來源的啟用,是否真正接收中斷還與全域啟用、待處理事件及委派等條件有關。
這篇只保存設定,不把某個 bit 為 1 解讀成已經處理過一次裝置中斷。
同樣地,satp 為零表示這次沒有啟用分頁轉譯,不代表 CPU 沒有權限機制,也不代表記憶體存取永遠不可能失敗。
權限模式、實體記憶體保護與分頁是相關但不同的機制,後面會分開建立。
先不要在這篇加入讀取 mstatus 的實驗。
它需要 M-mode 權限,而核心還沒有自己的例外處理入口,現在故意違規只會讓觀察失去落點。
Day 07 設好 handler 後,我們會讓這次違規留下明確的錯誤資訊。
結束時在 GDB 執行 disconnect 與 quit,再到 QEMU 視窗按 Ctrl+A 後按 X。
若再次啟動時顯示連接埠占用,先結束上一個實驗的 QEMU。
Connection refused 通常先檢查 QEMU 是否仍在執行、兩邊 port 是否相同,以及兩個終端機是否真的在同一台主機或 VM 裡。127.0.0.1 指向各自的本機,Windows 的終端機與 Ubuntu VM 內的終端機不一定共用同一個 loopback。
最簡單的做法是讓 QEMU 與 GDB 都在同一個開發環境中執行。
若顯示找不到 privilege_checkpoint 符號,則檢查 ELF 是否重新編譯、GDB 開啟的路徑是否正確,以及有沒有保留函式名稱與除錯資訊。
連不上和停錯位置是兩種問題,先分清楚,才不會把時間花在無關的 CSR 設定上。
這次留下 csr.h、更新後的 kernel.c 與 Makefile,以及一個能重複停住的 GDB 檢查點。
值得保存的不只是一張 CSR 截圖,還包括當時停在哪個函式、使用哪份 ELF,以及模式資訊是從哪個工具讀到的。
建議 commit 訊息為:
day03: inspect privilege mode
Day 04 會沿著這套除錯方式,把 CPU 從核心進入點走到 C 的過程逐步停下來,確認堆疊與 .bss 是在哪一步準備好的。