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


概述

上一节介绍了文件系统格式的解析,主要涵盖文件系统的文件节点层和块缓存层;本节主要介绍块设备的处理,以及附带的命令行参数、重定向等实现。

内容

先介绍上一节跳过的文件描述符层。基本都是调用文件节点层的各类函数,这里仅给出close和创建函数:

void fnode_close(File *self) {
    FNode *i = (FNode *)self->bind;
    self->occupied = 0; i->refcnt--; bcache_save();
    if (i->refcnt == 0) bd_free(i);
}
isize make_fnode(char *path, usize flags) {
    FNode *i;
    if (flags & O_CREAT) {
        i = inode_get(path, 1); if (!i) return -1;
        inode_clear(i);
    } else {
        i = inode_get(path, 0); if (!i) return -1;
        if (flags & O_TRUNC) inode_clear(i);
    }
    i->refcnt = 1; i->offset = 0; usize fd;
    File *f = alloc_fd(&fd); f->bind = (void *)i;
    f->read = fnode_read; f->write = fnode_write;
    if (flags & O_RDONLY) f->write = illegal_rw;
    if (flags & O_WRONLY) f->read = illegal_rw;
    f->copy = fnode_copy; f->close = fnode_close;
    return fd;
} 

每次close文件的时候都保存一下所有缓存,上一节已经做过介绍了。注意make_fnode函数中创建文件的时候inode_get也有可能返回失败,因为之前限制过了所有路径都必须是绝对路径,那么对于相对路径,也就是路径字符串第一个字符不是“/”就直接返回一个失败值。

有了文件系统,就不需要在内核开始运行的时候把所有程序加载到内存里面了,而是通过调用文件节点层的函数直接读取程序文件的内容:

char *get_app_data_by_name(char *name) {
    usize len = strlen(name); char *s = bd_malloc(len + 2);
    s[0] = '/'; memcpy(s + 1, name, len); s[len + 1] = '\0';
    FNode *fn = inode_get(s, 0); bd_free(s); if (!fn) return 0;
    len = *(int *)(fn->dinode + 4); s = bd_malloc(len);
    fn->offset = 0; inode_read(fn, s, len); bd_free(fn);
    if (((struct elfhdr *)s)->magic != ELF_MAGIC) return 0;
    return s;
}
void list_root() {
    FNode *fn = inode_get("/", 0); int len;
    char *s = inode_list(fn, &len);
    printf("**** ROOT ****\n");
    for (int i = 0; i < len; i += strlen(s + i) + 1)
        printf("%s\n", s + i);
    printf("**************\n");
    bd_free(s); bd_free(fn);
}

这个get_app_data_by_name是通过exec函数执行的,为了方便,我在这个函数之前固定加上一个“/”,因此在调用exec函数和直接从shell输入程序名的时候不需要在程序名前面加上“/”。同时我也把程序文件的合法性判断直接放在这里了,之前基本只有可能出现找不到程序的错误,现在把程序不合法的错误也在这里一并返回。

还有一个要注意的是现在不正确释放文件导致的就不仅仅是内存泄漏了,还可能扰乱文件系统布局,所以在waitpid函数里释放进程文件列表前需要将进程所有文件close。

然后是设备驱动层的处理。上节说过引用的是xv6中的virtio_disk.c,里面有三个给外面调用的函数:virtio_disk_init,virtio_disk_rw和virtio_disk_intr。第三个函数需要在发生外部中断的时候调用,因此得进行设置,同时需要保证外部中断在内核态也可以发生。rustsbi已经帮我们做好了机器态需要的设置,比如通过设置mideleg寄存器将中断转发给内核态。内核态和中断相关的有sstatus寄存器和sie寄存器,要想发生某种中断,需要sstatus的sie位为1,且sie寄存器对应该中断的控制位也为1才能发生。如果没有特别设置的话,在刚启动内核的时候sstatus的sie位为0,进入用户态后sie位变成1,从用户态进入内核态后这一位又会变成0。这就是为什么我们在内核态接收不到时钟中断的原因。所以我们在进行ext2文件系统初始化的时候,需要先通过修改sstatus开启总中断,然后把sie寄存器中外部中断对应位打开,才能读取磁盘:

void load_all() {
    kernel_intr_switch(1);
    usize sie; asm volatile("csrr %0, sie":"=r"(sie));
    sie |= (1 << 9); asm volatile("csrw sie, %0"::"r"(sie));
    ...
void kernel_intr_switch(int on) {
    usize sstatus; asm volatile("csrr %0, sstatus":"=r"(sstatus));
    if (on) sstatus |= (1 << 1); else sstatus &= ~(1 << 1);
    asm volatile("csrw sstatus, %0"::"r"(sstatus));
}

但是我们在内核态不想接收到时钟中断,所以进入内核态时应先关闭sie寄存器中时钟中断对应位,再开启总中断,进入用户态时先关闭总中断,再打开sie寄存器中时钟中断对应位:

void trap_handler() {
    ...
    time_intr_switch(0); kernel_intr_switch(1);
    ...
}
void trap_return() {
    kernel_intr_switch(0); time_intr_switch(1);
    ...
void time_intr_switch(int on) {
    usize sie; asm volatile("csrr %0, sie":"=r"(sie));
    if (on) sie |= (1 << 5); else sie &= ~(1 << 5);
    asm volatile("csrw sie, %0"::"r"(sie));
}

然后是对中断的处理,从用户态来的中断加个判断即可。而从内核态来的中断以前是调用trap_from_return函数,里面直接panic。现在需要在里面加上判断,如果是设备中断则执行设备中断相关操作,由于中断处理完是需要继续执行其他代码的,所以中断前必须保护现场了,因此也得定义保存现场和恢复现场的汇编函数,然后传入stvec寄存器中。这里我发现了一个之前没有发现的错误,就是如果要往stvec寄存器中传函数地址,这个地址必须按4字节对齐,如果是C函数,就必须在函数声明前加__attribute__ ((aligned (4))),难怪我之前内核exception了直接卡死。

这样就能接收到中断了吗?非也。还有一个东西必须设置——平台级中断控制器(PLIC)。这个坑了我很久,一开始以为这个只是用来辨明中断是由哪个设备引起的,没想到没设置这个设备连中断都不会触发。而且这个的设置不归SBI管,这个的相关代码xv6也有,可以直接借鉴,初始化之后就可以接收到中断了。中断处理函数判断是外部中断就执行device_interrupt_handle函数:

void device_interrupt_handle() {
    int irq = plic_claim();
    if (irq == VIRTIO0_IRQ) {
        virtio_disk_intr();
    } else if (irq) {
        printf("Other IRQ: %d\n", irq);
    }
    if (irq) plic_complete(irq);
}

注意有时会出现一些irq为0的不明中断,需要忽略。另外就是处理完中断还需要调用plic_complete函数,不然下次中断又接收不到了。virtio和plic映射的内存区域也要在内核页表中进行映射。

文件的部分基本结束了,接下来是命令行参数和重定向。这需要shell的支持,通过空格分隔输入的命令行,提取出一个字符串指针数组,作为参数调用exec函数:

        int f = 0, l = strlen(line); argc = 0;
        for (int i = 0; i < l; i++) {
            if (line[i] == ' ') {
                if (f) {
                    line[i] = '\0'; f = 0;
                }
            } else if (!f) {
                argv[argc++] = line + i; f = 1;
            }
        }
        argv[argc] = 0;

在sys_exec函数中,需要获得原来的命令行参数,这里直接将传入的字符串指针数组本身及各元素转化为物理地址,然后重新连成一个用\0分隔的长字符串。由于字符串指针数组的长度是不确定的,所以需要使用一个vector存储,不断地通过copy_area获得数组的单个元素,直到0结束,这样虽然速度较慢(更优地方法是直接获得整个物理页帧再在其中找0),但是代码比较简单。同理对于字符串也需要用这种方法一个字符一个字符地转化:

isize sys_exec(char *name, char **argv) {
    struct vector vname; vector_new(&vname, 1);
    struct vector vargv; vector_new(&vargv, 1);
    PhysAddr pgtbl = current_user_pagetable(); usize l = 0;
    for (;;) {
        char c; copy_area(pgtbl, (VirtAddr)name + l, &c, 1, 0);
        vector_push(&vname, &c); vector_push(&vargv, &c);
        if (c == '\0') break; else l++;
    }
    usize argc = 0;
    for (;;) {
        char *addr;
        copy_area(pgtbl, (VirtAddr)argv + argc * sizeof(char *),
                &addr, sizeof(char *), 0);
        if (!addr) break; else argc++; l = 0;
        for (;;) {
            char c; copy_area(pgtbl, (VirtAddr)addr + l, &c, 1, 0);
            vector_push(&vargv, &c); if (c == '\0') break; else l++;
        }
    }
    isize r = exec(vname.buffer, vargv.buffer, vargv.size);
    vector_free(&vname); vector_free(&vargv); return r;
}

exec函数把命令行参数后传给user_init函数,由其初始化用户栈。原来初始化的用户栈栈顶是TRAP_CONTEXT,现在就要压栈,将命令行参数的长字符串复制到对应的用户虚拟地址,然后再重新构建一个字符串指针数组也压入栈中,注意这个字符串指针数组里的元素指向的的是长字符串在栈中的虚拟地址,不是其物理地址:

    // fill argv
    VirtAddr user_sp = TRAP_CONTEXT - argv_len;
    copy_area(tcb->pagetable, user_sp, argv, argv_len, 1);
    struct vector vargv; vector_new(&vargv, sizeof(char *));
    for (usize i = 0; i < argv_len; i += strlen(argv + i) + 1) {
        char *t = (char *)user_sp + i; vector_push(&vargv, &t);
    }
    user_sp -= vargv.size * sizeof(char *);
    copy_area(tcb->pagetable, user_sp, vargv.buffer,
            vargv.size * sizeof(char *), 1);

记得把trap_context里的sp寄存器和用来表示参数的x10、x11寄存器值改一下:

   trap_cx->x[2] = trap_cx->x[11] = user_sp; trap_cx->x[10] = vargv.size;

这里还有一个坑,在系统调用结束的时候,我们需要将系统调用的返回值放入trap_context里表示返回值的寄存器,然而这个寄存器也是x10,和表示参数的x10正是同一个。所以exec返回以后第一个参数会被覆盖。解决方法是加一个特判,当系统调用是exec时直接不设返回值了,反正如果exec正常执行的话回到用户态时已经是另一个程序了,没人关心exec的正常返回值是什么:

    if (scause == 8) {
        cx->sepc += 4; int is_exec = cx->x[17] == SYSCALL_EXEC;
        isize result = syscall(cx->x[17], cx->x[10], cx->x[11], cx->x[12]);
        cx = current_user_trap_cx();
        if (!is_exec || result == -1) cx->x[10] = result;
        ...

最后就是重定向。这里简化一下,当输入的命令行经过空格分隔之后倒数第2个字符串是<或者>才执行重定向。具体就是打开重定向目标文件,并关闭标准输入/输出,然后复制目标文件,默认复制到刚刚关闭的标准输入/输出,然后把目标文件关闭,这样标准输入/输出就被重定向到目标文件了:

            if (argc > 2) {
                if (argv[argc - 2][0] == '<') {
                    isize f = open(argv[argc - 1], O_RDONLY);
                    if (f < 0) {
                        printf("Redirect failed!\n");
                        return -4;
                    }
                    close(0); dup(f); close(f); argv[argc - 2] = 0;
                }
                if (argv[argc - 2][0] == '>') {
                    isize f = open(argv[argc - 1], O_CREAT | O_WRONLY);
                    if (f < 0) {
                        printf("Redirect failed!\n");
                        return -4;
                    }
                    close(1); dup(f); close(f); argv[argc - 2] = 0;
                }
            }

原来关闭标准输入/输出的函数里没有内容,现在就加上将对应文件结构体的occupied的属性置为0的操作即可。dup也不难,先调用alloc_fd函数(分配的是编号最小的未占用文件结构体,这里就是刚刚关闭的标准输入/输出),然后把参数文件结构体的内容复制过去,再调用文件结构体的copy函数复制一下内部绑定的资源就可以了:

isize sys_dup(usize fd) {                                                           File *f = current_user_file(fd); if (!f) return -1;
    File *o = alloc_fd(&fd); *o = *f; f->copy(f); return 0;
}

至此,rCore-Tutorial-Book-v3学习笔记就结束了。主要原因是这个操作系统和rCore的差别已经越来越大了,从内存管理时利用C语言过程式写出来的代码,到进程管理时重新排布了用户地址空间的布局,再到文件系统基本另起炉灶,后面再扩展恐怕也很难借鉴rCore了,需要查找其他资料。未来的目标是将目前的这个操作系统移植到开发板上、增加多CPU支持、探索更多的设备(比如显卡、网卡、声卡等),以及移植到多架构(比如ARM、X86等)……

rCore-Tutorial-Book-v3这个教程真的很不错,借着开源指令集RISCV的东风,非常清晰易懂,而且是从零开始,对新手小白十分友好,非常感谢教程作者老师同学们的付出!