--- title: nju pa3 categories: [lab,nju-pa] --- # 穿越时空的旅行 ## 异常响应机制及CTE ### 实现异常响应机制 我们要实现操作系统的自陷功能,虽然中断的大致原理和流程上课都讲过,但不同操作系统有着不同的具体设计,因此在这之前有必要结合文档与源码过一遍我们框架代码中“异常响应机制”的流程 riscv对于中断与异常提供了各种令人眼花缭乱的CSR控制状态寄存器,我们这里涉及到的有: - mtvec寄存器 - 存放了发生异常时处理器需要跳转到的地址 - mepc寄存器 - 存放发生异常的指令地址,用与异常处理返回时能回到原本程序执行的位置 - mstatus寄存器 - 存放处理器的状态 - mcause寄存器 - 存放异常的种类 另外根据文档中提到 > 首先当然是对`R`的扩充, 除了PC和通用寄存器之外, 还需要添加上文提到的一些特殊寄存器 所以我们先给处理器添加上述的几个CSR ``` c typedef struct { word_t mcause; vaddr_t mepc; word_t mstatus; word_t mtvec; } riscv64_CSRs; typedef struct { word_t gpr[32]; vaddr_t pc; riscv64_CSRs csr; } riscv64_CPU_state; ``` 定义好了需要到的寄存器后,接下来我们就结合nanos-lite的运行来分析中断响应流程吧~在此之前需要再次明确一下模拟器的各层的关系:**am是硬件层(确切地说是抽象硬件,对操作系统屏蔽了架构的差异),nanos-lite是操作系统层**。我们从nanos-lite的入口,main函数看起,其中中断相关我们只需要关注`init_irq`以及`yield`,先回忆一下,还记得文档说的那句话吗? > 我们刚才提到了程序的状态, 在操作系统中有一个等价的术语, 叫"上下文". 因此, 硬件提供的上述在操作系统和用户程序之间切换执行流的功能, 在操作系统看来, 都可以划入上下文管理的一部分. `init_irq`里调用的`cte_init`,其实就是操作系统向硬件注册事件发生(如中断)的回调函数`do_event`,这个回调函数就是真正把异常交给操作系统处理的地方(要与异常处理入口函数区分开来),其中的参数为事件和相关的程序上下文。那么这个回调函数什么时候被调用呢,显然是异常发生的时候。 我们接着查看`cte_init`的代码,**其功能简单地说就是保存异常处理入口函数地址,以及保存用户回调函数即上述的do_event**,以便异常处理过程中时调用这个用户回调函数。其中异常处理入口函数是`__am_asm_trap`,这个函数在trap.S这个文件中用汇编语言实现,暂时不管,先接着看流程。 接下来在main函数的最后调用了`yield`,如果说`init_irq`描述如何处理异常,那么`yield`就是真正触发了一个异常(自陷),并进入之前注册的异常处理函数进行异常处理。`yield`只有两句汇编指令 ``` assembly li a7, -1 ecall ``` 将异常种类存放到a7寄存器中,以及发起自陷,其中`ecall`会使得程序流程转到之前注册的异常处理入口函数中去执行,即`__am_asm_trap`,这里就得分析一下这个函数都干了些什么了: ``` assembly __am_asm_trap: ... jal __am_irq_handle ... mret ``` 目前只关注运行流程,多余的代码先去除,`__am_asm_trap`简单来说是提供了统一的异常入口地址,主要作用是将csr和gpr的内容作为参数调用`__am_irq_handle`并在其返回后把csr和gpr的新值再存回去(值得一提的是,csr和gpr作为cpu的寄存器,am将他们包装在上下文结构中传给了操作系统,而不是让操作系统直接访问cpu,体现了处处都是抽象和屏蔽的艺术)。`__am_irq_handle`这个函数也是定义在抽象硬件层(am)中的,通过判断程序上下文内容(比如在riscv-nemu中通过分支`mcause`的值)来构造事件,最终将事件和上下文一并通过回调函数传给操作系统,开始真正的异常处理....至此从异常注册到异常触发及响应的流程分析就结束了,**如果说PA3之前的工作还没对这些抽象硬件、操作系统层等形成认知,或者到了PA3这个部分依然存疑,建议一定要好好做这一小节的内容并去认真体会它是如何设计的,因为确实值得**。 若理解了流程,剩下的填代码环节就是顺便的事情了。首先实现几条指令,csr的读写指令和ecall指令 ``` c INSTPAT("??????? ????? ????? 001 ????? 11100 11", csrrw , I, R(dest) = CSR(imm); CSR(imm) = src1); INSTPAT("??????? ????? ????? 010 ????? 11100 11", csrrs , I, R(dest) = CSR(imm); CSR(imm) |= src1); INSTPAT("0000000 00000 00000 000 00000 11100 11", ecall , I, ECALL(s->dnpc)); ``` 其中两个新宏`CSR`, `ECALL`如下: ``` c static vaddr_t *csr_register(word_t imm) { switch (imm) { case 0x341: return &(cpu.csr.mepc); case 0x342: return &(cpu.csr.mcause); case 0x300: return &(cpu.csr.mstatus); case 0x305: return &(cpu.csr.mtvec); default: panic("Unknown csr"); } } #define ECALL(dnpc) { bool success; dnpc = (isa_raise_intr(isa_reg_str2val("a7", &success), s->pc)); } #define CSR(i) *csr_register(i) ``` 然后去实现`isa_raise_intr`,这个函数其实就是模拟了`ecall`的功能,即使得程序跳转到异常处理中,根据注释提示我们知道`NO`对应异常种类,`epc`对应触发异常的指令地址,最后返回异常入口地址 ``` c word_t isa_raise_intr(word_t NO, vaddr_t epc) { cpu.csr.mcause = NO; cpu.csr.mepc = epc; return cpu.csr.mtvec; } ``` ### 让DiffTest支持异常响应机制 这里有点坑的,文档只是提到了mstatus要初始化为`0xa00001800`,但是我们得跑去c++代码里添加些代码来使得difftest支持新加入的CSR寄存器,首先在difftest.cc中给`diff_context_t`添加新的寄存器,注意添加顺序要和riscv64_CPU_state中的寄存器顺序相同 ``` c++ struct diff_context_t { word_t gpr[32]; word_t pc; // control and status registers word_t mcause; vaddr_t mepc; word_t mstatus; word_t mtvec; }; ``` 添加了寄存器,当然也要修改读写寄存器的函数:`diff_get_regs`和`diff_set_regs`,聪明的你不用我再告诉你怎么修改了吧 然后修改比较ref和dut寄存器的代码,这里用宏重写了一下代码,使得代码更加简洁 ``` c #define CHECKDIFF(p) if (ref_r->p != cpu.p) { \ printf("difftest fail at " #p ", expect %#lx got %#lx\n", ref_r->p, cpu.p); \ return false; \ } #define CHECKDIFF_FMT(p, fmt, ...) if (ref_r->p != cpu.p) { \ printf("difftest fail at " fmt ", expect %#lx got %#lx\n", ## __VA_ARGS__, ref_r->p, cpu.p); \ return false; \ } bool isa_difftest_checkregs(CPU_state *ref_r, vaddr_t pc) { int reg_num = ARRLEN(cpu.gpr); for (int i = 0; i < reg_num; i++) { CHECKDIFF_FMT(gpr[i], "gpr[%d]", i); } CHECKDIFF(pc); CHECKDIFF(mstatus); CHECKDIFF(mcause); CHECKDIFF(mepc); CHECKDIFF(mtvec); return true; } ``` 这里有一个想了比较久的bug:**`CHECKDIFF(mcause)`会使得difftest无法通过**,也就是说ref的mcause和我们的mcause的值不一样导致difftest不通过,经过上一个小节分析可以知道,我们的mcause在`ecall`时赋了a7寄存器的值,然而通过打印信息可以发现,无论a7改成什么值,ref的mcause都是`0xb`。最后查阅rv的手册发现是自己没好好看手册的问题,mcause=0xb表示的是environment call from M-mode,由于我们全程都在M模式下跑,因此ecall对应的mcause就是0xb ### 保存上下文 重新组织`Context`结构体,观察trap.S中将参数保存到栈中的顺序,调整`Context`内的字段的声明顺序与保存顺序对应即可 ### 事件分发 有了前文铺垫,这里就不难了 ### 恢复上下文 恢复上下文最重要的是恢复执行流程,即恢复pc,触发中断的指令地址保存在`mepc`中,那么`mret`就负责从用这个寄存器恢复pc。需要注意的是自陷只是其中一种异常类型. 有一种故障类异常,此异常返回的PC无需加4,所以根据异常类型的不同, 有时候需要加4, 有时候则不需要加,在什么地方做这个+4的决定呢?我们在`ecall`时判断中断类型并+4即可 ### etrace 在ecall指令的实现中进行exception tracing # 用户程序和系统调用 ## 加载第一个用户程序 ### 实现loader `resource.S`文件里用汇编将`ramdisk.img`放在了静态存储区data section(实际上程序应该放在硬盘中,但就如文档所说当前用ram来模拟,因此放在了内存中的静态存储区),我们需要负责判断elf合法性,如果合法的话,我们需要做的是把程序加载到它应该处于的内存段中,以及将.bss段(全局变量区)清零,其中phdr(program header)中的`p_vaddr`即程序段应该加载到的内存段的首地址 ``` c static uintptr_t loader(PCB *pcb, const char *filename) { Elf_Ehdr ehdr; ramdisk_read(&ehdr, 0, sizeof(Elf_Ehdr)); // check valid elf assert((*(uint32_t *)ehdr.e_ident == 0x464c457f)); Elf_Phdr phdr[ehdr.e_phnum]; ramdisk_read(phdr, ehdr.e_phoff, sizeof(Elf_Phdr)*ehdr.e_phnum); for (int i = 0; i < ehdr.e_phnum; i++) { if (phdr[i].p_type == PT_LOAD) { ramdisk_read((void*)phdr[i].p_vaddr, phdr[i].p_offset, phdr[i].p_memsz); // set .bss with zeros memset((void*)(phdr[i].p_vaddr+phdr[i].p_filesz), 0, phdr[i].p_memsz - phdr[i].p_filesz); } } return ehdr.e_entry; } ``` ## 系统调用 ### 识别系统调用 看到navy-apps里有个syscall.h文件,里面定义了所有的系统调用号,系统调用号会中断原因,因此我们只需要在cte中分发事件时,根据`mcause`来分发事件,并在操作系统的事件处理中,判断事件类型为`EVNET_SYSCALL`,调用`do_syscall` ### 实现yield和exit系统调用 1. 对于yield和exit的系统调用,分别调用`yield`和`halt`即可 2. 设置系统调用返回值:观察`_syscall_`的代码,发现是从`a0`寄存器取得系统调用的返回结果,因此修改`$ISA-nemu.h`中`GPRx`的宏定义,将其改成寄存器`a0`的下标,然后就可以在操作系统中通过`c->GPRx`根据实际情况设置返回值了 ## 系统调用的踪迹 ### 实现strace 在`do_syscall`中根据系统调用号输出是什么系统调用就行了...(跟linux的实在是不一样) ## 操作系统之上的TRM ### 在Nanos-lite上运行Hello world 这块应该比较简单了,另外有个坑要解决,得实现后面的堆区管理,否则每次只会输出第一个字符 ### 实现堆区管理 我们程序加载进内存之后,其中存在一个名为用户程序的数据段的段,并且链接的时候`ld`会默认添加一个名为`_end`的符号, 来指示程序的数据段结束的位置(program break),在这个结束位置之后的位置用户不能再随便往里面存东西, 必须通过`sbrk`来向系统申请,本质就是更新这个program break,通过`man 3 end`查阅了`_end`用法后,在用户层维护并更新这个program break即可: ``` c extern char end; void *_sbrk(intptr_t increment) { static char *myend = &end; if (_syscall_(SYS_brk, increment, 0, 0) == 0) { void *ret = myend; myend += increment; return (void*)ret; } return (void*)-1; } ``` # 简易文件系统 ## 实现文件系统 ### 让loader使用文件 首先在fs.c中借助`ramdisk`实现这些api ``` c int fs_open(const char *pathname, int flags, int mode); size_t fs_read(int fd, void *buf, size_t len); size_t fs_write(int fd, const void *buf, size_t len); size_t fs_lseek(int fd, size_t offset, int whence); int fs_close(int fd); ``` 注意要在`Finfo`中添加一个`open_offset`字段来记录文件当前读写位置,并更新维护它,完事了之后在loader中使用即可。如果实现这些api时遇到bug的话确实不好看出来,有一个方法是:fs最终是用ramdisk读写的,用printf打印调用ramdisk读写的参数,与原来loader中的ramdisk的读写参数做对比,这样就能很快找到bug了 ### 实现完整的文件系统 这个任务的目标是通过file-test测试,我们只需要把系统调用实现好,主要的地方还是在于fs.c,如果遇到bug可以查看那几个相关的文件(syscall, fs),通过打印函数调用进行调试。我做的时候就因为`SYS_read`写成`SYS_write`,还有就是之前实现过的`sys_write`函数只处理了标准流的情况,导致调了很久的bug,有时候越简单越容易犯错,还是得谨慎点,按步骤写,不要想到哪里写到哪里,比如先写好调好检查运行流程是否走到了系统调用,再去实现系统调用的具体功能.... 还有一个需要注意的地方,虽然用户程序使用的是`fseek`和`ftell`,但是我们实现的是简化版本的操作系统,实际上内部使用的是`lseek`系统调用,查询`lseek`的用法,调用成功的时候返回值为文件的当前偏移量 ## 一切皆文件 ### 把串口抽象成文件 注意在数组中先把`stdout`和`stderr`的`write`函数初始化为`serial_write` ``` c size_t fs_write(int fd, const void *buf, size_t len) { assert(fd >= 0 && fd <= LENGTH(file_table)); Finfo *f = file_table + fd; if (f->write != NULL) { return f->write(buf, 0, len); } else { size_t real_len = REAL_LEN(f, len); if (real_len <= 0) return 0; size_t ret = ramdisk_write(buf, f->open_offset, real_len); f->open_offset += real_len; return ret; } } ``` ### 实现gettimeofday 还记得之前在AM实现了这个关于获取时间的ioe吗,直接拿来用就可以了,在操作系统层面,使用`io_read`来与抽象IO设备交互,因此借助这个函数: ``` c static uintptr_t sys_gettimeofday(uintptr_t *a) { struct timeval* tv = (struct timeval*)a[1]; struct timezone* tz = (struct timezone*)a[2]; uint64_t us = io_read(AM_TIMER_UPTIME).us; if (tv != NULL) { tv->tv_sec = us / (1000*1000); tv->tv_usec = us % (1000*1000); } if (tz != NULL) { // to implement } return 0; } ``` 另外timer-test的测试用例是自己写,每0.5秒输出一次信息,输出什么都可以,简单验证一下系统调用的正确性 ``` c int main() { struct timeval tv; // struct timezone tz; gettimeofday(&tv, NULL); __uint64_t ms = 500; while (1) { while ((tv.tv_sec * 1000 + tv.tv_usec / 1000) < ms) { gettimeofday(&tv, NULL); } ms += 500; printf("ms = %d\n", ms); } } ``` ### 实现NDL的时钟 代码中`NDL_GetTicks`也没有相关功能的注释,我们先查阅一波`SDL_GetTicks`这个[API](https://wiki.libsdl.org/SDL2/SDL_GetTicks)(盲猜NDL是SDL的什么简化版吧),这个函数功能返回自从SDL库初始化到现在经过的毫秒值 首先明确一下`gettimeofday`系统调用相关的ioe无论是使用`clock_gettime`还是`gettimeofday`(linux提供的版本,不是我们自己的),返回的时间都不是从0开始,前者是宿主机器开机到现在经过的时间,后者是从1970到现在经过的时间。所以我们还得保存一个NDL初始化时的时间,以便实现**自从SDL库初始化到现在经过的时间**的功能。最后把测试改成使用`NDL_GetTicks`来获取时间就行了 还有一点就是,navy-apps的make在编译链接的时候,没有加入libndl这个库,我们得打开Makefile把这个库加进去 ### 把按键输入抽象成文件 首先是在fs中添加文件,这个不用说了,抽象层比较简单的,脏活累活都是底层做的 然后就是真正读键盘的操作,借助之前实现的`io_read(AM_INPUT_KEYBRD)`,就可以轻松实现`events_read`了,也没有多脏多累 ``` c size_t events_read(void *buf, size_t offset, size_t len) { AM_INPUT_KEYBRD_T ev = io_read(AM_INPUT_KEYBRD); if (ev.keycode == AM_KEY_NONE) { *(char*)buf = '\0'; return 0; } int ret = snprintf(buf, len, "%s %s\n", ev.keydown?"kd":"ku", keyname[ev.keycode]); printf("%s\n", buf); return ret; } ``` 最后就是应用层的NDL,可以说是很简单了,一行代码: ``` c int NDL_PollEvent(char *buf, int len) { return read(evtdev, buf, len); } ``` 当然了,代码几乎没有做任何cheking,主要是理解原理即可,不需被checking打乱真正的思路。还有一个要注意的就是文档中提到的: > 用fopen()还是open()? 第一个区别在于`fopen`属于缓冲文件系统,`fopen, fclose, fread, fwrite, fseek`等都有缓冲区,而`open`属于非缓冲文件系统,相关文件操作的函数有`open, close, read, write, lseek`,由于键盘是字符设备,而且写入速度(用户键盘输入)十分慢,不需要缓冲区,因此选择后者。 第二个区别在这里倒不是太重要,关于可移植性,感兴趣自己查 ### 在NDL中获取屏幕大小 `NDL_OpenCanvas`这个函数中与`NWM_APP`相关的代码我们不用管,也不要删,直接在后面写我们的代码即可。借助`/proc/dispinfo`这个文件读取屏幕的大小,保存到变量中以待用。另外还需要根据文档提示检查`w`和`h`的值 ### 把VGA显存抽象成文件 `NDL_DrawRect`就是不断使用`write`,每次一行像素地写入fb即可,每次只能写一行的原因是文件系统的接口限制了我们无法将画布宽高也同时传进`write`中,fb的写操作只会根据屏幕大小,行优先地写入帧缓冲中 至于居中画布这个操作,其实就是把画布的左上角坐标从`(0, 0)`移动到`((screen_w+w)/2, (screen_h+h)/2)`而已,可以在`NDL_DrawRect`中进行这个操作