rCore-Tutorial-Book-v3学习笔记(二)


概述

第二部分是实现一个批处理系统。批处理系统顾名思义就是能输入好几个程序,然后对这些程序依次执行的操作系统。重点不是在批处理,而是在输入用户程序,这就要求用户程序和我们的系统有一种隔离,所以需要在这一部分的系统实现用户态和内核态的切换。

内容

这一部分的代码文件比上一部分多了好几个,大致说明一下功能:

为了写起来简单,代码基本没怎么对错误情况和异常进行处理,因此可能存在不少漏洞。下面是一些注意点:

首先是lib.c中的入口函数:

int main();
__attribute__((section(".text.entry")))
void _start() {
    clear_bss();
    exit(main());
}

注意第二行,它等价于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表示是来自用户态的系统调用。