先從一行很普通的 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 韌體。
《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。
如果兩邊沒有遵守同一套約定,程式可以成功連結,卻算出錯誤結果。
x10 和 a0 不是兩個不同的暫存器,而是硬體編號與慣例名稱。
反組譯若顯示 a0,QEMU 又寫成 x10/a0,它們指的是同一份狀態。
這個小地方先弄清楚,後面對照畫面時就不會多找一個不存在的暫存器。
還有一件事容易被名稱騙過:ra 雖然叫返回位址暫存器,CPU 並不會替你保存每一層函式的返回歷史。
下一次呼叫可能覆蓋它,所以巢狀呼叫時,原本的返回位址需要另找地方存放,堆疊正好承擔這個工作。
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,最後的 ret 依 ra 返回呼叫端。
以剛才的 S 為基準,這段程式用到的空間如下:
| 現在的存取位置 | 相對原本 sp 的位置 | 暫存內容 |
|---|---|---|
12(sp) |
S - 4 |
呼叫端的返回位址 |
8(sp) |
S - 8 |
第一個參數 17 |
0(sp)~7(sp) |
S - 16~S - 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.c 的 kernel_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,但後續程式仍可以使用這個暫存器。
我們把結果寫進 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.c、kernel.ld 與 Makefile。
從系列資料夾保存這些檔案,建議 commit 訊息為:
day02: add riscv assembly experiments
我們現在能追蹤一次 C 與組合語言之間的呼叫。
Day 03 會進一步檢查 CPU 的權限狀態,看看能執行這些指令,和能控制整個系統之間還差了什麼。