第二部分是实现一个批处理系统。批处理系统顾名思义就是能输入好几个程序,然后对这些程序依次执行的操作系统。重点不是在批处理,而是在输入用户程序,这就要求用户程序和我们的系统有一种隔离,所以需要在这一部分的系统实现用户态和内核态的切换。
这一部分的代码文件比上一部分多了好几个,大致说明一下功能:
hello_world.c
、lib.c
和user.h
hello_world.c
是用户写的程序,和普通程序一样,主函数是int main() { ... }
,因为支持的系统调用有限,目前只有往标准输出打印一个Hello word!然后返回退出的功能。lib.c
是包含用户程序的入口函数,在函数中执行main函数,然后调用exit()系统调用退出,另外还包含了清零bss段和系统调用函数在用户态的实现(即设置对应寄存器然后ecall的过程)。user.h
包含一些类型、宏定义和函数头。batch.c
、entry.S
、kernel.h
、sbicall.c
、printf.c
、syscall.c
、link_app.S
、mod.c
和trap.S
batch.c
是批处理系统的核心,包含初始化、加载程序(由于目前没有文件系统,所以用户程序都是和内核程序一起加载到内存中,等到要执行时将用户程序复制到对应的位置,从那个位置开始执行)、程序管理等功能。entry.S
和实验一一样,是内核的入口点。kernel.h
包含一些类型、宏定义和函数头。sbicall.h
包含了对SBI的调用。printf.c
和实验一一样,实现了输出函数和panic函数。syscall.c
包含了对各类系统调用的实际处理。link_app.S
将用户程序加载到内核程序的数据段中,并定义了一些符号供C语言调用。mod.c
包含了对trap的处理,如果是系统调用则执行syscall.c里那些函数,否则说明是其他异常,直接panic。trap.S
包含了进出内核态时保存现场的汇编代码。为了写起来简单,代码基本没怎么对错误情况和异常进行处理,因此可能存在不少漏洞。下面是一些注意点:
首先是lib.c
中的入口函数:
注意第二行,它等价于Rust里的#[link_section = ".text.entry"]
,作用是把下面的函数放到字符串指定的段里,这有什么作用呢,看下用户态的链接脚本:
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80400000;
SECTIONS
{
. = BASE_ADDRESS;
.text : {
*(.text.entry)
*(.text .text.*)
}
...
注意到代码段专门把.text.entry
这一段列出来放在最前面,所以如果指定了某函数是在这个段里的,那么链接器就会把这个函数放在最前面,也就是BASE_ADDRESS
的位置。事实上,链接脚本里的ENTRY
在内核编译中是没有意义的,这个“入口点”的定义放在ELF文件的元信息里,而我们在使用objcopy的过程中会把ELF的元信息去掉,那么ENTRY
指定的东西也就被去掉了。
在内核编译中,指令的地址才是最重要的,如果指定用户程序从0x80400000开始执行,那么用户程序0x80400000位置的第一条指令就是其入口点。而链接脚本的段定义配合语言中的段名指定正是决定变量和函数放在哪个内存位置的方法。如果不加段名指定,像上面的程序就可能把其他的函数放在0x80400000这个位置,那么内核在执行用户程序时就会先执行这个函数,然后当这个函数运行结束后返回时内核就傻了,不知道该返回到哪,只有_start函数最后有系统调用exit,能告诉内核运行下一个用户程序。
然后是batch.c
里的app_init_context
函数:
TrapContext *app_init_context(usize entry, usize sp, TrapContext *cx) {
usize sstatus; asm volatile("csrr %0, sstatus":"=r"(sstatus));
sstatus &= ~(1L << 8);
for (int i = 0; i < 32; i++) cx->x[i] = 0;
cx->sepc = entry; cx->sstatus = sstatus;
cx->x[2] = sp; return cx;
}
C语言调库麻烦,所以这里就硬编码了,sstatus的第8位(从0开始)指定程序所处的态,0为用户态,1为内核态。
然后是run_next_app
,虽然app_init_context
的参数和教程不太一样,但思想都是仿照教程来的:
void run_next_app() {
load_app(current_app); current_app++;
__restore((usize)app_init_context(
APP_BASE_ADDRESS,
(usize)USER_TOP,
(TrapContext *)(KERNEL_TOP - sizeof(TrapContext))
));
}
结合教程提出的问题:
有兴趣的读者可以思考: sscratch 是何时被设置为内核栈顶的?
这里谈一下这一部分栈是怎么切换的。首先明确一点,目前用到的地址全是物理地址,没有用户态和内核态之间的页表切换,riscv和x86不一样,也没有段寄存器这些东西,所以在本部分中sp寄存器指向哪哪就是当前的栈。KERNEL_TOP和USER_TOP虽然为了反映程序中栈从高向低增长的特性,用的都是“TOP”,但是为了贴合现实生活中的栈以方便理解,下面都用“内核栈底”、“用户栈底”来描述。
在程序启动时,和实验一一样,sp指向了boot_stack
,那么那里就是入口函数load_all
所用的栈。然后load_all
调用run_next_app
,后者调用app_init_context
,这里对TrapContext
的操作修改的都是内核栈的栈底,把USER_TOP
传给了TrapContext
里的sp寄存器,并返回了TrapContext
的指针。
然后进入restore,第一句mv sp, a0
,将TrapContext
的指针传给了sp,这一步就相当于重置了内核栈,把内核栈重置为栈底只有一个TrapContext
的状态,同时此处也将程序当前的栈从boot_stack
转到内核栈了。然后各条指令都是在处理内核栈里的那一个TrapContext
。之后csrw sscratch, t2
将刚才传进x[2]的用户栈栈底传给sscratch寄存器,最后一句csrrw sp, sscratch, sp
,sscratch被设置为了内核栈底,sp也指向了用户栈底。这就是上面问题的解答。比较有趣的地方是每次run_next_app
的时候都会重置一下内核栈,这一方面是节省空间,另一方面也是为了写起来便捷,不过在后面的程序中应该会改掉。
trap.S
有个小细节,就是因为代码里用到了宏,如果要用符号常量调用宏,须在代码最前面加上.altmacro
,原因我也不知道,这方面资料太少了,如果不加,宏就会直接把参数替换成符号常量的名字,而不是其代表的值。
最后是trap_handler
函数:
TrapContext *trap_handler(TrapContext *cx) {
usize scause, stval;
asm volatile (
"csrr %0, scause\n"
"csrr %1, stval\n"
:"=r"(scause), "=r"(stval)
);
switch (scause & (~(1L << 63))) {
case 8:
cx->sepc += 4;
cx->x[10] = syscall(cx->x[17], cx->x[10], cx->x[11], cx->x[12]);
break;
default:
panic("Other trap");
}
return cx;
}
同样的,scause的处理我也进行了硬编码,scause的第0到62位存储trap的原因,8表示是来自用户态的系统调用。