如果這次實驗成功,畫面會停住,而且不會印出 Hello World。
這聽起來不像值得慶祝的結果,但原因很簡單:我們還沒寫出讓核心顯示文字的功能。
螢幕上的開機資訊來自 OpenSBI 韌體,不是我們的程式,因此不能看見幾行文字就認定核心已經啟動。
真正要確認的是,CPU 有沒有執行到我們安排的位置。
這個系列會跟著 Seiya Nuta 的《OS in 1,000 Lines》(繁體中文版《1000 行打造作業系統》)動手做,再用《Operating System Concepts》補上需要的理論。
第一篇採用〈核心啟動〉的最小範例,工作只有準備堆疊、清零 .bss,最後留在迴圈中,不急著把完整作業系統塞進第一天。
QEMU 負責模擬硬體,OpenSBI 負責早期初始化,我們的核心則從 boot() 接手。
接下來會沿著這條路徑檢查:
OpenSBI 韌體
↓ 切換到監督者模式(S-mode)
boot():核心進入點
↓ 設定堆疊指標 sp
kernel_main()
↓ 清除 .bss
for (;;):停留在無限迴圈
pc 是程式計數器(Program Counter),用來觀察 CPU 目前的指令位址。sp 是堆疊指標(Stack Pointer),__stack_top 則是稍後由連結器腳本定義的堆疊頂端位址。
先不用背下這幾個名字,等編譯結果出來後,我們會把它們一個個對回程式碼。
最後要回答的問題是:
pc 是否對應 kernel_main() 裡的無限迴圈?
sp 是否與這次建置的 __stack_top 位址一致?
嚴格來說,現在還沒有一般使用者期待的那些作業系統功能。
你不能在裡面開檔案,也不能執行另一個程式,稱它為「核心的起點」比較合適。
不過,先把它啟動起來,才能逐步接上管理資源的工作。
俗稱「恐龍書」的《Operating System Concepts》第 10 版,在第 1.1 節把電腦系統拆成四個部分:使用者、應用程式、作業系統與硬體。
CPU、記憶體與輸入/輸出(I/O)裝置提供資源,應用程式利用資源完成工作,作業系統則控制硬體並協調不同程式如何使用它。
例如瀏覽器和編輯器同時執行,它們都需要 CPU,也都需要記憶體。
誰先跑、哪一塊記憶體可以分出去,不能只靠應用程式各自決定,這就是書中**資源配置者(Resource Allocator)**的角色。
另外,如果某個程式出了錯,能不能任意改掉別人的資料,甚至關閉整個系統的中斷?
作業系統還需要約束程式與 I/O 的執行,這對應到**控制程式(Control Program)**的角色。
前者處理如何分配,後者處理如何維持可管理的執行環境,實際功能經常同時涉及兩者。
本系列後續的實作會逐步完成這些責任,例如:
分頁配置器(Page Allocator)/排程器(Scheduler)
→ Resource Allocator
陷阱處理(Trap)/權限模式(Privilege Mode)/系統呼叫(System Call)
→ Control Program
書中也提醒,Operating System 沒有一個適用所有電腦的完整定義。
常見的實用說法,是把持續執行並管理系統的核心程式稱為 Kernel。
今天完成的 kernel.elf 是存放核心程式的 ELF(Executable and Linkable Format)執行檔。
這個核心還不會動態配置記憶體、排程行程(Process)或處理系統呼叫,後續會逐步補上這些功能。
《Operating System Concepts》第 1.4.2 節用使用者模式(User Mode)與核心模式(Kernel Mode)說明硬體保護邊界。
應用程式需要核心服務時,會發出系統呼叫,經由受控的陷阱處理流程進入核心。
硬體中斷(Interrupt)也可能讓核心取得控制權,例如處理裝置事件。
本次 RISC-V 實驗中,各權限模式的分工如下:
M-mode 機器模式(Machine Mode) OpenSBI 韌體
S-mode 監督者模式(Supervisor Mode) 我們的核心
U-mode 使用者模式(User Mode) 後續加入的應用程式
今天會看到 OpenSBI 在 M-mode 完成初始化,再把 CPU 交給 S-mode 的 kernel.elf。
U-mode 要等到 Day 16~Day 17 才會出現。
先把這段理論記成一個具體方向就夠了:後續每加入一項核心功能,都要知道它在管理什麼資源、限制什麼操作。
這次還沒開始分配 CPU 時間,先確認 CPU 願意照著我們的程式跑。
本系列前 18 天沿用教材的 32 位元 RISC-V(RV32)環境,並使用 QEMU 的 virt 虛擬平台。
Day 19 起會進入 MIT 的教學作業系統 xv6-riscv,它使用 64 位元 RISC-V(RV64)。
屆時會比較位址寬度、應用程式二進位介面(ABI)與分頁表(Page Table)的差異。
以下安裝步驟以 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
《OS in 1,000 Lines》的〈開始〉一章使用 qemu-system-riscv32 作為套件名。
在本文的 Ubuntu 環境中,該執行檔由 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,也能產生 RV32 程式,這叫交叉編譯,不必先找一台 RISC-V 電腦。
版本輸出是為了日後對照,不要求每個人都與範例完全相同。
但若某一行出現 command not found,要先處理工具安裝,繼續修改核心程式不會解決這個問題。
在你準備存放系列檔案的工作目錄執行:
mkdir -p 30-days-os-kernel/tiny-kernel
cd 30-days-os-kernel/tiny-kernel
如果已有這個資料夾,直接進入即可。
若你的編輯器已開啟 30-days-os-kernel/,只需要執行 mkdir -p tiny-kernel 與 cd tiny-kernel,不要再建立一層同名的系列資料夾。
接下來的建檔、編譯與 QEMU 指令都在 tiny-kernel/ 執行,完成後目錄會包含:
tiny-kernel/
├── .gitignore
├── Makefile
├── kernel.c
└── kernel.ld
kernel.c 存放核心程式,其中的 boot() 負責準備堆疊,再進入 kernel_main()。
這個進入點採用教材中的 naked 函式與內嵌組合語言(Inline Assembly),稍後會說明它們的用途。
連結器腳本(Linker Script)用來指定程式各區段的配置方式。
建立 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(4);
. += 128 * 1024;
__stack_top = .;
}
這份檔案先決定四件事:
ENTRY(boot) ELF 進入點是 boot()
0x80200000 Kernel 起始位址
__bss~__bss_end 尚未初始化資料的範圍
__stack_top 128 KB Kernel Stack 的頂端
在本文的啟動方式中,QEMU 透過 -kernel 載入核心映像,OpenSBI 完成初始化後再把控制權交給核心。
我們將核心起始位址設為 0x80200000。
因此 .text.boot 必須排在 .text 最前方,讓 boot() 的第一條指令落在這個位址。
RISC-V 的堆疊往較低位址成長,所以初始 sp 要指向預留區域的高位址端,也就是 __stack_top。
SECTIONS 裡的 . 是連結器目前安排到的位址。. = 0x80200000 設定起點,放入各區段後,位置會跟著往後移動,最後 . += 128 * 1024 再替堆疊預留範圍。
這不代表 CPU 已配置出一塊記憶體,更不是向 Ubuntu 呼叫 malloc,只是安排核心預計使用的位址。
幾個區段可以從資料用途理解:機器指令放在 .text,唯讀常數放在 .rodata,有初始內容的可寫資料放在 .data,零初始化資料則由 .bss 描述。.bss 不需要在檔案中逐 byte 存下所有零,但執行前仍必須確保對應 RAM 的值正確,稍後的清零程式就在做這件事。
堆疊前的 ALIGN(4) 沿用教材的最小配置,不代表一般 RISC-V 函式呼叫只需要四-byte 對齊。
Day 02 會把它改為 ILP32 ABI 要求的 16-byte 對齊,再加入獨立的組語函式實驗。
平常寫 int main(),堆疊和執行環境通常已由系統與啟動程式準備好。
這次沒有那一層支援,不能光把函式命名為 main 就期待 CPU 自動找過來,必須明確安排進入點。
建立 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() 開始,而是:
boot()
↓ mv sp, __stack_top
設定 Kernel Stack
↓ j kernel_main
清除 .bss
↓
無限迴圈
section(".text.boot") 把 boot() 放到 Linker Script 保留的最前方。naked 則要求編譯器不要自動加入函式開頭與結尾的堆疊操作,因為此時還沒有設定我們自己的核心堆疊。
extern char __bss[] 等宣告不是要讀取一個字元,而是要取得 Linker Script 建立的符號位址。kernel_main() 用這兩個位址算出 .bss 長度,再把內容清成零。
memset() 也由我們自己提供,因為現在沒有連結一般應用程式使用的標準函式庫。
這是教材的精簡版本,不是在實作完整 libc,參數型別與使用範圍都先配合這個小核心。
最後使用 j kernel_main 而不是需要返回的普通呼叫,因為核心初始化後沒有一個上層應用程式可以回去。kernel_main() 的無限迴圈讓執行位置保持可觀察,但它仍在消耗模擬 CPU 的執行時間,不等於硬體已睡眠。
這裡與官方教材有一個操作上的差別:教材使用 run.sh,本文專案使用 Makefile。
Make 不會替核心提供任何執行能力,只是把下面的 Clang 與 QEMU 命令取好名稱,讓我們可以分開編譯、檢查與啟動。
對照教材時,請比較實際執行的命令與參數,不用把 Makefile 當成另一套 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)
幾個編譯選項值得先認識:
--target=riscv32-unknown-elf 指定 RV32 bare-metal 目標-ffreestanding 告訴編譯器,程式在自行提供啟動流程與基本支援的環境中執行-nostdlib 不自動連結標準函式庫與啟動檔-fno-stack-protector 避免編譯器插入目前尚未提供的 Stack Protection 支援-Wl,-T,kernel.ld 指定 Linker Script-Wl,-Map,build/kernel.map 保存 Linker 配置結果先知道這四種操作就足夠往下走:
make:編譯 kernel.c 並產生 build/kernel.elf
make check:查看 ELF 格式與進入點make symbols 與 make disasm:查看符號位址與機器碼對應的組合語言make run:使用下載的 OpenSBI 韌體啟動核心核心程式仍沿用該章的流程:boot() 設定堆疊、跳入 kernel_main(),再清除 .bss。
Makefile 另以 -march=rv32imac 與 -mabi=ilp32 明確指定指令集及 ABI,並用 -bios 指定本機韌體檔案。
-g3 保留除錯資訊,讓工具能把位址對回函式與來源位置,並不代表 ELF 只能拿來除錯。-O2 則允許最佳化,因此 C 的一行不一定對應組語的一行,後面會直接看反組譯,而不是猜編譯器應該產生什麼。
另外注意:Makefile 中真正執行命令的行首要使用 Tab,不是幾個空白。
若出現 missing separator,先檢查縮排,不必回頭修改 kernel.c。
在前面已進入的 tiny-kernel/ 目錄執行:
make clean
make
make check
make clean 只應用於這份專案的 build/ 產物,若你改過 Makefile,先確認清理目標沒有指到其他資料夾。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
接著查看符號:
make symbols
以下是一份符號表範例:
80200000 T boot
8020000e T memset
80200024 T kernel_main
8020004c B __bss
8020004c B __bss_end
8022004c B __stack_top
T 表示符號位於程式碼區段,B 表示位於 .bss 區段。
除了在腳本中指定的起始位址,其餘符號位址可能隨編譯器版本、選項或程式修改而改變,請以自己這次建置的輸出為準。
boot 確實落在 0x80200000。__stack_top 與 .bss 起點相差 0x20000,換算後就是 Linker Script 保留的 128 KB。
目前沒有未初始化的全域變數,所以 __bss 和 __bss_end 位址相同。
清除長度為零不會造成問題,未來加入 .bss 資料時,這段初始化流程仍然適用。
再看反組譯:
make disasm
對應上述範例位址,關鍵部分如下:
80200000 <boot>:
80200000: 37 05 22 80 lui a0, 0x80220
80200004: 13 05 c5 04 addi a0, a0, 0x4c
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() 先設定 sp,再跳進 kernel_main(),最後停在無限迴圈。
ELF header 回答進入點在哪裡,符號表回答函式與變數在哪裡,反組譯則回答那個位置到底放了什麼指令。
三者用途不同,file 說它是 RISC-V 執行檔,還不足以證明它已經在 QEMU 執行。
先下載 OS in 1,000 Lines 使用的 RV32 OpenSBI firmware:
make firmware
sha256sum opensbi-riscv32-generic-fw_dynamic.bin
這份固定韌體檔案的既有紀錄如下,請把自己的輸出與它比較:
3de92c4ac5acb09a4e8678ce730491d9ff017c17669c67f4a9188f480c6fad42
啟動 Kernel:
make run
OpenSBI 資訊裡先找:
Domain0 Next Address : 0x80200000
Domain0 Next Mode : S-mode
這表示 OpenSBI 會從 M-mode 將 CPU 交給位於 0x80200000 的 S-mode Kernel。
接著按 Ctrl+A,放開後再按 C,進入 QEMU monitor:
(qemu) info registers
CPU#0
pc 80200048
...
x2/sp 8022004c
把結果與剛才的符號表放在一起:
pc 0x80200048
kernel_main loop 0x80200048
sp 0x8022004c
__stack_top 0x8022004c
以這組範例來說,兩組位址都吻合,才可以判斷 CPU 已經走過 boot(),目前停留在我們預期的迴圈。
你的位址可能不同,重要的是「自己的 ELF」與「自己的 QEMU 執行結果」對得上,不是數字一定要與本文相同。
此處 sp 相等的判準針對這份反組譯所示的最小程式。
如果你的編譯器在 kernel_main() 中建立了 stack frame,sp 可能比 __stack_top 低,應先檢查函式開頭調整了多少空間,不要一看到不相等就認定啟動失敗。
在 monitor 輸入 q 會結束整個 QEMU 程序,返回主機的終端機。
也可以切回 Console 後按 Ctrl+A,放開再按 X 結束模擬器。
目前的 Kernel 還沒有任何輸出函式,單靠畫面靜止無法判斷啟動是否成功。
確認 pc 落在本次反組譯結果中的迴圈指令,且 sp 與本次建置的堆疊頂端一致,才是這個範例的驗證方式。
你可能會想,直接加一行 printf 不是比較快嗎?
但 printf 也需要實作與輸出通道,現在還沒把它們接進來,所以暫存器與反組譯先成為我們的觀察工具。
Day 05 補上 Console 後,才能換成在核心內印出除錯資訊。
clang -print-targets | grep riscv
沒有 riscv32 時,先確認 which clang 與套件來源。
這是工具鏈問題,不是 kernel.c 寫錯。
ls -lh opensbi-riscv32-generic-fw_dynamic.bin
檔案不存在時重新執行 make firmware。
boot 不在 0x80200000make symbols
llvm-readelf -h build/kernel.elf
確認 ENTRY(boot)、.text.boot 與 KEEP(*(.text.boot)) 都存在。
sp 不等於 __stack_top先比較以下兩項:
make symbols | grep __stack_top
(qemu) info registers
若位址不同,先檢查 kernel_main() 是否調整了 sp,再回頭檢查 boot() 的 Inline Assembly 與 naked attribute。
也要確認沒有拿舊的符號表對照新編譯的 ELF。
結束 QEMU 後,從 tiny-kernel/ 回到系列資料夾:
cd ..
若這個資料夾尚未納入 Git 儲存庫,再執行:
git init
在 tiny-kernel/.gitignore 填入以下內容,排除可重新產生的編譯結果與下載的韌體:
/build/
/opensbi-riscv32-generic-fw_dynamic.bin
從系列資料夾保存實作檔案:
git add tiny-kernel/.gitignore \
tiny-kernel/Makefile \
tiny-kernel/kernel.c \
tiny-kernel/kernel.ld
git commit -m "day01: bootstrap riscv kernel project"
今天新增的功能很少,但已建立一條可以重現的證據鏈:
原始碼
→ ELF
→ Symbol/Disassembly
→ OpenSBI
→ QEMU Register
這條鏈會成為後面所有 Kernel 除錯的起點。
Day 02 會回頭拆解今天反組譯中出現的 RISC-V 指令。
我們將操作 a0、sp 與 ra,比較 C、Assembly 與 Machine Code 如何描述同一個動作。
virt platform