最近看到清华的一个操作系统教程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段清零的操作:
这里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以后就可以进行查看代码、加断点、单步执行等操作了。