Exercise 5 Handing Page Faults
缺页中断(中断向量14 T_PGFLT)在这个和后续的实验中都极其重要。当处理器发生缺页中断时,它将会把把造成这个错误的虚拟地址存到控制寄存器CR2中。在trap.c中已经提供了一个函数page_fault_handler(),处理缺页中断。
Exercise 5: 修改trap_dispatch(),将缺页错误引导到page_fault_handler()函数上执行。运行 make grade,出现的结果应该是你修改后的 JOS 可以成功运行 faultread,faultreadkernel,faultwrite,faultwritekernel 测试程序。可以使用make run-x或make run-x-nox将JOS引导到特定的用户程序中。例如,让make run-hello-nox运行hello用户程序。
tf_trapno表示中断向量码,所以补充的代码很简单。
if(tf->tf_trapno == T_PGFLT){
page_fault_handler(tf);
return;
}
实验结果
Exercise 6: The Breakpoint Exception
断点异常,中断向量3(T_BRKPT), 通常用于允许调试器通过特殊的int3软件中断指令在程序代码中插入断点。在JOS中,我们会稍微滥用这个异常,把它变成一个原始的伪系统调用,任何用户环境都可以用它来调用JOS内核监视器。如果我们把JOS内核监视器看作一个原始的调试器,这种用法实际上有点合适。例如,实现lib/panic.c的panic的用户模式在显示panic信息后执行int3.
Exercise 6: 修改trap_dispatch()以使断点异常调用内核监视器。现在应该能够在断点测试中取得成功。
int3软件中断指令是中断指令的一种特殊形式,仅有一个字节长。调试器把该指令当做软件中断指令来用。调试的时候,当在程序某个位置插入断点时,调试器会把断点处指令编码的第一个字节替换成int3指令的编码。当程序执行到int3,会向调试器申请调用,调试器获得CPU控制权。之后再用原先的编码替换int3编码。
if(tf->tf_trapno==T_BRKPT)
monitor(tf);
return;
}
没想到居然没有得分?代码就这么两行,所以不可能是这一部分代码逻辑的问题...又返回去查看了breakpoint测试程序的详细信息。产生的异常是General Protection? 为啥? 能想到的原因是我在之前部分的程序时,设置中断处理程序的时候出了问题,最大可能bug出在SETGATE()。虽然已经定位到了这个地方,但我还是没有找到问题所在...没有想到下面的Question就是提示,特权级设置错了,当特权优先级设置成0的时候,产生的错误就是Generation Protection,特权级为3时,错误就是breakpoint。
make grade
question
断点测试用例将生成一个breakpoint exception或一个general protection fault,这取决于如何在IDT中初始化断点条目(即从trap_init调用SETGATE)。为什么?需要如何设置它才能使断点异常按照上面指定的方式工作,什么不正确的设置会导致它触发一般保护故障?
回答可见上面的debug历程...
Exercise 7: System calls
用户进程要求内核通过调用系统调用来为他们做事情。当用户进程调用系统调用时,处理器进入内核模式,处理器和内核合作保存用户进程的状态,内核执行适当的代码以执行系统调用,然后恢复用户进程。用户进程如何引起内核的注意以及它如何指定要执行哪个调用的具体细节因系统而异。
在JOS内核中,将使用导致处理器中断的int指令。特别是,将使用int $0x30作为系统调用中断。已经将常数T_SYSCALL定义为48 (0x30)。必须设置中断描述符,以允许用户进程导致该中断。注意,中断0x30不能由硬件生成,因此允许用户代码生成它不会引起歧义。
应用程序将在寄存器中传递系统调用号和系统调用参数。这样,内核就不需要在用户环境的堆栈或指令流中四处寻找。系统调用号将进入%eax,参数(最多五个)将分别进入%edx、%ecx、%ebx、%edi和%esi。内核将返回值传回%eax。最后,您需要在kern/syscall.c中实现syscall()。如果系统调用号无效,请确保syscall()返回-E_INVAL。调用系统调用的汇编代码已经编写好了,在syscall()中,在lib/syscall.c中。应该通读它,并确保了解正在发生的事情。
Exercise 7: 在内核中为中断向量T_SYSCALL添加一个处理程序。需要编辑kern/trapentry.S和kern/trap.c文件中的trap_init()函数。还需要更改trap_dispatch()来处理系统调用中断,方法是用适当的参数调用syscall()(在kern/syscall.c中定义),然后在%eax中将返回值传递回用户进程。最后,需要在kern/syscall.c中实现syscall()。阅读并理解lib/syscall.c(尤其是内联程序集例程),以便确认对系统调用接口的理解。通过为每个调用调用相应的内核函数来处理inc/syscall.h中列出的所有系统调用。
在你的内核下运行user/hello程序(make run-hello)。它应该在控制台上打印“hello world”,然后在用户模式下导致页面错误。如果这种情况没有发生,可能意味着您的系统调用处理程序不太正确。你现在也应该能够在testbss测试中取得成功.
关于内联汇编的解读,参考了https://www.jianshu.com/p/f67034d0c3f2这篇博客,实现的功能就是上面红字部分。
asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");
编辑kern/trapentry.S和kern/trap.c文件中的trap_init()函数。这部分就跟之前处理其他中断向量一样。
//trapenrty.S
TRAPHANDLER_NOEC(syscall_handler,T_SYSCALL);
//kern/trap.c
void syscall_handler();
SETGATE(idt[T_SYSCALL], 0, GD_KT, syscall_handler, 3);
接下来就是重头戏,修改syscall.c文件。首先需要弄清楚的是kern/syscall.c和lib/syscall.c的区别。我理解如下(可能不准确),当程序在内核态时,就是运行kern/这个目录下的文件,反之,如果是在lib下,就是在用户态下会运行的文件。
如果现在运行的是内核态的程序的话,此时调用了一个系统调用,比如 sys_cputs 函数时,此时不会触发中断,系统直接执行定义在kern/syscall.c 文件中的 sys_cputs.
那么当程序运行在用户态下(这个练习就是让我们处理程序在用户态下进行系统调用),进行系统调用,其中的汇编代码就通过INT指令产生软件中断,程序陷入内核执行。之前已经设置到了SYSCALL的中断处理入口,然后我们就是要在trap_dispatch函数中进行相应的中断处理。根据lib/syscall.c中syscall()函数编写如下代码:
int32_t ret;
if(tf->tf_trapno == T_SYSCALL){
ret = syscall(
tf->tf_regs.reg_eax,
tf->tf_regs.reg_edx,
tf->tf_regs.reg_ecx,
tf->tf_regs.reg_ebx,
tf->tf_regs.reg_edi,
tf->tf_regs.reg_esi);
tf->tf_regs.reg_eax = ret;
return;
}
捕捉到中断之后转到kern/syscall.c中进行处理
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
// Call the function corresponding to the 'syscallno' parameter.
// Return any appropriate return value.
// LAB 3: Your code here.
// panic("syscall not implemented");
switch (syscallno) {
case SYS_cputs:
sys_cputs((const char*)a1,a2);
break;
case SYS_cgetc:
return sys_cgetc();
case SYS_getenvid:
return sys_getenvid();
case SYS_env_destroy:
return sys_env_destroy(a1);
default:
return -E_INVAL;
}
return 0;
}
最后make run-hello,发生了page fault. make grade
Exercise 8: User-mode startup
用户程序开始运行的地方在lib/entry.S. 在一些设置之后,在lib/libmain.c中会调用libmain()函数。在这里应该修改libmain()以将全局指针thisenv初始化为指向env[]数组中该环境的结构Env。(请注意,lib/entry.S已经定义了指向您在第一部分中设置的UENVS映射的环境)Hint: 在inc/env.h中查找并使用sys_getenvid。
然后libmain()调用umain,在hello程序的情况下,它位于user/hello.c中。请注意,在打印“hello,world”之后,它会尝试访问thisenv->env_id。这就是为什么它较早出现故障。既然您已经正确地初始化了这个环境,它应该不会出错。如果它仍然有问题,你可能还没有绘制用户可读的UENVS区域。这是我们第一次真正使用UENVS区域)。
Exercise 8: 将所需代码添加到用户库中,然后启动内核。你应该看到用户/你好打印“hello,world”,然后打印“i am environment 00001000”。然后,user/hello试图通过调用sys_env_destroy()来“exit”(参见lib/libmain.c和lib/exit.c)。由于内核目前只支持一个用户环境,它应该报告它已经破坏了唯一的环境,然后进入内核监视器。你应该能在hello test中make grade。
-------------------------------------------------------------------------------------------------------------------------------------------------------------------
通过阅读 lib/env.h 文件我们知道,env_id的值包含三部分,第31位被固定为0;第10~30这21位是标识符,标示这个用户环境;第0~9位代表这个用户环境所采用的 Env 结构体,在envs数组中的索引。所以我们只需知道 env_id 的低 0~9 位,我们就可以获得这个用户环境对应的 Env 结构体了。
//取后10bit
#define ENVX(envid) ((envid) & (NENV - 1))
因此只需在lib/libmain.c中添加一行代码
// LAB 3: Your code here.
thisenv = &envs[ENVX(sys_getenvid())];
make grade
Exercise 9: Page Fault and memory protection
内存保护是操作系统的一个重要特性,确保一个程序中的错误不会破坏其他程序或操作系统本身。
操作系统通常依赖硬件支持来实现内存保护。操作系统让硬件知道哪些虚拟地址有效,哪些无效。当一个程序试图访问一个无效地址或一个它无权访问的地址时,处理器在导致错误的指令处停止该程序,然后用关于所尝试的操作的信息捕获到内核中。如果故障是可修复的,内核可以修复它并让程序继续运行。如果故障无法修复,那么程序就无法继续运行,因为它永远无法通过导致故障的指令。
作为可修复故障的一个例子,考虑自动扩展堆栈。在许多系统中,内核最初分配一个单独的堆栈页,然后如果一个程序错误地访问了堆栈下面的页,内核将自动分配这些页,让程序继续运行。通过这样做,内核只分配程序所需的堆栈内存,但是程序可以在它有一个任意大的堆栈的假象下工作。
系统调用给内存保护带来了一个有趣的问题。大多数系统调用接口允许用户程序将指针传递给内核。这些指针指向要读取或写入的用户缓冲区。然后内核在执行系统调用时解引用这些指针。这有两个问题:
1.内核中的页面错误可能比用户程序中的页面错误严重得多。如果内核在处理自己的数据结构时出现页面错误,那是一个内核错误,错误处理程序会使内核(以及整个系统)死机。因此,内核需要记住这个错误是来自用户进程。
2.内核通常比用户程序拥有更多的内存权限。用户程序可能会传递一个指向系统调用的指针,该系统调用指向内核可以读取或写入但程序不能读取或写入的内存。内核必须小心,不要被骗去引用这样的指针,因为这可能会泄露私有信息或破坏内核的完整性.
现在,将使用一个机制来解决这两个问题,这个机制会仔细检查从用户空间传递到内核的所有指针。当程序向内核传递指针时,内核将检查地址是否在地址空间的用户部分,以及页表是否允许内存操作。
因此,内核永远不会因为解引用用户提供的指针而出现页面错误。如果内核出现页面错误,它应该会死机并终止。
Exercise 9: 修改kern/trap.c文件,使其实现:当在内核模式下发现页面错误时,trap.c文件会panic. 提示:为了能够判断这个page fault是出现在内核模式下还是用户模式下,我们应该检查 tf_cs 的低几位。
阅读 user_mem_assert (在 kern/pmap.c),并且实现 user_mem_check;
修改一下 kern/syscall.c 去检查系统调用的输入参数。
启动内核后,运行 user/buggyhello 程序,用户环境应该被销毁,内核不可以panic,你应该看到:
[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!
最后,修改kern/kdebug.c文件中的debuginfo_eip函数来调用在usd,stabs和stabstr上的user_mem_check. 如果现在运行user/breakpoint,应该 能够从内核监视器运行回溯,并在内核因页面错误而死机之前看到回溯遍历到lib/libmain.c。是什么导致了这个页面错误?你不需要修复它,但是应该理解它为什么会发生。(这个问题还没搞懂, 因为lab1没掌握好...依旧先卡一下...)
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
根据提示和代码注释,可以得知根据CS段寄存器的低2位来判断当前运行的程序处在内核态还是用户态。这两位的名称叫做CPL位,表示当前运行的代码的访问权限级别,0代表内核态,3代表用户态。
修改kern/trap.c,在page_fault_handler()中添加一个判断语句即可。
// LAB 3: Your code here.
if(tf->tf_cs && 3 == 0){
panic("page fault in kernel node, fault address %d\n",fault_va);
}
然后实现user_mem_check(). 这个函数的功能是检查当前用户态程序对虚拟地址[va,va+len)是否有访问权限。打开kern/pmap.c. 照着注释来,我觉得难点在于查看该虚拟地址对应的页表项的访问权限字段。注意一种特殊情况:如果第一页就是不被允许访问的空间,那么要注意是用虚拟地址va给user_mem_check_addr赋值,而不是向下对齐的那个虚拟地址。
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
uintptr_t start = ROUNDDOWN((uintptr_t)va,PGSIZE);
uintptr_t end = ROUNDUP((uintptr_t)va+len,PGSIZE);
pte_t *cur_pte;
for(uintptr_t cur_va =start; cur_va < end;cur_va+=PGSIZE){
cur_pte = pgdir_walk(env->env_pgdir,(void *)cur_va,0);
if(cur_va>ULIM || cur_pte==NULL || (*cur_pte&(perm|PTE_P))!=(perm|PTE_P)){
if(cur_va==start)
user_mem_check_addr = (uintptr_t)va;
else
user_mem_check_addr = (uintptr_t)cur_va;
return -E_FAULT;
}
}
return 0;
}
最后补全kern/syscall.c文件中的内容。
static void
sys_cputs(const char *s, size_t len)
{
// Check that the user has permission to read memory [s, s+len).
// Destroy the environment if not.
// LAB 3: Your code here.
user_mem_assert(curenv,s,len,0);
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}
Exercise 10
exercise 9解决了,exercise 10也就成功了。