我们知道虚拟空间和物理内存是通过页表建立起映射关系的,当访问某段虚拟内存时,这种映射关系很有可能是尚未建立的,也有可能是在fork了之后页表被设置了WR模式。如果此时进程想往这部分区域写数据时,就会导致处理器产生异常。内核需要捕获并“修复”这种异常,这一过程就是缺页异常处理。博文中一些图片的copyright是15年看这部分内容时记的笔记,这里没去水印直接借用了。虽然via-telecom已被英特尔收购,但如果设计版权问题请联系博主。
不论是空pte还是pte被设置为wr模,如果向里写数据肯定会触发处理器异常,而且应该是MMU发生的异常。现看ARM官方manual,看下这部分异常硬件是怎么处理的。
首先ARM有好几种异常模式,其中一种是Data Abort。ARM v7 manual对这种异常的解释如下。
这种异常简单说就是发生在memory access阶段,在读/写数据、取指令或者访问页表时均有可能发生。
ARM v7 core的MMU支持两种类型的页表。
所以如果是不支持大物理地址扩展技术的ARM,只支持Short-descriptor格式。我们只分析Short-descriptor format。
在看当MMU发生异常时候CP15寄存器的变化。
这里提到保存信息的寄存有两个分别是DFSR和DFAR。
DFAR中存放的是发生异常的VA。DFSR则存放的是发生异常的类型。
这里能看到有[10, 3:0] 5个bit来表示FS(FAULT STATUS)。bit[11]来指明发生exception时是因为读指令还是写指令导致的。然后再看下FS支持哪些状态。
这里需要关注的状态是一二级页表的translation fault & permission fault。因为linux内核只支持这四种,如下图所示。
有了这些硬件基础之后,内核就有办法来处理这个data abort异常了。大致的流程如下:MMU translation or permission fault ->异常向量data abort入口->从CP15的c5 & c6寄存器中读出当前的fault类型和触发异常的VA->进入do_DataAbort流程进行修复页表,也就是缺页异常处理。
有了前面的硬件基础之后,我们就可以看内核的缺页处理过程了。从前面的FSR信息来看,内核支持的MMU fault类型有很多,但内核并没有全部支持。
内核在进入data abort异常时会有一些预处理,最后在得到CP15的C5 C6寄存器值之后便跳转到do_DataAbort函数,并且将C5 C6寄存器作为参数传入。这部分比较复杂,内核需要考虑到各种情况,比如:
1) 是由于访问用户地址空间中有效地址引起的,还是应用程序试图访问内核的受保护区域。
2) 目标地址是否对应某个现存的映射
3) 通过何种机制来获取该区域的数据
先看代码
544 asmlinkage void __exception
545 do_DataAbort(unsigned long addr, unsigned int fsr, struct pt_regs *regs)
546 {
547 const struct fsr_info *inf = fsr_info + fsr_fs(fsr);
548 struct siginfo info;
549
550 if (!inf->fn(addr, fsr & ~FSR_LNX_PF, regs))
551 return;
552
553 pr_alert("Unhandled fault: %s (0x%03x) at 0x%08lx\n",
554 inf->name, fsr, addr);
555 show_pte(current->mm, addr);
556
557 info.si_signo = inf->sig;
558 info.si_errno = 0;
559 info.si_code = inf->code;
560 info.si_addr = (void __user *)addr;
561 arm_notify_die("", regs, &info, fsr, 0);
562 }
这函数很简单,判断出FS的类型后就直接调用对用的handler了。从前面贴的fsr handler来看有四种:
1.一级页表translation出错,调用do_translation_fault
2.一级页表permisson出错,调用do_sect_fault
3.二级页表translation/permisson出错,调用do_page_fault
4.内核不支持的fault, 调用do_bad
内核不支持的fault会调用do_bad来处理。do_bad的实现很简单,就是return 1。根据前一节代码,返回1之后会直接走到arm_notify_die(“”, regs, &info, fsr, 0)
344 void arm_notify_die(const char *str, struct pt_regs *regs,
345 struct siginfo *info, unsigned long err, unsigned long trap)
346 {
347 if (user_mode(regs)) {
348 current->thread.error_code = err;
349 current->thread.trap_no = trap;
350
351 force_sig_info(info->si_signo, info, current);
352 } else {
353 die(str, regs, err);
354 }
355 }
在arm_notify_die之后,首先判断是哪种模式:
1.如果是user,说明fault的是user空间。通过force_sig_info发送signal给相关异常进程。而内核不会挂起
2.如果是其他模式,则说明fault的可能是内核模块,需要调用die函数来处理。
319 void die(const char *str, struct pt_regs *regs, int err)
320 {
321 enum bug_trap_type bug_type = BUG_TRAP_TYPE_NONE;
322 unsigned long flags = oops_begin();
323 int sig = SIGSEGV;
324
325 if (!user_mode(regs))
326 bug_type = report_bug(regs->ARM_pc, regs);
327 if (bug_type != BUG_TRAP_TYPE_NONE)
328 str = "Oops - BUG";
329
330 if (__die(str, err, regs))
331 sig = 0;
332
333 oops_end(flags, regs, sig);
334 }
在die函数通过__die函数来依次打印内核信息,打印内容和顺序如下:当前模块信息->硬件寄存器R0~PC内容->栈内容->打backtrace->打PC指针。
打印完现场后,内核就需要通过oops_end()函数来决定内核是pending还是进行必要扫尾工作后继续运行。oops_end如下:
293 static void oops_end(unsigned long flags, struct pt_regs *regs, int signr)
294 {
295 if (regs && kexec_should_crash(current))
296 crash_kexec(regs);
297
298 bust_spinlocks(0);
299 die_owner = -1;
300 add_taint(TAINT_DIE, LOCKDEP_NOW_UNRELIABLE);
301 die_nest_count--;
302 if (!die_nest_count)
303 /* Nest count reaches zero, release the lock. */
304 arch_spin_unlock(&die_lock);
305 raw_local_irq_restore(flags);
306 oops_exit();
307
308 if (in_interrupt())
309 panic("Fatal exception in interrupt");
310 if (panic_on_oops)
311 panic("Fatal exception");
312 if (signr)
313 do_exit(signr);
314 }
可以看到这里分了三种情况:
1..in_interrupt()进入panic,也就是内核pending
2.设置了panic_on_oops后,不论哪种OOPS一律进入panic
3.如果内核没有设置panic_on_oops,并且当前oops发生在进程中。则通过do_exit进行扫尾后,内核还是可以继续运行。
结论:内核发生OOPS后并不总是挂起。只有:1.当前处于in_interrupt 2.或者设置了CONFIG_PANIC_ON_OOPS_VALUE强制内核只要发生OOPS就pending时内核才回pending。其余情况内核都能继续运行。
一级页表的translation错误需要调用do_translation_fault()来处理。先看流程图:
1.当addr < TASK_SIZE。说明是用户空间的地址access时发生异常,直接调用缺页异常处理。这个稍后分析。
2.内核空间access异常,但出于USER mode。暂时不知道什么CASE下会跑这里,但只要跑这里就当作内核不支持的fault处理,调用do_bad_area。do_bad_area的流程和前面do_bad的流程类似,不再分析。
3.如果既是内核的地址空间fault,又处于SVC模式。则进行内核页表同步的操作。具体看内核页表同步。
如果是user 空间的一级页表fault,调用缺页处理。如果是kernel空间的一级页表fault,进行页表同步。前一种可能发生在进程分配VM后,并没有分配对应的物理内存,导致直接一级页表出错。
什么情况下需要做内核页表的同步?需要做页表映射说明这部分内存应该是动态映射。
我们知道内核在初始化的时候会将低端内存(low_mem)以分段的方式映射到一级页表中了,这部分应该是固定映射。但像vmalloc是内核运行过程中动态分配,这使得内核的一级页表有部分内容是动态更新的。这个vmalloc会更新swapper_pg_dir,也就是这里提到的pmd_k。
另外我们知道每个用户进程都会有一个自己的内核页表拷贝。这个拷贝是进程创建过程中复制来的,而我们的内核页表可能一直在更新,这导致运行过程中,进程的内核页表可能落后于内核页表swapper_pg_dir,所以需要做内核页表同步。
如果是一级页表的权限异常,内核会调用do_sect_fault来处理。
我们前面知道一级页表不存在,可以通过缺页或者页表同步来处理,但是这里的权限异常我们是没办法修复的了,总不能把原来设置的读写权限给改了吧。所以内核发生一级页表的权限异常时,基本和发了内核不支持的fault处理流程类似。这里也可以看到do_sect_fault()中最后也是调用do_bad_area()来处理善后的,具体不分析了。
pmd矫正之后需要接着矫正pte。pte的矫正和pmd矫正内容类似,包括page的base address和 flag。具体是在handle_pte_fault中处理。在看handle_pte_fault之前,先看下linux对pte的设计。
linux基于通用设计的原因,需要兼容不同厂家的MMU。所以内核设计了pte sw。在软件层关心的是pte sw,硬件页表则有pte hw,中间做一次映射关系。这样软件即不用大改,也能兼容不同厂家的MMU。pte sw和pte hw的映射关系如下:
pte hw的解释可以参考ARM manula,pte sw的flags解释如下:
L_PTE_PRESENT 页在内存中但是不可读写。典型的用途是写时复制。 1.没有分配物理页 2.已分配物理页,但被交换出去
L_PTE_YOUNG 在分配物理页的时候被置为1,当解除映射时被置为0。
L_PTE_FILE 页是非线性映射
L_PTE_NONE 没有分配物理页
L_PTE_DIRTY 页脏(写过了?) 如果一个page需要写权限,必须将L_PTE_DIRTY置为1。
L_PTE_RDONLY 页是只读的。只要设置了这个bit为1,无论L_PTE_DIRTY是否被置位,该page都是只读。(所以如果需要将一个page设置为write权限,需要做两步:1.将L_PTE_DIRTY置1 2.将L_PTE_ONLY置0)。
L_PTE_USER 页属于用户
L_PTE_XN 页内容可执行
L_PTE_SHARED 页是共享的
另外看下内核对pte sw的默认设置(_PAGE前缀):
可以看到pte sw初始化时所有page都被赋值_L_PTE_DEFAULT,而这个宏就是将L_PTE_PRESENT和L_PTE_YOUNG置1!
在看内核页表矫正之前,先看下内核二级页表的结构:
我们知道内核二级页表有256条entry,每条4Byte,所以一张二级页表(也就是一项pmd)需占用1KB。并且我们知道内核pte 有sw和hw之分。所以一张二级页表事实需要对应2KB的size。内核为了充分利用内存,在分配pmd的时候直接alloc一个page,将一个page拆分成两个张二级页表。也就是上图看到的pmd[0] pmd[1]。两张页表就正好占满一个5KB的物理page。一点都不浪费。而且从结构看,page的上班部分存放的是pte sw,下半部分存放的才是pte hw。在pmd中填写的是pte hw的base address,一个page的第三个1KB存放到了pmd[0]中,第四个1KB存放到了pmd[1]中。
从前面的分析可以看到,缺页处理发生在 1.用户空间一级页表转换出错 2. 二级页表转换出错 3. 二级页表权限出错。
先看下do_page_fault的简单流程。
可以看到do_page_fault最后会调用handle_mm_fault。
先看下handl_mm_fault的流程:
handle_mm_fault中是对__handle_mm_fault的封装,这个函数中有很多huge page的判断,我们的平台不支持,所以跳过这部分内容。在__handle_mm_fault函数中最重要的就是这个判断语句。
3439 if (unlikely(pmd_none(*pmd)) &&
3440 unlikely(__pte_alloc(mm, vma, pmd, address)))
3441 return VM_FAULT_OOM;
因为内核支持的是二级页表,所以这里的pmd就是pgd。如果一级页表条目pgd都是空的,那么我们就需要线修复一级页表。
pmd中填入的pmd描述符包含两部分信息:1.二级页表(pte table)的base address.2.flag。pmd的矫正也就是矫正这两部分的过程。
pmd为none,则内核通过__pte_alloc首先分配一个page来作为pte table使用,然后经过计算组织成pmd描述符填入到pmd table中。
如果一级页表条目不为空,那就接着再调用handle_pte_fault进行pte修复。
handle_pte_fault的流程如下:
因为缺页的原因有很多,比如页被swap出去了或者没有分配物理内存。没有分配物理内存又分为,文件映射和普通的匿名内存分配。 所以这个handle_pte_fault就需要逐项判断。
这里提一下,比如页被swap换出时,L_PTE_YOUNG应为1,L_PTE_PRESENT为0。
接着我们还需要判断pte,如果pte不是none,才是页被swap。
这里主要分析匿名page的分配过程。文件映射以及swap换页机制将来再分析。
匿名映射(简单的理解,映射内容非文件等类型)体现了linux为进程分配物理空间的基本态度,不到实在不行的时候不分配物理页。当使用malloc/mmap申请映射一段物理空间时,内核只是给该进程创建了段线性区vma,但并未映射物理页,然后如果试图去读这段申请的进程空间,由于未创建相应的一级或二级页表条目,MMU会发出缺页异常,而这时内核依然只是把一个默认的零页zero_pfn(zero page在内核初始化过程中已经创建好)给vma映射过去;当应用程序又试图写这段申请的物理空间时,这就是实在不行的时候了,内核才会给vma映射物理页。大致流程如下:
这里的重点是如果不是FAULT_FLAG_WRITE导致的data abort,内核会取出zero page然后组合出pte。这时pte中的flags有:L_PTE_PRESENT、L_PTE_YOUNG、L_PTE_RDONLY。所以这个缺页进程对这个zero page只有读权限。如果对这个page执行写操作时还是会触发data abort,这时就进入了COW流程,在COW中会判断page是不是zero page,如果是zero page会分配一个新的page。
回头再看pte矫正的流程,L_PTE_PRESENT为0时我们走了按需分配/按需调页的流程,那当L_PTE_PRESENT为1需要怎么处理呢?
L_PTE_PRESENT为1表示当前访问的页在物理内存中,但是分以下两种情况:
1. 缺少写权限导致的data abort
2. 而是当前页在物理内存中但是映射关系被解除(_PTE_YOUNG已置0,且非写权限导致的data abort)
针对情况2我们只需要将当前的L_PTE_YOUNG位置1即可,而情况1就是我们碰到的写时拷贝(COW),也就是针对一个RDONLY的page执行了write操作。例如,进程拷贝中,son会通过dup_mmap函数拷贝parent的页表。在拷贝过程中会将父子页表中的pte条目访问权限改为写保护L_PTE_RDONLY。这导致任何一方在后续执行中只能对相关页进行read操作,进行write操作必然会出现异常。如果发生异常,就会通过缺页处理函数进入到这个handle_pte_fault中的COW流程。COW流程如下: