iT邦幫忙

2026 iThome 鐵人賽

DAY 1
0
Software Development

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

Day 01|作業系統到底在做什麼?建立我們的 Tiny Kernel 專案

  • 分享至 

  • xImage
  •  

平常寫 C 程式時,我們習慣從 main() 開始,也習慣直接使用 printf(),但在 main() 執行以前,其實已經有其他程式幫我們載入可執行檔、準備堆疊,並把輸出通道接好。

今天的情況反了過來,因為我們要寫的正是作業系統核心。
沒有現成的 OS 替 Kernel 準備執行環境,我們必須自己回答幾個很基本的問題:CPU 從哪個位址開始執行?堆疊要放在哪裡?C 函式為什麼能安全地被呼叫?

Day 01 不追求做出功能完整的 OS,只完成一條最小啟動路徑,讓 OpenSBI 把 CPU 交給 boot(),設定 Stack 後進入 kernel_main()。

這個 Kernel 還不會印出 Hello World,所以當畫面停在 OpenSBI 資訊時,我們不會把「沒有動靜」當成成功證明,而是從 ELF 符號、反組譯與 CPU 暫存器找到可以相互印證的結果。

先把今天的終點說清楚

實驗完成時,我們要拿出三組證據:

  1. kernel.elf 是 RV32 可執行檔,進入點為 0x80200000
  2. OpenSBI 準備從 0x80200000 進入 Supervisor Mode
  3. QEMU 中的 pc 落在 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 是我們將在連結器腳本中定義的位址。
現在不用先背起這些名字,後面會把每個數值逐一對回程式碼。

作業系統為什麼需要 Kernel?

俗稱「恐龍書」的《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 執行我們的程式碼。

QEMU、OpenSBI 與 Kernel 各做什麼?

本系列前 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 原始碼不會解決這類錯誤。

建立 Tiny 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。

第三步:用 Makefile 固定可重現的命令

《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:反組譯 Kernel
  • make run:用 OpenSBI 與 QEMU 啟動 Kernel

第四步:先檢查 ELF,不急著啟動

我們目前在 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 找不到 RV32 Target

clang -print-targets | grep riscv

若沒有 riscv32,先檢查 which clang 與 Clang 的安裝來源,因為這是工具鏈問題,不是 kernel.c 的邏輯錯誤。

QEMU 找不到 OpenSBI

ls -lh opensbi-riscv32-generic-fw_dynamic.bin

檔案不存在時重新執行 make firmware,再用 sha256sum 確認下載結果。

boot 不在 0x80200000

make 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 屬性。

留下 Day 01 的可執行成果

結束 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 如何描述函式呼叫。

參考資料


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

尚未有邦友留言

立即登入留言