概述
最近看到清華的一個操作系統教程rCore-Tutorial-Book,和其他實驗不同的是,這個教程介紹的是完全從零開始實現一個Riscv操作系統。教程所用的編程語言是Rust,但是我的Rust水平只到勉強能看懂代碼的地步,所以打算用C語言照着實現一遍。雖然說是照着實現,但不同的語言還是會帶來不少細節的不同,相比於同個語言照抄代碼還是能注意到不少平常沒在意的東西。因此開個坑,記錄一下遇到的問題,代碼放在Github上了。由於是練習,代碼寫得比較亂。
第一部分是實現一個最小化內核,即能讓qemu-system-riscv跑起來並輸出Hello world!然后退出就算成功。得益於SBI的幫助,我們可以少研究很多東西。這里大致介紹一下SBI,SBI指的是一套輔助操作系統內核編程的工具,它包含兩部分,一部分是boot loader,即在機器態里初始化裸機上的一些寄存器和硬件設備,把操作系統內核讀取到對應的內存區域,然后進入內核態(Supervisor態,直譯為監管者態,太晦澀,因為是操作系統內核主要運行的特權級,后面均稱內核態),開始執行內核的第一條指令;另一部分是提供內核態的系統調用,在內核態設置好存儲調用號和參數的寄存器,然后執行指令ecall,系統就會進入機器態,由SBI執行一些機器態才能做的操作,然后返回內核態。沒有SBI,機器態相關的代碼就得自己寫了,xv6就是這樣做的,所以xv6除了進程、文件、內存管理這些模塊,還有一些充滿晦澀代碼的模塊,這些就是在處理機器態和硬件相關的操作;riscv-pk的系統引導用的是BBL(Berkeley Boot Loader),需要機器態做的任務則轉發給spike模擬器的htif模塊,由宿主系統執行這些任務。這里我用的是RustSBI,和教程用的一樣,雖然是用Rust寫的,但是已經打包成二進制文件了,可以直接使用。原先我打算使用qemu自帶的OpenSBI,但是不知道為什么,在調用OpenSBI的退出程序功能時,qemu會報錯,沒法正常退出,RustSBI則不會。
內容
首先是SBI的系統調用,由於涉及寄存器操作,需要用到內聯匯編:
isize sbi_call(usize id, usize a0, usize a1, usize a2) {
isize ret;
asm volatile (
"mv x10, %1\n"
"mv x11, %2\n"
"mv x12, %3\n"
"mv x17, %4\n"
"ecall\n"
"mv %0, x10\n"
:"=r"(ret)
:"r"(a0), "r"(a1), "r"(a2), "r"(id)
:"memory", "x10", "x11", "x12", "x17"
);
return ret;
}
本項目中為了簡化代碼以及與教程保持一致,把unsigned long long定義成usize,long long定義成isize了。這里要注意的是內聯匯編的格式,和Rust不同,C語言不能在內聯匯編的函數中綁定變量和寄存器(如果寫的是x86匯編好像可以,riscv就不行了)(可以在聲明變量的同時指定該變量必須用某寄存器存,但語法比較麻煩),所以需要先把變量存到對應寄存器才可以。這樣最后一個冒號右側的限制符也必須添加上這三個寄存器的名字,否則編譯器可能會編譯出錯誤的代碼,打個比方說,沒有限制符,上面的程序編譯出來的結果可能會在內聯匯編前面用x10存a2,那么進入內聯匯編后程序首先將a0賦給x10,a2就被覆蓋掉了,程序就出錯了。
然后是教程中說的一個bss段清零的操作:
extern char sbss, ebss;
void clear_bss() {
for (char *i = &sbss; i < &ebss; i++) *i = 0;
}
這里sbss、ebss都是來自linker script的符號。符號可以定義在C程序中,可以定義在匯編代碼中,可以定義在linker script中,只要符號的強定義不是在當前C程序,那么對於當前C程序,這個符號可以解釋成任何類型,因為它只是一個位置標識。在上面的代碼中,我把sbss和ebss解釋成linker script中這兩個符號指向的第一個字節,那么就只要對這兩個字節的地址之間的空間清零就行了。教程里面是把兩個符號解釋成地址,我覺得C語言應該也一樣,即下面的寫法和上面應該是等價的:
void sbss();
void ebss();
void clear_bss() {
for (char *i = (char *)sbss; i < (char *)ebss; i++) *i = 0;
}
然后就是這個函數正確編譯需要在編譯選項里加-mcmodel=medany
,不然會報錯,具體原因我沒看懂,好像是默認對符號地址有什么限制。
最后是編譯選項,我寫了個makefile:
default: os.bin
riscv64-unknown-elf-gcc os.c printf.c entry.S -T linker.ld -ffreestanding -nostdlib -g -o os -mcmodel=medany
riscv64-unknown-elf-objcopy os --strip-all -O binary os.bin
qemu-system-riscv64 -machine virt -nographic -bios rustsbi-qemu.bin -device loader,file=os.bin,addr=0x80200000
這里-ffreestanding
的意思是允許重新定義標准庫里已經有的函數,比如我自己定義了一個printf函數(主要內容是從xv6復制的,這里就顯現出Rust的好了,Rust的格式化輸出是定義在語言內部的,只需要重寫字符串的輸出方式,C的整個格式化都得重寫),和stdio.h那個同名,不加這個編譯選項就會報錯。在編譯完后,需要用objcopy把程序的elf元信息去掉,因為裸機只能理解代碼,不能解析elf格式,經過objcopy后整個文件上來就是二進制代碼,裸機可以直接執行。最后是SBI把內核放到的位置,我放在0x8020_0000,和教程的0x8002_0000不太一樣,相應的linker script里的內容也要改。
順便提一下如何使用gdb調試,首先編譯的時候必須用-g
往二進制文件里添加調試符號表,接着在最后執行qemu的時候添加選項-s -S
意思是監聽調試端口1234,同時在執行第一句匯編指令前停下來等待gdb連接。然后打開另一個終端,運行riscv-elf-gdb 二進制文件
,gdb的程序名不一定是這個,只要是riscv目標版本的都可以,二進制文件指的是沒有經過objcopy,gcc直接編譯出來的文件。進入gdb后運行命令target remote :1234
,連上qemu以后就可以進行查看代碼、加斷點、單步執行等操作了。