iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

打造 OS Kernel:從 OS in 1,000 Lines 到 xv6系列 第 3

Day 03|Privilege Mode:Kernel 為什麼比 Application 有權力?

  • 分享至 

  • xImage
  •  

昨天的加法函式可以讀寫暫存器,也可以使用堆疊,但一般應用程式同樣做得到這些事。
那麼,核心到底多了什麼權力?

答案不在函式名稱裡,把程式取名為 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 $pcbt 確認位置,再解讀數字。
continue 尚未停住,請不要把快照陣列原本的零值當成 CSR 讀取結果。

本實驗預期 $priv0x1,表示 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 執行 disconnectquit,再到 QEMU 視窗按 Ctrl+A 後按 X
若再次啟動時顯示連接埠占用,先結束上一個實驗的 QEMU。

如果 GDB 連不上,先不要動核心

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 是在哪一步準備好的。

參考資料


上一篇
Day 02|RISC-V 101:Kernel 開發前先搞懂 CPU 如何執行程式
下一篇
Day 04|Kernel Boot 深入:從 OpenSBI 逐步追到 kernel_main()
系列文
打造 OS Kernel:從 OS in 1,000 Lines 到 xv613
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言