MIT-JOS系列7:用户环境(三)
Part B:页面错误,断点异常和系统调用
注:根据MIT-JOS的lab指导手册,以下不明确区分“环境”和“进程”
到目前位置我们以及你实现了内核基本的异常处理,现在要在此基础上利用异常处理进行系统调用。
处理页面错误
页面错误(page fault,中断向量14)是我们在本实验和往后会大量使用的一个重要例子。当页面错误发生时,处理器将导致故障的线性地址(虚拟地址)存放在特殊寄存器cr2
中。在trap.c
中,提供了一个用于页面错误的函数page_fault_handler()
修改trap_dispatch()
,调用page_fault_handler()
处理页面错误
处理断点异常
断点异常(breakpoint,中断向量3)常被调试器用来在程序的断点处临时用int 3
代替原来的语句。在JOS中我们会把这个异常作为一个伪系统调用以便任何用户环境都能调用到JOS的内核monitor。
修改trap_dispatch()
,在断点异常时调用monitor()
这两节的代码实现如下:
static void
trap_dispatch(struct Trapframe *tf)
{
// Handle processor exceptions.
// LAB 3: Your code here.
switch (tf->tf_trapno)
{
case T_PGFLT:
page_fault_handler(tf);
break;
case T_BRKPT:
monitor(tf);
break;
default:
// Unexpected trap: The user process or the kernel has a bug.
print_trapframe(tf);
if (tf->tf_cs == GD_KT)
panic("unhandled trap in kernel");
else {
env_destroy(curenv);
return;
}
}
}
对于断点异常,为了能让他在用户态被正常调用,还要对其权限进行修改:
SETGATE(idt[T_BRKPT], 1, GD_KT, t_brkpt, 3);
否则,用户将无权调用int $3
,在断点异常产生时将再产生一个异常protection fault(trap 13),就像上一个实验最后做的那样
系统调用
用户进程利用系统调用请求内核为它完成一些操作。当用户进程发起系统调用,处理器进入内核态,处理器和内核将保存当前用户进程的上下文状态,内核执行相应代码实现系统调用,然后返回继续执行用户进程的代码。用户如何向内核发起系统调用以及某个特定系统调用的具体用途在不同的操作系统中有很多不同的实现方式
在JOS系统中,我们使用int
指令,它会出发一个处理器的中断。特别的,我们使用int $0x30
作为系统调用
我们定义其中断向量为48(0x30),然后需要在中断向量表中初始化它的中断号和入口函数。这个中断不会被硬件触发
用户程序会通过寄存器向内核传递系统调用号和参数,这样内核就不需要从用户的堆栈或指令流中获取参数了
- 系统调用号放在
%eax
- 参数(最多五个)分别放在
%edx, %ecx, %ebx, %edi, %esi
- 内核的返回值放在
%eax
例如在hello.c
中,跟进cprintf
可以发现它是最终利用lib/syscall.c
中的syscall()
,将需要打印的字符串地址、字符串长度放在寄存器中传给内核,并传递系统调用号num
告诉内核想要做什么,请求内核帮助完成打印。
补充代码完成系统调用:
-
在
trapentry.S
中增加系统调用的入口TRAPHANDLER_NOEC(t_syscall, T_SYSCALL)
-
在
trap.c
中初始化IDT表SETGATE(idt[T_SYSCALL], 0, GD_KT, t_syscall, 3);
-
在
trap_dispatch()
中对系统调用补充调用syscall()
的代码,向它传递正确的参数,并正确接收返回值 -
实现
syscall()
,为所有inc/syscall.h
中的系统调用号实现系统调
trap_dispatch()
的补充代码如下:
case T_SYSCALL:
tf->tf_regs.reg_eax = 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);
break;
syscall()
实现如下:
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((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);
case NSYSCALLS:
default:
return -E_INVAL;
}
return 0;
}
用户模式启动
用户进程从lib/entry.S
开始运行。在进行一些初始化后,调用lib/libmain.c
的libmain()
,它设置用户环境指向当前环境,设置用户进程的名称,并调用umain()
进入用户进程的主函数。修改libmain()
,使
thisenv
指向envs
数组中的当前环境(lib/entry.S
已经将envs
指向了UENVS
,这是之前做内存映射时envs
数组映射的位置)- 提示:利用系统调用
sys_getenvid()
- 用
ENVX
根据envid
计算出env
在envs
中的偏移
代码:thisenv = envs + ENVX(sys_getenvid());
之后,libmain()
调用user/hello.c
中的umain()
,执行程序hello
。在libmain()
完整之前在qemu执行内核,打印“hello world”后发生了页面错误,就是因为程序还没完善,hello.c
中的thisenv->env_id
指向0
页面错误和内存保护
内存保护是操作系统中至关重要的一个特性,它保证一个进程中的错误不会破坏其他进程或系统内核。
操作系统通常依赖硬件支持来实现内存保护。操作系统始终能让硬件知道哪些虚拟地址是有效的、哪些是无效的,当进程需要访问无效的地址或它无权访问某地址时,操作系统会在导致错误的指令处终止程序,进入内核态,并将错误信息报告给内核。如果这个异常是可修复的,那么内核修复这个异常,程序继续执行;如果这个异常无法被修复,程序将被终止。
举一个可修复的错误的例子:考虑一个可自动扩展的堆栈。很多操作系统中,内核为用户程序分配单个页面作为栈区,如果程序想要访问这个栈区以外的空间而触发异常,操作系统自动为其分配更大的空间保证用户程序继续执行。通过这种做法,操作系统实际上只分配程序需要的栈内存量,但程序会觉得自己能在任意大的堆栈下执行。
系统调用也为内存保护带来问题:大部分系统调用接口让用户程序传递一个指针参数给内核。这些指针指向的是用户读写的缓冲区。通过这种方式,系统调用在执行时就可以读写这些指针指向的数据。但是这里有两个问题:
- 在内核中发生的页面错误可能比用户态下发生的页面错误严重的多。如果内核在操纵它自己的数据结构时发生了页面错误,这是一个内核BUG,异常处理程序应该终止整个系统的运行。但如果这个指针来自用户,操作系统就需要分辨出这个异常是在引用用户数据时造成的
- 内核比用户拥有更高的访问权限。用户程序传递给内核的指针指向的数据可能是内核有权访问但用户无权访问的,因此内核必须小心读写指针指向的数据,防止将重要信息泄露给用户
针对这两点,我们之前写的中断处理和系统调用还存在一些问题:
- 处理页面错误时没有针对发生异常的是用户态还是内核态做特殊处理(利用
tf_cs
检测执行权) - 系统调用
sys_cputs()
中没有进行权限检测(实现user_mem_check()
)
接下来就需要完善这些函数保证系统调用的安全性
在pmap.c
中实现user_mem_check()
:
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
// LAB 3: Your code here.
uintptr_t begin = ROUNDDOWN((uintptr_t)va, PGSIZE);
uintptr_t end = begin + ROUNDUP(len, PGSIZE);
uintptr_t *pte = NULL;
while (begin < end) {
pte = pgdir_walk(env->env_pgdir, (uintptr_t*)begin, 0);
if (begin < ULIM && pte && (*pte & perm) == perm) {
begin += PGSIZE;
continue;
} else {
if (begin < (uintptr_t)va)
user_mem_check_addr = (uintptr_t)va;
else
user_mem_check_addr = begin;
return -E_FAULT;
}
}
return 0;
}
这里由于将va
向下4k对齐,因此需要检查begin < (uintptr_t)va
,如果在va
首地址处就发现权限不对,要正确返回va
的地址而不是begin
在page_fault_handler()
中增加检查是否内核发生了页面错误:
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;
// Read processor's CR2 register to find the faulting address
fault_va = rcr2();
// Handle kernel-mode page faults.
// LAB 3: Your code here.
if ((tf->tf_cs & 0x11) == 0)
panic("kernel page fault at %x.\n", fault_va);
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.
// 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);
}
在sys_cputs()
中检查用户是否具有权限读写相应内存:
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, PTE_P);
// Print the string supplied by the user.
cprintf("%.*s", len, s);
}