kernel_main()如果開機後讀到一個全域變數是零,能證明核心的清零程式真的執行過嗎?
在模擬器裡,載入器可能早已把那段記憶體清成零,單看結果很容易錯過沒有執行的初始化步驟。
今天會在核心進入點暫停,先用 GDB 把測試變數填成 0xdeadbeef,再讓自己的啟動程式把它清掉。
當資料從我們指定的非零值變成零,才能清楚看到初始化造成的改變。
《Operating System Concepts》第 10 版第 2.5 節分別介紹連結器與載入器,第 2.9.2 節則說明開機時如何找到並啟動核心。
連結器安排程式的位址,載入器把映像放進記憶體,接著才輪到 CPU 從指定位置執行。
我們的實驗由 QEMU 載入 ELF,OpenSBI 完成前置工作後進入核心。
但 C 函式需要的堆疊,以及未初始化靜態資料的零值,仍必須由啟動流程確保。
今天把這些責任放到一個可以單步執行的 boot.S。
《OS in 1,000 Lines》的〈核心啟動〉使用 C 裡的 naked 函式作為進入點。
這篇延伸同一條啟動流程,將組合語言獨立成檔案,並在進入 C 之前完成 .bss 清零。
以下都在 30-days-os-kernel/tiny-kernel/ 進行,接續 Day 03。
新增 boot.S:
.section .text.boot
.balign 4
.globl boot
.type boot, @function
boot:
la sp, __stack_top
la t0, __bss
la t1, __bss_end
1:
bgeu t0, t1, 2f
sb zero, 0(t0)
addi t0, t0, 1
j 1b
2:
call kernel_main
3:
j 3b
.size boot, . - boot
1b 表示向後尋找標號 1,2f 表示向前尋找標號 2。
清零採用逐 byte 寫入,起訖長度不必剛好是四的倍數。kernel_main() 正常不會返回,最後的迴圈則保留一個明確的意外返回落點。
新增 kernel.h,讓接下來不同來源檔共用型別:
#pragma once
#include <stddef.h>
#include <stdint.h>
extern char __bss[], __bss_end[], __stack_top[];
void *memset(void *buf, int value, size_t n);
這兩個標頭提供型別與巨集,沒有把主機的 C 函式庫連進核心。
使用 size_t 等標準名稱,也能減少自己宣告和編譯器預期不一致的問題。
用以下內容完整取代 kernel.c,因此原本 C 版本的 boot() 也會一起移除:
#include "kernel.h"
#include "csr.h"
extern uint32_t asm_probe(uint32_t a, uint32_t b);
volatile uint32_t probe_result;
volatile uint32_t csr_snapshot[3];
volatile uint32_t bss_probe;
void *memset(void *buf, int value, size_t n) {
uint8_t *p = buf;
while (n--)
*p++ = (uint8_t) value;
return buf;
}
__attribute__((noinline))
void privilege_checkpoint(void) {
__asm__ __volatile__("nop");
}
void kernel_main(void) {
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");
}
bss_probe 沒有設定初始值,應該落在 .bss。memset() 暫時保留給後續功能使用,這一天的開機清零已由 boot.S 完成。
完整的 kernel.ld 改為:
OUTPUT_ARCH(riscv)
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text : {
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) { *(.rodata .rodata.* .srodata .srodata.*); }
.data : ALIGN(4) { *(.data .data.* .sdata .sdata.*); }
.bss (NOLOAD) : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
*(COMMON);
__bss_end = .;
}
.stack (NOLOAD) : ALIGN(16) {
__stack_bottom = .;
. += 128 * 1024;
__stack_top = .;
}
}
.sdata 等名稱是工具鏈可能使用的小型資料區段,也要被明確安排。NOLOAD 區段保留執行時的空間,不需要把整段零值塞進 ELF 的檔案內容。
堆疊獨立於 .bss,兩者的範圍現在可以分開檢查。
在 Makefile 更新來源清單,並於既有 CFLAGS 定義之後追加選項:
SOURCES := kernel.c asm_probe.S boot.S
CFLAGS += -msmall-data-limit=0 -Wl,--no-relax
這個實驗尚未設定全域指標 gp,因此先停用小型資料配置策略與連結器 relaxation,讓組語裡的位址載入維持容易追蹤的形式。
保留 Day 03 加入的標頭相依規則。
在 tiny-kernel/ 編譯:
make
llvm-readelf -S build/kernel.elf
llvm-nm -n build/kernel.elf
依 Day 03 的雙終端機方式啟動 QEMU 與 GDB,QEMU 仍須包含 -S。
GDB 連線後輸入:
target remote 127.0.0.1:1234
hbreak *boot
continue
set var bss_probe = 0xdeadbeef
p/x bss_probe
x/6i $pc
此時 CPU 停在 boot 第一條指令,OpenSBI 已把控制權交到核心,但我們自己的 .bss 迴圈尚未執行。
繼續設定下一個檢查點:
hbreak *kernel_main
continue
p/x bss_probe
p/x $sp
p/x (unsigned int) &__stack_top
p/x ((unsigned int) $sp & 15)
預期 bss_probe 已變成 0x0,sp 等於 __stack_top,最後的對齊檢查也是 0x0。
這裡使用 *kernel_main,讓斷點停在第一條機器指令之前,避免 C 函式先執行 prologue 移動了堆疊。
若要逐條看 la 和迴圈如何展開,可以重新啟動實驗,停在 boot 後使用 si。la 是偽指令,可能展開成多條真正的 CPU 指令,不能把一次 si 當成執行完整一行原始組語。
在個人的實驗副本中,暫時跳過清零迴圈,再重複剛才的 GDB 寫入測試。
如果 bss_probe 仍保留 0xdeadbeef,便能確認這份測試確實能抓到「初始化被漏掉」的情況。
完成觀察後恢復清零迴圈並重新編譯,保存的是正常啟動版本。
若出現重複 boot 符號,檢查 kernel.c 是否還留著 Day 01 的 naked 函式。
若 bss_probe 不在 __bss 到 __bss_end 之間,先檢查連結器腳本與符號表,再懷疑清零迴圈。
今天新增 boot.S 與 kernel.h,修改 kernel.c、kernel.ld 與 Makefile。
建議 commit 訊息為:
day04: boot into kernel_main
Day 05 會替這個已能可靠進入 C 的核心接上文字輸出,讓除錯資訊直接出現在終端機。