Skip to content

核心啟動(Booting the Kernel)

當電腦啟動時,CPU 首先會初始化自身,接著開始執行作業系統。作業系統會初始化硬體、啟動應用程式。這整個流程就叫做「開機(booting)」。

那麼在作業系統開始前發生了什麼事呢?在 PC 中,BIOS(或較新的 UEFI)會初始化硬體、顯示開機畫面、並從磁碟載入 OS。而在 QEMU 的 virt 虛擬機中,OpenSBI 就扮演著 BIOS/UEFI 的角色。

Supervisor Binary Interface (SBI)

Supervisor Binary Interface(SBI)是一種提供給作業系統核心的 API,它定義了 firmware(例如 OpenSBI)對作業系統所提供的功能。

SBI 的規格可以在 GitHub 找到。它定義了許多有用的功能,例如在除錯用序列埠上顯示字元、重開機/關機、定時器設定等。

目前最常見的 SBI 實作就是 OpenSBI。在 QEMU 中,OpenSBI 會在開機時預設啟動,先執行硬體相關的初始化,再啟動核心。

讓我們啟動 OpenSBI 吧!

首先建立一個名為 run.sh 的 shell 腳本:

$ touch run.sh
$ chmod +x run.sh

內容如下:

run.sh
bash
#!/bin/bash
set -xue

# QEMU file path
QEMU=qemu-system-riscv32

# Start QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot

這段命令會用 QEMU 啟動一台虛擬機,參數說明如下:

  • machine virt:使用 virt 虛擬機(可用 -machine '?' 查詢支援的其他機種)
  • bios default:使用預設的 BIOS(在 QEMU 中就是 OpenSBI)
  • nographic:不開啟 GUI 視窗
  • serial mon:stdio:將 QEMU 標準輸入/輸出接到虛擬機的序列埠,可按 Ctrl+A 再按 C 進入 QEMU monitor
  • -no-reboot:當虛擬機崩潰時不要自動重開(方便除錯)

TIP

在 macOS 上,可以用以下命令查看 Homebrew 安裝的 QEMU 路徑:

$ ls $(brew --prefix)/bin/qemu-system-riscv32
/opt/homebrew/bin/qemu-system-riscv32

執行 ./run.sh,你會看到以下畫面:

$ ./run.sh

OpenSBI v1.2
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name             : riscv-virtio,qemu
Platform Features         : medeleg
Platform HART Count       : 1
Platform IPI Device       : aclint-mswi
Platform Timer Device     : aclint-mtimer @ 10000000Hz
...

OpenSBI 會顯示版本、平台名稱、支援功能、CPU 核心數(HART)、定時器裝置等資訊。

這個階段下,你在鍵盤上按下任何鍵都不會有反應,因為 QEMU 的標準輸入/輸出被接到了虛擬機的序列埠,而你輸入的字元會被傳送到 OpenSBI,但目前沒有任何程式會去讀取這些輸入字元。

現在讓我們按下 Ctrl+A 再按 C 以進入 QEMU 的除錯監控介面(QEMU monitor)。你可以透過輸入 q 命令離開該介面:

QEMU 8.0.2 monitor - type 'help' for more information
(qemu) q

TIP

Ctrl+A 除了用來切換到 QEMU 的監控介面(C 鍵)之外,還有許多其他的功能。例如,按下 X 鍵可以立即關閉 QEMU。

C-a h    顯示此幫助畫面
C-a x    離開模擬器
C-a s    將磁碟資料儲存回檔案(若使用 -snapshot)
C-a t    切換是否顯示主控台時間戳
C-a b    傳送中斷(magic sysrq)
C-a c    在主控台與監控介面之間切換
C-a C-a  傳送 C-a 自身字元

連結器腳本(Linker Script)

連結器腳本是一種用來定義可執行檔的記憶體配置的檔案。連結器會根據這個配置,幫每個函式與變數分配記憶體位址。

現在讓我們建立一個新檔案 kernel.ld

kernel.ld
ld
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; /* 128KB */
    __stack_top = .;
}

重點說明:

  • 核心的進入點是 boot 函式。
  • 程式的起始位址為 0x80200000
  • .text.boot 區段永遠會被放在最前面。
  • 區段會按照 .text.rodata.data.bss 的順序擺放。
  • 核心堆疊配置在 .bss 後面,大小為 128KB。

這裡提到的 .text.rodata.data.bss 區段,是具有特定用途的資料區域:

區段說明
.text此區段包含程式的機器碼(code)。
.rodata此區段包含唯讀的常數資料。
.data此區段包含可讀寫的已初始化資料。
.bss此區段包含可讀寫但初始值為 0 的資料。

讓我們來更仔細地看看連結器腳本的語法。首先,ENTRY(boot) 宣告程式的進入點是 boot 函式。接著,各區段(section)的配置會在 SECTIONS 區塊中定義。

*(.text .text.*) 會將所有檔案(*)中的 .text 區段,以及任何以 .text. 開頭的區段,都放到此處。

符號 . 代表當前位址,當配置資料時(像是 *(.text)),它會自動遞增。而 . += 128 * 1024 的意思是「將當前位址往後推移 128KB」。ALIGN(4) 則確保當前位址對齊到 4-byte 的邊界。

最後,__bss = . 表示將當前位址指定給符號 __bss。在 C 語言中,你可以透過 extern char symbol_name 來參考連結器腳本中定義的符號。

TIP

連結器腳本提供了許多方便的功能,尤其在開發核心(kernel)時非常有用。你可以在 GitHub 上找到許多實際範例!

最小化核心(Minimal kernel)

我們現在準備好開始撰寫作業系統核心了。先從建立一個最小化的版本開始吧!請建立一個名為 kernel.c 的 C 語言原始碼檔案:

kernel.c
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" // Set the stack pointer
        "j kernel_main\n"       // Jump to the kernel main function
        :
        : [stack_top] "r" (__stack_top) // Pass the stack top address as %[stack_top]
    );
}

接下來我們逐一說明重點:

核心進入點(Entry Point)

核心的執行從 boot 函式開始,這個函式已在連結器腳本中被指定為進入點了。在此函式中,堆疊指標(sp)會被設定為連結器腳本中定義的堆疊區域的結尾位址。接著,它會跳躍至 kernel_main() 函式。請注意,堆疊是往位址遞減的方向成長的(往 0 成長),也就是使用時會遞減位址。因此我們要設定的是堆疊區域的結尾位址(而不是起始位址)。

boot 函式的屬性(function attributes)

boot 函式有兩個特殊屬性(attribute)。屬性 __attribute__((naked)) 指示編譯器不要在函式本體的前後生成多餘的程式碼(例如 return 指令),這樣可以確保我們寫得內嵌組語就是函式的完整內容。

boot 函式還有 __attribute__((section(".text.boot"))) 屬性,用來控制函式在連結器腳本中的擺放位置。由於 OpenSBI 只會單純跳到 0x80200000,而不會知道實際的進入點,因此 boot 函式必須放在 0x80200000

使用 extern char 取得連結器腳本中的符號

在檔案開頭,我們會用 extern char 宣告連結器腳本中定義的每個符號(symbol)。這裡我們只需要取得符號的「位址」,所以使用 char 型別即可。

我們也可以寫成 extern char __bss;,但單寫 __bss 代表的是「.bss 區段第 0 位元組的值」,而不是「取得 .bss 區段的起始位址」,因此建議加上 [] 確保 __bss 代表的是位址,避免誤用。

.bss 區段初始化

kernel_main() 中,我們使用 memset().bss 區段清成 0。雖然有些 bootloader(開機載入器)會自動將 .bss 清 0,但我們還是自己做一次保險。最後,函式會進入無窮迴圈,表示核心初始化完成、進入穩定狀態。

執行核心!

run.sh 中加入一條建構核心(kernel)的命令,以及一個新的 QEMU 選項(-kernel kernel.elf):

run.sh
bash
#!/bin/bash
set -xue

QEMU=qemu-system-riscv32

# Path to clang and compiler flags
CC=/opt/homebrew/opt/llvm/bin/clang  # Ubuntu users: use CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fno-stack-protector -ffreestanding -nostdlib"

# Build the kernel
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
    kernel.c

# Start QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
    -kernel kernel.elf

TIP

macOS 用戶可使用以下命令確認 clang 的路徑:

$ ls $(brew --prefix)/opt/llvm/bin/clang
/opt/homebrew/opt/llvm/bin/clang

這些 clang 編譯選項(即 CFLAGS)的說明如下:

選項說明
-std=c11使用 C11 標準
-O2開啟最佳化,以產生更有效率的機器碼
-g3產生最多的除錯資訊
-Wall開啟主要警告訊息
-Wextra開啟額外警告訊息
--target=riscv32-unknown-elf目標平台為 32-bit RISC-V
-ffreestanding不使用主機環境(開發環境)的標準函式庫
-fno-stack-protector關閉 stack protection,避免影響底層堆疊操作(詳見 #31
-nostdlib不連結標準函式庫
-Wl,-Tkernel.ld使用指定的 linker script
-Wl,-Map=kernel.map輸出 linker 分配結果(map 檔)

-Wl, 前綴表示該參數要傳遞給 linker,而不是 C 編譯器本身。clang 命令會先進行 C 的編譯,接著在內部執行 linker。

初次核心除錯(Your first kernel debugging)

當你執行 run.sh 時,核心會進入無窮迴圈,畫面上不會有任何輸出以讓你知道核心正在正確地執行。但別擔心,這在較低階的開發中非常常見!這時就輪到 QEMU 的偵錯功能派上用場了。

要查看更多 CPU 暫存器的資訊,你可以開啟 QEMU monitor,並執行 info registers 命令:

QEMU 8.0.2 monitor - type 'help' for more information
(qemu) info registers

CPU#0
 V      =   0
 pc       80200014  ← Address of the instruction to be executed (Program Counter)
 ...
 x0/zero  00000000 x1/ra    8000a084 x2/sp    80220018 x3/gp    00000000  ← Values of each register
 x4/tp    80033000 x5/t0    00000001 x6/t1    00000002 x7/t2    00000000
 x8/s0    80032f50 x9/s1    00000001 x10/a0   80220018 x11/a1   87e00000
 x12/a2   00000007 x13/a3   00000019 x14/a4   00000000 x15/a5   00000001
 x16/a6   00000001 x17/a7   00000005 x18/s2   80200000 x19/s3   00000000
 x20/s4   87e00000 x21/s5   00000000 x22/s6   80006800 x23/s7   8001c020
 x24/s8   00002000 x25/s9   8002b4e4 x26/s10  00000000 x27/s11  00000000
 x28/t3   616d6569 x29/t4   8001a5a1 x30/t5   000000b4 x31/t6   00000000

TIP

具體數值會因 clang 與 QEMU 的版本不同而有所差異。

pc 80200014 表示目前的程式計數器(Program Counter),也就是正在執行的指令位址。讓我們使用反組譯工具(llvm-objdump)把範圍縮小到對應的那一行程式碼:

$ llvm-objdump -d kernel.elf

kernel.elf:     file format elf32-littleriscv

Disassembly of section .text:

80200000 <boot>:  ← boot function
80200000: 37 05 22 80   lui     a0, 524832
80200004: 13 05 85 01   addi    a0, a0, 24
80200008: 2a 81         mv      sp, a0
8020000a: 6f 00 60 00   j       0x80200010 <kernel_main>
8020000e: 00 00         unimp

80200010 <kernel_main>:  ← kernel_main function
80200010: 73 00 50 10   wfi
80200014: f5 bf         j       0x80200010 <kernel_main>  ← pc is here

每一行都對應一條指令。每一欄分別代表:

  • 指令所在的記憶體位址
  • 該指令的十六進位機器碼
  • 對應的反組譯指令

pc 80200014 表示目前執行的指令是 j 0x80200010,也就是跳回 kernel_main,這證實 QEMU 已正確地到達了 kernel_main() 函式。

我們也來確認一下堆疊指標(sp 暫存器)是否被正確地設定為連結器腳本中定義的 __stack_top 值。從暫存器的傾印中可以看到 x2/sp 80220018。要確認 linker 實際將 __stack_top 放在哪個位置,可以查看 kernel.map 檔案中的內容:

     VMA      LMA     Size Align Out     In      Symbol
       0        0 80200000     1 . = 0x80200000
80200000 80200000       16     4 .text
...
80200016 80200016        2     1 . = ALIGN ( 4 )
80200018 80200018    20000     1 . += 128 * 1024
80220018 80220018        0     1 __stack_top = .

你也可以使用 llvm-nm 來列出符號對應的位址:

$ llvm-nm kernel.elf
80200010 t .LBB0_1
00000000 N .Lline_table_start0
80220018 T __stack_top
80200000 T boot
80200010 T kernel_main

第一欄代表各符號在記憶體中的位址(VMA)。可以看到 __stack_top 被放在 0x80220018,這證實堆疊指標在 boot 函式中被正確設定了。

隨著程式的執行,info registers 顯示的內容也會改變。若你想暫停模擬器執行,可以在 QEMU monitor 中使用 stop 命令:

(qemu) stop             ← The process stops
(qemu) info registers   ← You can observe the state at the stop
(qemu) cont             ← The process resumes

到這裡,你已經成功寫出你的第一個核心了!