接下来结合具体的代码讲解 OS 是如何实现页面错误的。

什么是处理页面错误?

页面错误(Page Fault)是一种常见的异常,通常发生在虚拟内存系统中。当程序试图访问的虚拟内存页不在物理内存中,或者程序试图执行的操作违反了内存保护策略时,就会发生页面错误。

在 x86 架构中,当页面错误发生时,处理器会自动执行以下操作:

  1. 将引发错误的线性地址(即虚拟地址)保存在 CR2 控制寄存器中。
  2. 将错误代码压入内核栈。
  3. 将当前的程序计数器、代码段、EFLAGS 寄存器的值压入内核栈。
  4. 通过中断描述符表(IDT)跳转到页面错误处理函数。

页面错误处理函数通常会根据错误代码和 CR2 寄存器的值来确定错误的原因,并采取相应的处理措施。例如,如果错误是由于所需的页面不在物理内存中,处理函数可能会从磁盘中加载所需的页面;如果错误是由于违反了内存保护策略,处理函数可能会终止引发错误的程序。

C 语言中的那些行为会导致页面处理错误?

页面错误(Page Fault)通常在以下几种情况下发生:

  1. 非法访问:当程序试图访问一个它没有权限访问的内存地址时,会发生页面错误。例如,程序试图写入只读内存,或者试图访问用户模式下不可访问的内存。

  2. 非映射内存访问:当程序试图访问一个并未映射到物理内存的虚拟内存地址时,会发生页面错误。这通常发生在程序访问一个尚未加载到内存的内存页,或者访问一个已经被释放的内存页。

  3. 空指针解引用:当程序试图通过空指针访问内存时,也会发生页面错误。在大多数操作系统中,地址 0 是不允许访问的,因此这种操作会导致页面错误。

  4. 栈溢出:当程序的调用栈超过了为其分配的内存空间时,会发生页面错误。这通常发生在递归调用过深或者在栈上分配了大量数据导致的栈溢出。

以上情况都可能导致页面错误,但具体的行为和结果可能会因操作系统和硬件的不同而有所差异。

如何区分不同类型的 trap ?

下面代码是处理中断和异常的函数。当发生中断或异常时,处理器会自动保存当前的状态,并跳转到这个函数进行处理。这个函数的主要任务是根据中断或异常的类型,调用相应的处理函数。

static void
trap_dispatch(struct Trapframe *tf)
{
	// Handle processor exceptions.
    switch (tf->tf_trapno) {
        case T_PGFLT:
            page_fault_handler(tf);
            return;
     }

	// 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;
	}
}

在这个函数中,首先通过一个 switch 语句检查中断或异常的类型。对于页面错误(T_PGFLT),调用page_fault_handler函数进行处理;对于断点异常(T_BRKPT),调用monitor函数进行处理;对于系统调用(T_SYSCALL)。

如果中断或异常的类型不在这些已知的类型中,那么就认为是一个意外的中断或异常。在这种情况下,首先打印出中断或异常的信息,然后检查中断或异常是在内核模式还是用户模式下发生的。如果是在内核模式下发生的,那么说明内核有 bug,因此调用panic函数终止程序运行。如果是在用户模式下发生的,那么说明用户程序有 bug,因此调用env_destroy函数销毁引发中断或异常的进程。

如何处理页面错误?

上面已经提及了会跳转到 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 & 3) == 0) {
	  panic("unhandled page fault in kernel mode");
 	}

	// 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);
}

函数的参数 tf 是一个指向 Trapframe 结构的指针,该结构包含了处理器在异常发生时保存的状态信息。

函数首先通过 rcr2 函数读取处理器的 CR2 寄存器,该寄存器包含了引发页错误的线性地址。

接下来,函数检查异常是否在内核模式下发生。这是通过检查 tf->tf_cs 的最低两位来完成的,如果这两位为 0,那么说明异常是在内核模式下发生的。如果是在内核模式下发生的页错误,函数会调用 panic 函数并输出错误信息,因为内核不应该产生页错误。

如果异常是在用户模式下发生的,函数会打印出引发异常的进程 ID、引发页错误的地址以及异常发生时的指令指针。然后,它会调用 print_trapframe 函数打印出异常发生时的处理器状态。

最后,函数会调用 env_destroy 函数销毁引发异常的进程。这是因为在用户模式下发生的页错误通常表示程序存在错误,例如试图访问未分配的内存或违反内存保护规则,所以最简单的处理方式就是终止这个程序。

如何销毁引发异常的进程?

下面这段代码是 env_destroy 函数,它的作用是销毁一个进程。

void
env_destroy(struct Env *e)
{
	env_free(e);

	cprintf("Destroyed the only environment - nothing more to do!\n");
	while (1)
		monitor(NULL);
}

函数接收一个 Env 类型的指针 e 作为参数,这个指针指向要被销毁的进程。

首先,函数调用 env_free(e) 来释放进程 e 及其使用的所有内存。

然后,函数使用 cprintf 打印一条消息,表明已经销毁了唯一的进程,没有其他事情可以做了。

最后,函数进入一个无限循环,调用 monitor(NULL)。这个调用会使内核进入一个简单的命令行内核监视器,用于接收和处理用户输入的命令。因为已经销毁了唯一的进程,所以内核没有其他事情可以做,只能等待用户的命令。

这个函数的主要用途是在用户模式下发生页错误时,销毁引发异常的进程。

这段代码是 env_free 函数,它的作用是释放进程 e 及其使用的所有内存。

函数首先检查是否正在释放当前进程,如果是,则在释放页目录之前切换到 kern_pgdir,以防止页面被重用。

然后,函数遍历用户地址空间的所有映射页面,并将它们从页表中移除。这是通过遍历页目录 e->env_pgdir 中的每个条目,并检查每个页表中的每个页表项来完成的。如果页表项表示一个映射的页面(即 PTE_P 位被设置),则调用 page_remove 函数将其移除。在移除所有页面后,函数释放页表本身。

接下来,函数释放页目录 e->env_pgdir,并将其设置为 NULL

最后,函数将进程 e 的状态设置为 ENV_FREE,并将其添加到空闲进程列表 env_free_list 中。

总的来说,这个函数的作用是释放一个进程及其使用的所有内存,包括页目录、页表和映射的页面。

总结

本文介绍了操作系统中的页面错误及其处理过程。页面错误通常发生在虚拟内存系统中,当程序访问未加载到物理内存中的地址或违反内存保护策略时。处理函数根据错误类型执行相应操作,例如在用户模式下发生的页面错误会销毁引发异常的进程。销毁进程的过程包括释放内存、打印消息,并进入等待用户输入命令的循环。