iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

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

Day 02|RISC-V 101:Kernel 開發前先搞懂 CPU 如何執行程式

  • 分享至 

  • xImage
  •  

先從一行很普通的 C 程式開始:result = add(17, 25)
如果只關心答案,寫到這裡差不多就結束了,但 CPU 並不知道什麼叫 add 函式,也不知道哪個數字是第一個參數。
它真正執行的是載入數字、移動暫存器、跳到另一個位址,再設法回來。

這篇把那一小段通常交給編譯器處理的工作拿出來,親手寫成組語。
答案仍然只是 42,但等它真的出現在記憶體裡,我們就能說清楚這個數字是怎麼走過 C、暫存器與堆疊的。

這個實驗接續 Day 01 的 RV32 專案,參考《OS in 1,000 Lines》的〈RISC-V 101〉。
以下建檔與編譯都在 30-days-os-kernel/tiny-kernel/ 進行,使用相同的 Clang、QEMU virt 與 OpenSBI 韌體。

C 的參數,到了 CPU 裡放在哪裡?

《Operating System Concepts》第 10 版第 1.2.2 節,從記憶體與暫存器的關係解釋程式如何執行。
程式必須先放進記憶體,CPU 才能依程式計數器(Program Counter,pc)指定的位址取得指令,解碼並執行。

執行加法時,運算元可以先載入 CPU 的暫存器,結果再寫回記憶體。
這正是今天的觀察順序:C 傳入數字,組合語言完成運算,C 把結果存進全域變數。

我們使用的 RV32 有 32 個整數暫存器,每個寬度為 32 位元。
今天先關注以下幾個:

暫存器 ABI 名稱 在本實驗中的用途
x0 zero 固定讀出零
x1 ra 保存函式返回位址
x2 sp 指向目前堆疊頂端
x5 t0 暫存計算資料
x10 a0 第一個整數參數,也用來傳回結果
x11 a1 第二個整數參數

ABI 是 Application Binary Interface,也就是不同編譯單元共同遵守的二進位介面。
CPU 不知道什麼是「C 的第一個參數」,是呼叫慣例(Calling Convention)讓呼叫端與被呼叫端同意使用 a0
如果兩邊沒有遵守同一套約定,程式可以成功連結,卻算出錯誤結果。

x10a0 不是兩個不同的暫存器,而是硬體編號與慣例名稱。
反組譯若顯示 a0,QEMU 又寫成 x10/a0,它們指的是同一份狀態。
這個小地方先弄清楚,後面對照畫面時就不會多找一個不存在的暫存器。

還有一件事容易被名稱騙過:ra 雖然叫返回位址暫存器,CPU 並不會替你保存每一層函式的返回歷史。
下一次呼叫可能覆蓋它,所以巢狀呼叫時,原本的返回位址需要另找地方存放,堆疊正好承擔這個工作。

開始動 sp 之前,先約定它應該長什麼樣

Day 01 已將堆疊頂端對齊到 16-byte 邊界,用來滿足 RV32 ILP32 ABI 的函式呼叫要求。
先回頭確認 kernel.ld 尾端仍是以下配置:

    . = ALIGN(16);
    . += 128 * 1024;
    __stack_top = .;

這個要求針對堆疊指標,不代表所有變數都要佔用 16 bytes。
稍後的組合語言也會以 16 bytes 為單位移動 sp,讓呼叫邊界維持相同條件。

例如進入函式時 sp 是 S,我們先讓它變成 S - 16,使用這段區域後再改回 S
這裡只是移動一個暫存器,沒有向配置器申請記憶體,能使用的範圍早已由 Linker Script 預留。
如果只減不加,程式每返回一次就留下錯誤的堆疊位置,出問題的可能是後面的函式,而不是這一行加法。

把一次加法拆開來看

建立 asm_probe.S,注意副檔名使用大寫 S

.section .text
.balign 4
.globl asm_probe
.type asm_probe, @function
asm_probe:
    addi sp, sp, -16
    sw   ra, 12(sp)
    sw   a0, 8(sp)
    lw   t0, 8(sp)
    add  a0, t0, a1
    lw   ra, 12(sp)
    addi sp, sp, 16
    ret
.size asm_probe, . - asm_probe

addi sp, sp, -16 先保留堆疊空間,sw 把暫存器內容寫入記憶體,lw 再讀回來。
add 把兩個運算元相加,結果放進 a0,最後的 retra 返回呼叫端。

以剛才的 S 為基準,這段程式用到的空間如下:

現在的存取位置 相對原本 sp 的位置 暫存內容
12(sp) S - 4 呼叫端的返回位址
8(sp) S - 8 第一個參數 17
0(sp)7(sp) S - 16S - 9 本次沒有使用

sw a0, 8(sp) 的意思是把 a0 的值存到 sp 加 8 的記憶體位置,不是把 a0 存進 sp 暫存器。
接著 lw t0, 8(sp) 取回 17,add a0, t0, a1 才把 17 與 25 相加,讓 a0 從「第一個參數」變成「回傳結果」。

這個表描述的是函式自己的保存區,不是說所有 RISC-V 函式都必須使用這幾個 offset。
不同函式需要的區域可以不同,呼叫慣例約束的是對齊與哪些暫存器要保持,而不是強迫大家使用同一張表。

這個函式沒有呼叫其他函式,其實可以省略保存 ra 與透過堆疊搬運參數的步驟。
此處刻意保留它們,讓反組譯能呈現保存、運算與恢復的完整過程。
將來函式內再呼叫別人時,原本的返回位址就不能任由新的呼叫覆蓋。

大寫 .S 會先經過前置處理器,後續要使用巨集或 include 時比較方便,這篇尚未用到那些功能。

接著讓 C 呼叫它。
kernel.ckernel_main() 前加入宣告,並用以下版本取代原本的 kernel_main(),保留 Day 01 的型別、memset()boot()

extern uint32_t asm_probe(uint32_t a, uint32_t b);
volatile uint32_t probe_result;

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
    probe_result = asm_probe(17, 25);
    for (;;)
        __asm__ __volatile__("nop");
}

volatile 要求編譯器保留對 probe_result 的實際存取,方便我們從模擬器觀察。
它不會自動提供多核心同步或執行緒安全,這裡只是建立一個可以檢查的記憶體結果。

extern 宣告讓 C 編譯器知道參數與回傳型別,真正的 asm_probe 實作則由另一個檔案提供。
如果只有宣告、沒有把組語檔送去連結,會得到找不到符號的錯誤,而不是由編譯器替你補出一個加法函式。

兩份來源檔,要一起送進連結流程

在 Makefile 的 CFLAGS 定義後新增 SOURCES,並取代原本產生 $(KERNEL) 的規則:

SOURCES := kernel.c asm_probe.S

$(KERNEL): $(SOURCES) kernel.ld | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ $(SOURCES)

其他目標維持 Day 01 的設定,命令列開頭必須是真正的 Tab。
後續加入新的 .c.S 時,也會在這個清單登記。

Makefile 只是集中列出來源檔,角色與 run.sh 把 kernel.c asm_probe.S 放進 Clang 命令相同。
替換規則後,檔案裡只應保留一個 $(KERNEL) 的建置配方,不要把新舊兩份規則疊在一起。

tiny-kernel/ 執行:

make
llvm-objdump -d --disassemble-symbols=asm_probe build/kernel.elf
llvm-nm -n build/kernel.elf

反組譯可能把 ret 顯示成跳躍指令,也可能把部分指令編成 16-bit 壓縮形式。
我們啟用了 RISC-V 的 C 擴充,因此不能預設每一行組語都一定對應四個位元組。
檢查的重點是 sp 先減後加、運算結果進入 a0,以及最後返回呼叫端。

ret 是組語提供的簡寫,也就是虛擬指令(Pseudo-instruction),不是 CPU 另外擁有一種「回到 C」的特殊能力。
它可以對應到以 ra 為目標、不保留新返回位址的跳躍,啟用壓縮指令時也可能使用較短的編碼。
所以比較原始組語與反組譯時,要看操作的效果,不要求每個名稱與位元組數都一模一樣。

算完以後,不要只看 a0

函式返回值會先放在 a0,但後續程式仍可以使用這個暫存器。
我們把結果寫進 probe_result,就不必賭停止 CPU 的那一刻,a0 還保留著答案。

先從 llvm-nm 找出 probe_result 的位址,再執行:

make run

OpenSBI 啟動後,按 Ctrl+A,放開再按 C 進入 QEMU monitor。
輸入 stop 暫停 CPU,再用 xp /1wx 後接剛才查到的實際位址,讀取一個 32-bit word。
例如位址若是 0x80200100,指令就是 xp /1wx 0x80200100,請勿直接照抄示意位址。

預期讀到 0x0000002a,十進位就是 42
由於目前尚未啟用虛擬記憶體,這裡能直接使用實體記憶體檢查命令 xp
輸入 q 結束 QEMU。

xp /1wx 可以拆成「檢查實體記憶體、讀 1 個 word、以十六進位顯示」,這裡的 word 是 32 bits。
它與之後 GDB 的 x/1wx 不是同一個介面的命令,輸入位置要看提示符號:現在應該是 (qemu)

如果改用逐 byte 顯示,這份 little-endian 環境會先看到 2a,後面是三個 00
兩種顯示沒有互相矛盾,只是把相同四個 bytes 用不同方式呈現。

把答案改掉一次,確認不是看錯位置

成功讀到 42 後,將 C 呼叫暫時改成 asm_probe(17, 26),重新編譯並啟動 QEMU。
這次應讀到 0x0000002b,也就是 43,完成後再把參數改回 25。
這不是額外考題,而是排除「其實一直在讀舊檔案或錯誤位置」的簡單檢查。

如果結果仍是零,先確認 ELF 是否為這次建置、符號位址是否重查,以及 CPU 是否已走過寫入結果的位置。
若是連結時出現 undefined symbol: asm_probe,才回頭看 SOURCES.globl 與拼字。
如果返回後跑到奇怪的位址,優先檢查 ra 的保存位置,以及 sp 是否恢復原值。

到這裡仍不能說所有暫存器保存規則都已驗證,這個小函式沒有使用 s0~s11,也沒有巢狀呼叫。
但至少這一次從 C 送出參數、組語算完、再回到 C 的路徑,已經有可以核對的結果。

留下第一個跨語言實驗

今天新增 asm_probe.S,並修改 kernel.ckernel.ld 與 Makefile。
從系列資料夾保存這些檔案,建議 commit 訊息為:

day02: add riscv assembly experiments

我們現在能追蹤一次 C 與組合語言之間的呼叫。
Day 03 會進一步檢查 CPU 的權限狀態,看看能執行這些指令,和能控制整個系統之間還差了什麼。

參考資料


上一篇
Day 01|作業系統到底在做什麼?建立我們的 Tiny Kernel 專案
下一篇
Day 03|Privilege Mode:Kernel 為什麼比 Application 有權力?
系列文
打造 OS Kernel:從 OS in 1,000 Lines 到 xv613
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言