PartB: Page Faults, Breakpoints Exceptions, and System Calls
处理页错误
Exercise 5.
修改trap_dispatch()
将 page fault exceptions 分配到 page_fault_handler(). 记住你可以用make run-x
ormake run-x-nox
是启动JOS执行特定的用户程序. For instance, make run-hello-nox runs the hello user program.
直接修改trap_dispatch
函数,用switch case实现
// LAB 3: Your code here.
switch(tf->tf_trapno) {
case T_PGFLT: page_fault_handler(tf);break;
default: break;
}
运行结果:
faultread: OK (4.0s)
(Old jos.out.faultread failure log removed)
faultreadkernel: OK (3.7s)
(Old jos.out.faultreadkernel failure log removed)
faultwrite: OK (3.1s)
(Old jos.out.faultwrite failure log removed)
faultwritekernel: OK (3.3s)
(Old jos.out.faultwritekernel failure log removed)
The Breakpoint Exception(断点异常)
断点异常,中断向量3(T_BRKPT),通常用于允许调试器通过使用特殊的1字节int 3
软件中断指令临时替换相关的程序指令,在程序代码中插入断点。
在JOS中,我们将稍微滥用此异常,将其转换为用户环境用于调用JOS内核监视器的原始伪系统调用。 如果我们将JOS内核监视器视为原始调试器,这种用法实际上是合适的。 例如,lib/panic.c
中的panic()
的用户模式实现在打印panic消息后执行int 3
。
// Cause a breakpoint exception
while (1)
asm volatile("int3");
Exercise 6
Modify trap_dispatch()
to make breakpoint exceptions invoke the kernel monitor. You should now be able to get make grade to succeed on the breakpoint test.
同样在trap_dispatch中加入一个breakpiont的case就可以了。
case T_BRKPT:
monitor(tf);
break;
Questions
3.断点测试例子中,产生断点异常还是通用保护错误取决于我们如何初始化断点异常的IDT项。为什么?
如果设置其DPL为0,则会产生GPF,因为用户程序跳转执行内核态程序。如果我们想要当前执行的程序能够跳转到这个描述符所指向的程序哪里继续执行的话,有个要求,就是要求当前运行程序的CPL,RPL的最大值需要小于等于DPL,否则就会出现优先级低的代码试图去访问优先级高的代码的情况,就会触发general protection exception。
4.尤其考虑到user/softint
测试程序,你认为这些机制的关键点是什么?
DPL的设置,可以限制用户态对关键指令的使用。
系统调用
用户进程通过系统调用要求内容去做一定的操作。当用户进程调用系统调用时,处理器进入内核模式( 怎么进入的?硬件自动,软件设置标志位? ),处理器和内核协同保存用户进程的状态,内核执行适当的代码以执行系统调用,然后恢复用户进程。 用户进程如何获得内核注意的确切细节以及它如何指定它想要执行的调用因系统而异。
注意硬件不能产生int 0x30
中断,需要程序自行产生此中断,并且没有二义性。
应用程序通过寄存器传递系统调用号和系统调用参数。 这样,内核就不需要在用户环境的堆栈或指令流中获取参数。系统调用号将存放在%eax中,参数(最多五个)将分别位于%edx,%ecx,%ebx,%edi和%esi中。内核通过%eax传递返回值。
asm volatile("int %1\n"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a1),
"c" (a2),
"b" (a3),
"D" (a4),
"S" (a5)
: "cc", "memory");
"__volatile__"表示编译器不要优化代码,后面的指令 保留原样,其中"=a"表示"ret"是输出操作数; "i"=立即数;
最后一个子句告诉汇编器这可能会改变条件代码和任意内存位置。memory
强制gcc编译器假设所有内存单元均被汇编指令修改,这样cpu中的registers和cache中已缓存的内存单元中的数据将作废。cpu将不得不在需要的时候重新读取内存中的数据。这就阻止了cpu又将registers,cache中的数据用于去优化指令,而避免去访问内存。
Exercise 7
在内核中为中断向量T_SYSCALL
添加一个handler.
根据其提示应该很容易写出这个Exe的代码,这里就不一一贴出。在此我们简单分析以下系统调用执行过程。用户态调用的是lib文件下的syscall.c中的sys_cputs()
等函数,然后转到lib中的syscall()
, 其中汇编代码使用寄存器传递参数并执行了int 0x30产生系统调用中断。最后进入trap中并dispatch。内核态调用了kern文件下的syscall根据系统调用号,执行相应的系统调用函数。 实际上我们可以发现,自从引进了用户进程这个概念后,lib
and kern
两个文件夹下的syscall.c and printf.c会出现很多函数名相似的代码。 这都是为了区分用户态与内核态。lib下的函数并不真正执行相应的操作,而是通过系统调用进入内核态,执行kern下的代码。
那我们可不可以直接调用kern下的函数呢?盲猜,kern下的代码在内存中有权限保护,所以不能在用户态调用。
make run-hello
后打印出了hello world
。并且出现了如练习中所说的page fault
。但是为什么会出现页错误呢? user-mode startup部分给出了解释,在hello.c中it tries to access thisenv->env_id,而thisenv并没有初始化。
Incoming TRAP frame at 0xefffffbc
hello, world
Incoming TRAP frame at 0xefffffbc
[00001000] user fault va 00000048 ip 00800048
TRAP frame at 0xf01be000
...
trap 0x0000000e Page Fault
cr2 0x00000048
err 0x00000004 [user, read, not-present]
...
[00001000] free env 00001000
user-mode startup
用户程序首先从lib/entry.S
开始运行。 经过一些初始化后,此代码在lib/libmain.c
中调用libmain()
。 我们应该修改libmain()
以初始化全局指针thisenv以指向envs[]数组中此环境对应的struct Env。 程序是如何执行到这里来的? 应该问题还是会回到之前提出的env[0]到底是什么时候初始化的。
一个环境ID 'envid_t' 有三个部分:
- 0~9bit :为环境索引,也等于ENVX(eid)
- 10~30bit: uniqueifier可区分在不同时间创建的环境,但共享相同的环境索引。
- 31bit: 恒为0, All real environments are greater than 0 (so the sign bit is zero).
修改libmain.c
,添加一条语句即可。
// set thisenv to point at our Env structure in envs[].
// LAB 3: Your code here.
thisenv = &envs[ENVX(sys_getenvid())];
页错误和内存保护
内存保护是操作系统的一个重要特性,可确保一个程序中的错误不会破坏其他程序或破坏操作系统本身。
操作系统通常依靠硬件支持来实现内存保护。 操作系统会通知硬件哪些虚拟地址有效,哪些虚拟地址无效。 当程序试图访问无效地址或无权地址时,处理器会在导致故障的指令处停止程序,然后携带者有关操作的信息陷入内核。
故障分为可修复故障(例如自动栈扩展,缺页)和不可修复故障。
系统调用为内存保护提出了一个有趣的问题。 大多数系统调用接口允许用户程序传递指向内核的指针。 这些指针指向要读取或写入的用户缓冲区。 然后内核在执行系统调用时对这些指针进行解引用操作。 这有两个问题:
- 内核中的页错误可能比用户程序中的页面错误严重得多。如果内核在处理自己的数据结构时发生页错误,那就是内核错误,并且错误处理程序应该使内核(进而整个系统)panic。 但是,当内核解引用用户程序传递的指针时,它需要一种方法来记住这些解引用操作引起的任何页错误实际上都代表用户程序。
- 内核通常比用户程序拥有更多的内存权限。用户程序可能传递一个指向系统调用的指针,该系统调用指向内核可以读写但程序不能读写的内存。内核必须小心不要被诱骗去解引用这样的指针,因为这可能会暴露私有信息或者破坏内核的完整性。
Exercise 9
Change kern/trap.c to panic if a page fault happens in kernel mode.
首先通过CPL位来判断处理器是否处于内核态。tf->tf_cs & 0x01 == 0
。
To determine whether a fault happened in user mode or in kernel mode, check the low bits of the tf_cs.
if(tf->tf_cs && 3 == 0) {
panic("page_fault in kernel mode, fault address %d\n", fault_va);
}
阅读 kern/pmap.c 文件中的 user_mem_assert 函数并实现 user_mem_check 函数,实现如下:
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
uint32_t start = (uint32_t)ROUNDDOWN((char *)va, PGSIZE);
uint32_t end = (uint32_t)ROUNDUP((char *)va+len, PGSIZE);
for(; start < end; start += PGSIZE) {
pte_t *pte = pgdir_walk(env->env_pgdir, (void*)start, 0);
if((start >= ULIM) || (pte == NULL) || !(*pte & PTE_P) || ((*pte & perm) != perm)) {
user_mem_check_addr = (start < (uint32_t)va ? (uint32_t)va : start);
return -E_FAULT;
}
}
return 0;
}
Finally, change debuginfo_eip in kern/kdebug.c to call user_mem_check on usd, stabs, and stabstr.
if (user_mem_check(curenv, usd, sizeof(struct UserStabData), PTE_U))
return -1;
...
if (user_mem_check(curenv, stabs, sizeof(struct Stab), PTE_U))
return -1;
if (user_mem_check(curenv, stabstr, stabstr_end-stabstr, PTE_U))
return -1;
写完后运行发现后面的evilhello
,buggyhello
,buggyhello2
都能通过,但是faultread
、faultreadkernel
,faultwrite
, faultwritekernel
都通不过了。最后发现是我理解错了,我以为在page_fault_handler
中如果不是内核模式而在用户模式,还需要进行user_mem_assert操作,即如下代码所示。导致程序每次都会终止在user_mem_assert,而不会打印之后的字符了。 正确的实现代码应删除user_mem_assert(curenv, (const void *) fault_va, PGSIZE, 0);
这一行。
// LAB 3: Your code here.
// 怎么判断是内核模式, CPL位
if(tf->tf_cs && 3 == 0) {
panic("page_fault in kernel mode, fault address %d\n", fault_va);
}
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.
// user_mem_assert(curenv, (const void *) fault_va, PGSIZE, 0);
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
以上我们实现的机制,同样适用于对恶意程序的处理。因此Exercise10可以直接通过。
总结
此lab让我们在头脑中建立了一个用户环境(进程)实现的框架,对用户程序的运行有了一个较清晰的概念。
首先我们需要创建用户环境、初始化用户环境的虚拟内存机制、并实现用户代码的加载与进程的运行。之后我们需要了解x86的中断与异常机制,以及IDT表的基本结构,并对IDT表进行初始化。对IDT,我一开始的理解是:具体实现handler,当中断发生时,直接查IDT表去调用相应的handler,但是在实际中是用trap的dispatch来实现分流。对这我有点不解。
最后实现通过dispatch来分流,实现页错误、断点异常以及系统调用等的处理。
Dingmos
@ : 此系列文章仅为我个人做828的笔记,个人能力有限,难免出现错误。如果你愿意指出我实现存在的问题,我会十分感谢你并进行更正。