LAB3概述:
本次操作系统实验,我们对计算机的操作系统进行了初步的探究,通过完成作业和问题,对lab3部分有了较好的理解。Lab3主要实现能运行被保护的用户模式环境(protected user-mode environment,即process)的内核服务。我们将增加数据结构来记录进程、创建进程、为其装载一个程序镜像。我们还要让JOS 内核能够处理进程产生的系统调用和异常。具体来说:
第一部分主要完成用户环境和异常处理,我们主要先为我们的环境申请地址并进行映射,之后改写env.c中的相应函数来使得我们可以让系统进入用户空间执行我们想要的系统调用。接下来我们对处理中断和异常进行了探索,建立了我们自己的中断描述符表IDT,在trapentry.s和trap.c对产生中断的一开始过程深入了解。
第二部分处理缺页错误,断点异常以及系统调用。我们逐个对缺页中断、断点异常、系统调用等等的中断进行处理,通过对应参数调用不同的函数对相应的中断进行对应处理。我们在最后完成了用户进程的启动,以及对页错误和内存的保护。
此外,在完成这两大块内容的同时,对于问题的完成也使我们对于一些基础概念的理解,以及代码部分知识的掌握有了进一步的提升。
作业一
一.准备知识
Env数据结构代表一个进程描述符,定义在env.h中,包括进程的id,父进程的id,执行状态,该进程的寄存器状态,执行的次数等,并使用env_link指向下一个空闲的Env。
所有Env对象存储在envs数组中,该数组定义在env.c的开头。
除此之外curenv代表当前正在执行的进程,env_free_list指向空闲的进程描述符,组成链表,链表的添加与删除均在表头执行。
二.代码展示
修改mem_init() 使之能分配envs数组。这个数组是由NENV个Env结构体组成的。envs数组所在的这部分内存空间应该是用户模式只读的。
如题,类似lab2中分配pages数组,分配空间给数组envs同时初始化。
在lab2中分配pages数组时操作。
类似的分配env数组空间,并初始化
然后可以将虚拟内存的UENVS段映射到envs的物理地址
用到函数boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_tpa, int perm);
作业二
一.函数介绍
env_init(): 初始化所有的在envs数组中的 Env结构体,并把它们加入到env_free_list中。还要调用 env_init_percpu,这个函数要配置段式内存管理系统,让它所管理的段,可能具有两种访问优先级其中的一种,一个是内核运行时的0优先级,以及用户运行时的3优先级。
env_setup_vm(): 为一个新的用户环境分配一个页目录表,并且初始化这个用户环境的地址空间中的和内核相关的部分。
region_alloc(): 为用户环境分配物理地址空间。
load_icode(): 分析一个ELF文件,类似于bootloader做的那样,我们可以把它的内容加载到用户环境下。
env_create(): 利用env_alloc函数和load_icode函数,加载一个ELF文件到用户环境中。
env_run(): 在用户模式下,开始运行一个用户环境。
二.代码实现
1)env_init():进程描述符数组初始化
按照注释,遍历数组envs,要把它的id设为0,并且令envs中所有环境都是free状态,然后按序插入链表中。此时注意第一个被使用的是envs[0]。
2)env_setup_vm():初始化进程的虚拟空间。不同的进程有不同的虚拟地址空间,进而就必须有自己的页目录和页表,该函数的任务就是初始化页目录。
本题注释讲的很清楚,The VA space of all envs is identical (same)above UTOP。进程的页目录和内核的页目录(utop之上)可以共用,所以先分配一个空闲页,然后直接copy就可以,我们不需要做任何其他事情。接着增加分配的物理页的引用,将此页的虚拟地址赋值给进程的pgdir,然后使进程有权限操作自己的pgdir。
3)region_alloc():分配物理地址。把起始地址和终止地址对齐,然后以页为单位,一页一页分配内容,并且修改页目录表和页表。
为进程分配物理内存,映射到虚拟内存。round函数对起始地址和终止地址页对齐。
然后就可以以页单位给进程进行内寸分配了。然后修改页目录和页表
4)通过load_icode给相应的进程加载可执行代码。可执行代码的格式是elf格式。
5)env_creat():简单的函数,新建进程,调用env-alloc。然后利用load-icode读取elf。
跟着注释一步步完成。先用函数env-alloc 分配新的环境。注意int r = env_alloc(&e, 0) 这里0是新进程的父进程的id,注释要求设为0。这个函数返回的是一个int型的数若小于0表示分配环境失败,若为0表示分配环境成功。成功后就可以读取elf了。然后注释要求改变类型
6)env_run():启动一个进程,这个函数注释解释的更清楚了
第一步检查目前的环境状态,要是是running,那么让他进入就绪状态,然后用新进程e进入环境,并使他成为running态,更新env-runs等等不再赘述。看最后一句,env_pop_tf(&e->env_tf); //use pop-tf store env-tf,查看load-icode函数,e->env_tf.tf_eip=ELFHDR->e_entry;这个时候已经到达可执行文件的入口。
注释里有个函数env_pop_tf
env_pop_tf首先将传入的trapframe,包含所有的寄存器信息压栈,然后使用iret,即中断返回来执行
三.运行结果。
到目前为止,JOS还没有设置相关硬件来实现从用户态向内核态的转换功能。当CPU发现,它没有被设置成能够处理这种系统调用中断时,它会触发一个保护异常,然后发现这个保护异常也无法处理,从而又产生一个错误异常,然后又发现仍旧无法解决问题,所以最后放弃,我们把这个叫做"triple fault"。
作业三
一.基本知识
本问及之后的练习为中断和异常处理部分,所谓中断和异常都是“受保护的控制权转移”(protected control transfers),使处理器从用户模式转到内核模式,用户模式代码无法干扰内核或者其他进程的运行。中断(Interrupt)是一种保护模式下的控制权转移,通常是由外部的异步事件例如外部设备的I/O 行为通知处理器所造成的;异常(Exception)则是由当前正在运行的代码的同步事件所造成的控制权转移,比如除零错误或者非法的内存访问。
在x86体系中,这种保护以如下两种方式体现:
1. 中断描述符表(The InterruptDescriptor Table,IDT)。处理器保证中断或者异常导致控制权转移到内核时,只会在内核预定好的特殊的、完善设计的程序入口处进入,而这些程序的入口与中断或者异常发生时所正在运行的代码无关。
在JOS操作系统中定义了以下的中断号来作为对应的入口点
2. 任务状态段(The Task StateSegment,TSS)。为了保证在内核中保证中断处理程序能够拥有一个完善的载入点(entry-point),在处理器执行中断处理程序之前,也需要一个地方来存储旧的处理器状态,比如EIP和CS的值,这样就可以在执行完中断处理程序之后,处理器还可以继续执行被中断的程序
二.重要的类型结构-Trapframe
在inc/trap.h中定义了Trapframe类型,用来定义一个trap的结构,其包含的数据类型如下图所示:
其中除了一些es、ds、cs等寄存器,需要注意的还有tf_trapno代表了中断的类型,tf_regs带面了中断发生时用户环境通用寄存器的内容。
三.作业分析
在作业三中,我们需要设置IDT,并且为每一个中断和异常设置仔细的处理函数(handler)。我们需要将IDT初始化为这些handler的入口地址,并且调用trap()函数,通过Trapframe的指针我们可以判断异常或错误类型,并分配给相应的处理函数。
四.代码讲解
在完成代码之前,首先需要理解以下段个主要的trap处理汇编代码的含义,以下代码位于kern/trapentry.S中。就其相同的部分来说,在两个函数中,都定义了一个全局的name,并且把name声明为函数类型,即为中断的处理函数handler,进行对齐之后,函数将num压栈,并执行_alltraps函数。而两个函数不同的部分是是否需要压入一个错误号,具体哪些中断或异常需要压入可以搜索中断符号表查询。
于是,我们便可以使用以上定义好的函数为trap定义入口点,lab3代码如下图:
紧接着,我们来看一下比较重要的_alltraps汇编代码。正如我们之前提到的,操作系统在处理中断和异常时,需要维护发生中断时用户环境的处理器状态,以用于处理之后的恢复,而此段代码正是通过将用户环境的数据、代码、状态压入内核栈来实现以上的目标。其中,pushal为压入全部通用寄存器的值。并且需要注意的是,不能用立即数直接给段寄存器赋值,所以在压入GD_KD时借助了寄存器ax。
在完成以上步骤之后,我们来到kern/trap.c文件,首先我们需要声明每个中断和异常的处理函数。并且使用提供的SETGATE()函数为每个中断设置入口点。
在inc/mmu.h中定义了SETGATE(),如图
其中gate 为struct Gatedesc,所以采用idt[T_*]表示门描述符,istrap trap(exception) 则为1,是interrupt 则为0。Sel为代码段选择器,进入内核的话是GD_KT,off 相对于段的偏移,简单来说就是函数地址。dpl(Descriptor Privileged Level),表示权限,0为内核态,3为用户态。根据以上的信息,我们可以完成每个中断异常的SETGATE的部分。至此作业三的部分全部完成。
作业四——缺页中断
本作业相对较为简单,它要求我们在kern/trap.c中完成一个基础的中断选择操做,并且简单的调用题中所说的page_fault_handler()函数,为之后的作业做准备。于是,在trap_dispatch()中,我们用switch-case对Trapframe类型的中断异常进行判断选择。
代码如下:
问题一:
(1)答:每个异常和中断处理方式不同,有些需要直接终止产生中断的程序,如除0的操作,而有些需要进行更为复杂的处理,如产生缺页中断时,需要CPU多次访问内存对页进行写回和查找,并返回。这些操作难以用一个handler完成
(2)答:
user/softint.c代码如下:
Grade_lab3对应的评分标准如下:
程序代码中希望产生一个缺页异常(int $14),而实际产生的是通用保护异常(int $13)。该程序在系统的用户态去使用系统指令INT,所以首先会引发 Gerneral Protection Excepetion。即 trap 13。
问题二
1.答:
通过实验发现出现这个现象的问题就是在设置IDT表中的breakpoint exception的表项时,如果我们把表项中的DPL字段设置为3,则会触发breakpoint exception,如果设置为0,则会触发generalprotection exception。DPL字段代表的含义是段描述符优先级(Descriptor Privileged Level),如果我们想要当前执行的程序能够跳转到这个描述符所指向的程序哪里继续执行的话,有个要求,就是要求当前运行程序的CPL,RPL的最大值需要小于等于DPL,否则就会出现优先级低的代码试图去访问优先级高的代码的情况,就会触发general protection exception。那么我们的测试程序首先运行于用户态,它的CPL为3,当异常发生时,它希望去执行int3指令,这是一个系统级别的指令,用户态命令的CPL一定大于int3 的DPL,所以就会触发general protectionexception,但是如果把IDT这个表项的DPL设置为3时,就不会出现这样的现象了,这时如果再出现异常,肯定是因为我们还没有编写处理breakpoint exception的程序所引起的,所以是break point exception。
2.答:
在inc/mmu.h中可以找到:
优先级低的代码无法访问优先级高的代码,优先级高低由gd_dpl 判断。数字越小越高
作业五
断点异常,中断向量3(T_BRKPT) 允许调试器给程序加上断点。原理是暂时把程序中的某个指令替换为一个1 字节大小的int3软件中断指令。在JOS 中,我们将它实现为一个伪系统调用。这样,任何程序(不限于调试器)都能使用断点功能。
跟之前的练习实现方法是一样的。另外需要找到在kern/monitor.c中的void monitor(structTrapFrame *tf)函数。改写trap_dispatch 函数,加入断点处理。
第一次运行发现并没有通过检验,报的是通用保护异常。一看是权限问题。把Exercise 4 中的
SETGATE(idt[T_BRKPT], 1, GD_KT, handler3,0);
改为:
SETGATE(idt[T_BRKPT], 1, GD_KT, handler3, 3);
作业六
这个头文件主要定义了系统调用号,实际就是一个enum 而已。
这是系统调用的通用模板,不同的系统调用(例如sys_cputs, sys_cgetc) 都会以不同参数调用syscall函数。
为了了解syscall 函数到底做了什么,需要看懂其中的内联汇编部分。
GCC内联汇编,其语法固定为:asm volatile (“asm code”:output:input:changed);
"a"、"b"、"c"、"d"、"S"、"D"分别表示寄存器eax、ebx、ecx、edx、esi、edi
所以看代码中:
根据表格内容,可以看出该内联汇编作用就是引发一个int中断,中断向量为立即数T_SYSCALL,同时,对寄存器进行操作。这一部分应该不需要我们改动,因为我们处理的是中断已经产生后的部分。
kern/trap.c
首先不要忘记在trap_init 中设置好入口,并且权限设为3,使得用户进程能够产生这个中断。
SETGATE(idt[T_SYSCALL], 0, GD_KT,handler48, 3);
另外就是trap_dispatch 函数中加入相应的处理方法:
由于已经通过lib/syscall.c 处理,tf 结构体中存储的寄存器状态已经记录了系统调用号,系统调用参数等等。现在我们就可以利用这些信息调用kern/syscall.c 中的函数了。
kern/syscall.c
我们在kern/trap.c 中调用的实际上就是这里的syscall 函数,而不是lib/syscall.c 中的那个。想明白这一点,设置参数也就很简单了,注意返回值的处理。
至此,本exercise 结束,运行make grade 可以通过testbss,运行make run-hello 可以打印出hello world,紧接着提示了页错误。通过exercise 7,可以看出JOS系统调用的步骤为:
1.用户进程使用inc/ 目录下暴露的接口
2.lib/syscall.c 中的函数将系统调用号及必要参数传给寄存器,并引起一次int $0x30 中断
3.kern/trap.c 捕捉到这个中断,并将TrapFrame 记录的寄存器状态作为参数,调用处理中断的函数
4.kern/syscall.c 处理中断
作业七
Add the required code to the user library,then boot your kernel. You should see user/hello print "hello, world"and then print "i am environment 00001000". user/hello then attemptsto "exit" by calling sys_env_destroy() (see lib/libmain.c andlib/exit.c). Since the kernel currently only supports one user environment, itshould report that it has destroyed the only environment and then drop into thekernel monitor. You should be able to get make grade to succeed on the hellotest.
首先,查看user/hello.c,可以看到通过thisenv->env_id获取当前进程id,这个变量是在inc/lib.h定义的
用户程序在lib/entry.S 的顶部开始运行。进行一些设置之后,这部分代码会调用lib/libmain.c 中的libmain()函数。之后,libmain()调用umain,假设当前是运行在user/hello.c 中的hello 程序。可以看到,在进程的主函数运行之前,这里对thisenv 进行了赋值。这个函数负责初始化全局的指向这个程序在envs[]数组中的Env 结构的env 指针(注意lib/entry.S 已经定义了envs 来指向UENVS)可查看inc/env.h,使用sys_getenvid获取当前进程id。于是给thisenv赋值为&envs[ENVX(sys_getenvid())]。
运行结果如下:
作业八
Change kern/trap.c to panic if a pagefault happens in kernel mode.Hint: to determine whether a fault happened inuser mode or in kernel mode, check the low bits of the tf_cs.Readuser_mem_assert in kern/pmap.c and implement user_mem_check in that samefile.Change kern/syscall.c to sanity check arguments to system calls.Boot yourkernel, running user/buggyhello. The environment should be destroyed, and thekernel should not panic. You should see:
[00001000] user_mem_check assertionfailure for va 00000001[00001000] free env 00001000Destroyed the onlyenvironment - nothing more to do!
Finally, change debuginfo_eip inkern/kdebug.c to call user_mem_check on usd, stabs, and stabstr.
首先在kern/trap.c中,在前面的题目中,我们有在trap_dispatch()中定义,在发生缺页错误时,触发缺页中断,调用page_fault_handler()函数,按照题目要求去修改该函数即可。
缺页中断是发生在用户模式还是内核模式,可以检查tf_cs 的最低几位。
存储保护是操作系统的一项至关重要的功能。通过存储保护,操作系统可以保证一个错误的程序不会影响到其他的程序或者操作系统本身。技术上讲,操作系统提供存储保护也依赖于硬件的支持。OS 保存着哪些虚拟地址可用而哪些不可用。如果一个程序访问了不可用的虚拟地址,或者是它无权访问的地址,处理器就会停止执行该程序的这条指令并带着这些信息陷入内核。如果这个问题是可以被修复的,内核就修复该问题并继续运行原来的程序;如果是不可修复的,那么该程序就无法继续运行了,因为这条指令永远无法执行过去。
按照作业要求,查看kern/pmap.c中的user_mem_assert函数,此函数检查进程是否有权限访问内存[va,va+len),如果可以就不需要做什么操作,如果不可以则终止并销毁进程。
可以看到,在user_mem_assert函数中,是使用user_mem_check函数来对指定内存空间作权限检查。于是,修改user_mem_check函数。首先,进行页对齐操作,将va和va+len4k对齐。循环检查对齐以后的每一页内存,检查的内容是
1. 页表项是否为空
2. 参数perm给出的权限是否都具备
3. 所有的页地址不能必须小于ULIM
另外,由于使用了页对齐,所以当第一页就出现错误时,我们不能简单地把该页头地址存入user_mem_check_addr,这样很可能返回了一个小于原进程头地址的量,这里的要使用原进程的头地址。最后,当发现错误时,该函数返回一个系统定义的小于零的数字。
这时,查看user/buggyhello.c我们可以看到调用的是kern/syscall.c中的sys_cputs函数。
于是,在sys_cputs中添加上user_mem_assert,对整个进程做用户内存检查就可以了。
最后,在kern/kdebug.c 中的debuginfo_eip,按照要求,分别对usd、stabs、stabstr 调用user_mem_check。首先对usd做检查,确保内存可用,之后用usd对stabs和stabstr赋值完成后再对二者进行检查。
运行结果:
作业九
If you now run user/breakpoint, you shouldbe able to run backtrace from the kernel monitor and see the backtrace traverseinto lib/libmain.c before the kernel panics with a page fault. What causes thispage fault? You don't need to fix it, but you should understand why it happens.
运行结果如下:
最终make grade评分