平常寫 C 程式時,我們習慣從 main() 開始,也習慣直接使用 printf(),但在 main() 執行以前,其實已經有其他程式幫我們載入可執行檔、準備堆疊,並把輸出通道接好。
今天的情況反了過來,因為我們要寫的正是作業系統核心。
沒有現成的 OS 替 Kernel 準備執行環境,我們必須自己回答幾個很基本的問題:CPU 從哪個位址開始執行?堆疊要放在哪裡?C 函式為什麼能安全地被呼叫?
Day 01 不追求做出功能完整的 OS,只完成一條最小啟動路徑,讓 OpenSBI 把 CPU 交給 boot(),設定 Stack 後進入 kernel_main()。
這個 Kernel 還不會印出 Hello World,所以當畫面停在 OpenSBI 資訊時,我們不會把「沒有動靜」當成成功證明,而是從 ELF 符號、反組譯與 CPU 暫存器找到可以相互印證的結果。
實驗完成時,我們要拿出三組證據:
kernel.elf 是 RV32 可執行檔,進入點為 0x80200000
0x80200000 進入 Supervisor Modepc 落在 kernel_main() 迴圈,sp 則指向我們配置的 Stack 頂端三者能夠對上,才能說明 CPU 真的走過這條路徑:
QEMU 虛擬機
↓
OpenSBI 完成早期初始化
↓ 進入 S-mode,跳到 0x80200000
boot()
↓ 設定 sp
kernel_main()
↓ 清除 .bss
for (;;)
pc 是程式計數器(Program Counter),記錄 CPU 目前執行的指令位址。sp 是堆疊指標(Stack Pointer),而 __stack_top 是我們將在連結器腳本中定義的位址。
現在不用先背起這些名字,後面會把每個數值逐一對回程式碼。
俗稱「恐龍書」的《Operating System Concepts》第 10 版,從系統角度把作業系統描述成資源配置者(Resource Allocator)與控制程式(Control Program)。
資源配置者處理「誰可以用」:當瀏覽器與編輯器同時執行,CPU 時間、記憶體與 I/O 裝置都不能由各程式自行搶奪,必須有一個共同的管理者進行分配。
控制程式處理「能夠做什麼」:一般應用程式不應隨意改寫其他程式的記憶體,也不應直接控制整部機器的中斷狀態,作業系統必須建立邊界,並在程式需要服務時提供受控的入口。
後面 30 天的程式碼會逐步對應這兩個角色:
Page Allocator/Scheduler
→ 分配記憶體與 CPU 時間
Privilege Mode/Trap/System Call
→ 限制操作並提供受控入口
今天的程式還做不到這些事,它甚至沒有行程、檔案與系統呼叫,稱作「Kernel 的起點」會比「完整作業系統」更準確,不過只有先接到 CPU 的控制權,後面才有機會實作這些責任。
恐龍書以使用者模式(User Mode)與核心模式(Kernel Mode)說明硬體保護邊界。
在 RISC-V 中,我們會實際遇到三種權限層級:
| Mode | 中文 | 本系列中的角色 |
|---|---|---|
| M-mode | 機器模式 | OpenSBI 韌體進行早期初始化 |
| S-mode | 監督者模式 | 我們的 Kernel 在這裡執行 |
| U-mode | 使用者模式 | Day 16~Day 17 才會加入的應用程式 |
把函式命名為 kernel_main() 不會自動取得高權限,必須先由韌體完成權限切換,CPU 才會在 S-mode 執行我們的程式碼。
本系列前 18 天使用 32-bit RISC-V(RV32)與 QEMU virt 平台,這也是《OS in 1,000 Lines》的實作環境。
QEMU 模擬一台 RISC-V 機器,但它不是我們的作業系統。
OpenSBI 是先於 Kernel 執行的韌體,負責機器階段的初始化,並透過 Supervisor Binary Interface(SBI)向 S-mode 軟體提供服務。
我們需要驗證的對象,是接在 OpenSBI 後面執行的 kernel.elf。
Day 19 會轉向 MIT 的 xv6-riscv,xv6 使用 RV64 與 Sv39 Page Table,因此到時會重新交代位址寬度、ABI 與分頁架構的差異,不會直接把今天的 RV32 數值套過去。
以下步驟在 Ubuntu 24.04.4 LTS 進行。
我們需要 Clang、LLVM、LLD、QEMU 與 Make:
sudo apt update
sudo apt install -y clang llvm lld qemu-system-misc curl make
Ubuntu 的 qemu-system-riscv32 由 qemu-system-misc 套件提供,套件名稱與執行指令不同,安裝後仍然使用 qemu-system-riscv32。
先確認 Clang 知道如何產生 RV32 程式:
clang -print-targets | grep riscv32
預期能找到:
riscv32 - 32-bit RISC-V
再記錄實驗使用的版本:
clang --version | head -n 1
ld.lld --version | head -n 1
llvm-readelf --version | head -n 1
llvm-objdump --version | head -n 1
qemu-system-riscv32 --version | head -n 1
Clang 處理 C 原始碼,LLD 依照連結器腳本安排位址。llvm-readelf 與 llvm-objdump 負責觀察編譯產物,QEMU 則執行它。
即使開發主機是 x86-64,Clang 仍能產生 RV32 機器碼,這就是交叉編譯。
版本不用和本文逐字相同,但若出現 command not found,應先處理工具環境,繼續修改 Kernel 原始碼不會解決這類錯誤。
我們從準備放置系列的工作目錄開始:
mkdir -p 30-days-os-kernel/tiny-kernel
cd 30-days-os-kernel/tiny-kernel
後面的建檔、編譯與 QEMU 指令都在 tiny-kernel/ 內執行。
完成後會有四個主要檔案:
tiny-kernel/
├── .gitignore
├── Makefile
├── kernel.c
└── kernel.ld
kernel.ld 決定各區段的位址,kernel.c 實作進入點與核心主程式,Makefile 則固定編譯、檢查與啟動指令。
kernel.ld 安排位址建立 kernel.ld:
OUTPUT_ARCH(riscv)
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text : {
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
. = ALIGN(16);
. += 128 * 1024;
__stack_top = .;
}
連結器腳本(Linker Script)不負責執行程式,它負責把函式與資料安排到可執行檔的指定位址。
這份腳本有幾個關鍵決定:
| 設定 | 作用 |
|---|---|
OUTPUT_ARCH(riscv) |
將輸出架構標示為 RISC-V |
ENTRY(boot) |
將 ELF 進入點設為 boot |
. = 0x80200000 |
從 OpenSBI 交接 Kernel 的位址開始配置 |
KEEP(*(.text.boot)) |
把 boot() 所在區段保留在程式碼最前面 |
__bss 與 __bss_end |
記錄需要清零的區間 |
__stack_top |
記錄 128 KiB Stack 的頂端 |
SECTIONS 裡單獨出現的 . 是目前配置位址,不是省略號,當連結器放入程式碼或資料,這個位置會隨著內容往後移動。
四種區段可以先用用途區分:
| 區段 | 內容 |
|---|---|
.text |
CPU 執行的機器碼 |
.rodata |
只讀常數 |
.data |
已有初始值的可寫資料 |
.bss |
執行前應為零的可寫資料 |
.bss 的零值不需要在 ELF 內重複存放,但 Kernel 執行前仍要確保對應記憶體已清成零,這就是稍後 kernel_main() 會呼叫 memset() 的原因。
Stack 使用時朝較低位址成長,因此初始 sp 要放在預留區間的高位址端,ALIGN(16) 則讓這個位址符合 RV32 ILP32 ABI 的 16-byte Stack 對齊要求。
boot() 進入 C 程式建立 kernel.c:
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;
extern char __bss[], __bss_end[], __stack_top[];
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
for (;;)
;
}
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\n"
"j kernel_main\n"
:
: [stack_top] "r" (__stack_top)
);
}
這份程式不是從 kernel_main() 自動開始,CPU 會先執行連結器腳本指定的 boot()。
section(".text.boot") 把 boot() 放入特別區段,而 kernel.ld 又把這個區段排在 0x80200000 最前面,
這兩邊必須一起看,只寫 ENTRY(boot) 並不足以改變 OpenSBI 實際跳入的機器碼位置。
naked 告訴編譯器不要自動生成函式開頭與結尾的 Stack 操作,因為剛進入 boot() 時,我們還沒有把 sp 設定為自己的 Stack。
內嵌組合語言只做兩件事:
mv sp, __stack_top
→ 讓 sp 指向 Stack 頂端
j kernel_main
→ 跳入 C 函式,不預期返回
這裡使用 j 直接跳入 kernel_main(),因為 Kernel 啟動後沒有上層應用程式可以回去。kernel_main() 最後留在無限迴圈,方便我們在固定位置觀察 CPU,但這個迴圈仍持續消耗模擬 CPU 時間,並不是省電或等待中斷的實作。
__bss、__bss_end 與 __stack_top 不是 C 檔案內的變數,它們由 kernel.ld 建立,
宣告為 extern char ...[] 的用意是取得符號位址,不是要讀取一個字元。
memset() 也由我們自己提供,因為這個 freestanding 程式不會連結一般應用程式使用的 C 標準函式庫,這只是配合目前需求的最小實作,不等於已經完成 libc。
《OS in 1,000 Lines》的〈核心啟動〉使用 run.sh 編譯並啟動 Kernel,
本系列則將同一組 Clang 與 QEMU 指令拆成 Makefile target,方便在不重複編譯的情況下分別檢查 ELF、符號與反組譯,
這只是建置流程的整理,並沒有改用另一種 Kernel 實作。
建立 Makefile:
CC := clang
READELF := llvm-readelf
OBJDUMP := llvm-objdump
NM := llvm-nm
QEMU := qemu-system-riscv32
BUILD_DIR := build
KERNEL := $(BUILD_DIR)/kernel.elf
MAP := $(BUILD_DIR)/kernel.map
FIRMWARE := opensbi-riscv32-generic-fw_dynamic.bin
FIRMWARE_URL := https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin
CFLAGS := \
-std=c11 \
-O2 \
-g3 \
-Wall \
-Wextra \
--target=riscv32-unknown-elf \
-march=rv32imac \
-mabi=ilp32 \
-fuse-ld=lld \
-fno-stack-protector \
-ffreestanding \
-nostdlib \
-Wl,-T,kernel.ld \
-Wl,-Map,$(MAP)
.PHONY: all check disasm symbols firmware run clean
all: $(KERNEL)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(KERNEL): kernel.c kernel.ld | $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ kernel.c
check: $(KERNEL)
file $(KERNEL)
$(READELF) -h $(KERNEL)
disasm: $(KERNEL)
$(OBJDUMP) -d $(KERNEL)
symbols: $(KERNEL)
$(NM) -n $(KERNEL)
firmware: $(FIRMWARE)
$(FIRMWARE):
curl -L --fail --output $@ $(FIRMWARE_URL)
run: $(KERNEL) $(FIRMWARE)
$(QEMU) \
-machine virt \
-bios $(FIRMWARE) \
-nographic \
-serial mon:stdio \
--no-reboot \
-kernel $(KERNEL)
clean:
rm -rf $(BUILD_DIR)
Makefile 中真正執行指令的行首必須是 Tab,若 Make 回報 missing separator,先檢查縮排,這通常與 kernel.c 無關。
幾個編譯選項決定了這個程式的基本條件:
| 選項 | 原因 |
|---|---|
--target=riscv32-unknown-elf |
生成 32-bit RISC-V ELF |
-march=rv32imac |
指定本系列使用的 RV32 指令集擴充 |
-mabi=ilp32 |
明確指定 RV32 ABI |
-ffreestanding |
告訴編譯器這不是一般主機應用程式 |
-nostdlib |
不自動加入標準啟動檔與函式庫 |
-fno-stack-protector |
避免產生目前尚未提供的 Stack Protector 支援 |
-Wl,-T,kernel.ld |
把連結器腳本交給 LLD |
-Wl,-Map,$(MAP) |
保存連結後的位址配置 |
-g3 保留除錯資訊,-O2 則允許編譯器進行最佳化,因此 C 原始碼與反組譯不會永遠一行對一行,後面會以實際產物為準。
實驗中會使用這幾個 target:
make:建立 build/kernel.elf
make check:查看 ELF 類型與進入點make symbols:列出符號位址make disasm:反組譯 Kernelmake run:用 OpenSBI 與 QEMU 啟動 Kernel我們目前在 tiny-kernel/ 目錄。
先重新建立 Kernel:
make clean
make
make check
建置完成後,make check 應顯示以下關鍵資訊:
build/kernel.elf: ELF 32-bit LSB executable, UCB RISC-V, RVC,
soft-float ABI, version 1 (SYSV), statically linked,
with debug_info, not stripped
Class: ELF32
Machine: RISC-V
Entry point address: 0x80200000
這份輸出證明我們建立了 RISC-V ELF,並且它宣告的進入點是 0x80200000,但還不能證明 CPU 真的執行過這份檔案。
接著查看符號:
make symbols
實際符號表如下:
80200000 T boot
8020000e T memset
80200024 T kernel_main
8020004c B __bss
8020004c B __bss_end
80220050 B __stack_top
T 表示符號位於程式碼區段,B 則對應 .bss 區域。boot 落在 0x80200000,與 ELF 進入點相同。
目前沒有未初始化的全域變數,所以 __bss 與 __bss_end 是同一個位址,清除長度為零並不會造成錯誤。__stack_top 位於 0x80220050,它先對齊到 16-byte 邊界,再由前方預留 128 KiB 範圍。
除了 boot 與 Kernel 起始位址,其餘數值都可能因程式內容、Clang 版本或選項而變化,因此檢查重點是同一次建置內部的位址關係,不必追求所有環境都出現相同數值。
最後查看反組譯:
make disasm
反組譯中的關鍵片段是:
80200000 <boot>:
80200000: 37 05 22 80 lui a0, 0x80220
80200004: 13 05 05 05 addi a0, a0, 0x50
80200008: 2a 81 mv sp, a0
8020000a: 6f 00 a0 01 j 0x80200024 <kernel_main>
80200024 <kernel_main>:
...
80200048: 01 a0 j 0x80200048 <kernel_main+0x24>
boot() 先組出 0x80220050,把它放進 sp,再跳到 kernel_main()。kernel_main() 的最後一條跳躍指令回到自己,所以我們預期 CPU 最後停留在 0x80200048。
到這裡,ELF Header 告訴我們「從哪裡開始」,符號表告訴我們「每個名字在哪裡」,反組譯則說明「那個位置放了什麼指令」,下一步才要確認 QEMU 裡的 CPU 是否真的走到那裡。
我們使用固定版本的 RV32 OpenSBI 韌體,先下載並檢查檔案:
make firmware
sha256sum opensbi-riscv32-generic-fw_dynamic.bin
本文使用的檔案雜湊值為:
3de92c4ac5acb09a4e8678ce730491d9ff017c17669c67f4a9188f480c6fad42
接著啟動 Kernel:
make run
OpenSBI 會先印出一大段平台資訊,今天先找以下兩個欄位:
Domain0 Next Address : 0x80200000
Domain0 Next Mode : S-mode
它們表示韌體準備將 CPU 交給位於 0x80200000 的 S-mode 程式,這符合我們對 Kernel 起始位址與權限模式的預期,但仍要查看 CPU 的實際狀態。
按 Ctrl+A,放開後再按 C,就能從串列主控台切換到 QEMU monitor。
在 (qemu) 提示符號後輸入:
info registers
QEMU monitor 的關鍵結果為:
CPU#0
pc 80200048
...
x2/sp 80220050
現在可以把執行結果與建置資訊排在一起:
QEMU pc 0x80200048
kernel_main 迴圈 0x80200048
QEMU sp 0x80220050
__stack_top 0x80220050
pc 落在反組譯所顯示的無限迴圈,證明 CPU 已經進入 kernel_main(),
同時 sp 與同一次建置產生的 __stack_top 一致,說明 boot() 的 Stack 設定也已執行。
若修改程式後 kernel_main() 開始使用 Stack Frame,sp 可能比 __stack_top 低,這時應回頭檢查該次反組譯中的 sp 調整量,而不是要求永遠與本文數值相同。
在 monitor 輸入 q 可以結束 QEMU,也可以切回 Console,再按 Ctrl+A 與 X 離開模擬器。
到這裡,畫面靜止不再是一個無法分辨成敗的現象,pc 與 sp 提供了可與 ELF 相互比對的資訊,我們因此能判斷 Kernel 走到哪一步。
把整個檢查過程整理起來,會得到這條證據鏈:
kernel.c 與 kernel.ld
↓
kernel.elf 的 Entry Point 與 Symbol
↓
boot() 的反組譯
↓
OpenSBI 的 Next Address 與 Next Mode
↓
QEMU 中的 pc 與 sp
每一層都可以向前一層取得預期值,再和後一層的實際結果比較,這種做法會貫穿後面的 Kernel 實驗,因為我們不會只靠最後一行輸出判斷整個系統是否正確。
clang -print-targets | grep riscv
若沒有 riscv32,先檢查 which clang 與 Clang 的安裝來源,因為這是工具鏈問題,不是 kernel.c 的邏輯錯誤。
ls -lh opensbi-riscv32-generic-fw_dynamic.bin
檔案不存在時重新執行 make firmware,再用 sha256sum 確認下載結果。
boot 不在 0x80200000make symbols
llvm-readelf -h build/kernel.elf
檢查 ENTRY(boot)、.text.boot 與 KEEP(*(.text.boot)) 是否同時存在,也要確認 QEMU 載入的是剛編譯完成的 build/kernel.elf。
sp 與 __stack_top 對不上先重新讀取目前 ELF 的符號:
make symbols | grep __stack_top
再從 QEMU monitor 查看 info registers,若位址不同,先看 kernel_main() 的反組譯是否已調整 Stack,
再檢查 boot() 的內嵌組合語言與 naked 屬性。
結束 QEMU 後,從 tiny-kernel/ 回到系列資料夾:
cd ..
若目錄尚未成為 Git Repository,先初始化:
git init
建立 tiny-kernel/.gitignore,排除可重新產生的 Build 目錄與下載韌體:
/build/
/opensbi-riscv32-generic-fw_dynamic.bin
將今天實際建立的檔案加入 Commit:
git add tiny-kernel/.gitignore \
tiny-kernel/Makefile \
tiny-kernel/kernel.c \
tiny-kernel/kernel.ld
git commit -m "day01: bootstrap riscv kernel project"
今天還沒有實作 Scheduler 或 System Call,但我們已經得到一個可編譯、可啟動,也可透過暫存器驗證的 RV32 Kernel 起點,後面每加入一項功能時,我們都有一條可以回頭檢查的啟動路徑。
Day 02 會拆開反組譯中的 RISC-V 指令,我們會實際操作 a0、sp 與 ra,並比較 C、Assembly 與 Machine Code 如何描述函式呼叫。
virt Platform