Linux内核缺页二三事

前言

我们知道虚拟空间和物理内存是通过页表建立起映射关系的,当访问某段虚拟内存时,这种映射关系很有可能是尚未建立的,也有可能是在fork了之后页表被设置了WR模式。如果此时进程想往这部分区域写数据时,就会导致处理器产生异常。内核需要捕获并“修复”这种异常,这一过程就是缺页异常处理。博文中一些图片的copyright是15年看这部分内容时记的笔记,这里没去水印直接借用了。虽然via-telecom已被英特尔收购,但如果设计版权问题请联系博主。

1 缺页硬件支持

1.1 ARM 异常模式

不论是空pte还是pte被设置为wr模,如果向里写数据肯定会触发处理器异常,而且应该是MMU发生的异常。现看ARM官方manual,看下这部分异常硬件是怎么处理的。
首先ARM有好几种异常模式,其中一种是Data Abort。ARM v7 manual对这种异常的解释如下。
Linux内核缺页二三事_第1张图片
这种异常简单说就是发生在memory access阶段,在读/写数据、取指令或者访问页表时均有可能发生。

1.2 ARM 页表结构

ARM v7 core的MMU支持两种类型的页表。
Linux内核缺页二三事_第2张图片
所以如果是不支持大物理地址扩展技术的ARM,只支持Short-descriptor格式。我们只分析Short-descriptor format。

1.3 CP15寄存器

在看当MMU发生异常时候CP15寄存器的变化。
Linux内核缺页二三事_第3张图片
这里提到保存信息的寄存有两个分别是DFSR和DFAR。
DFAR中存放的是发生异常的VA。DFSR则存放的是发生异常的类型。
Linux内核缺页二三事_第4张图片
这里能看到有[10, 3:0] 5个bit来表示FS(FAULT STATUS)。bit[11]来指明发生exception时是因为读指令还是写指令导致的。然后再看下FS支持哪些状态。
Linux内核缺页二三事_第5张图片
这里需要关注的状态是一二级页表的translation fault & permission fault。因为linux内核只支持这四种,如下图所示。
Linux内核缺页二三事_第6张图片

1.4 内核data abort流程

有了这些硬件基础之后,内核就有办法来处理这个data abort异常了。大致的流程如下:MMU translation or permission fault ->异常向量data abort入口->从CP15的c5 & c6寄存器中读出当前的fault类型和触发异常的VA->进入do_DataAbort流程进行修复页表,也就是缺页异常处理。

2 data fault处理

2.1 内核支持的fault类型

有了前面的硬件基础之后,我们就可以看内核的缺页处理过程了。从前面的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

2.1 内核不支持的fault

内核不支持的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。其余情况内核都能继续运行。

2.2 一级页表translation错误

一级页表的translation错误需要调用do_translation_fault()来处理。先看流程图:
Linux内核缺页二三事_第7张图片
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后,并没有分配对应的物理内存,导致直接一级页表出错。

2.3 内核页表同步

什么情况下需要做内核页表的同步?需要做页表映射说明这部分内存应该是动态映射。
我们知道内核在初始化的时候会将低端内存(low_mem)以分段的方式映射到一级页表中了,这部分应该是固定映射。但像vmalloc是内核运行过程中动态分配,这使得内核的一级页表有部分内容是动态更新的。这个vmalloc会更新swapper_pg_dir,也就是这里提到的pmd_k。
另外我们知道每个用户进程都会有一个自己的内核页表拷贝。这个拷贝是进程创建过程中复制来的,而我们的内核页表可能一直在更新,这导致运行过程中,进程的内核页表可能落后于内核页表swapper_pg_dir,所以需要做内核页表同步

2.4 一级页表permission错误

如果是一级页表的权限异常,内核会调用do_sect_fault来处理。
我们前面知道一级页表不存在,可以通过缺页或者页表同步来处理,但是这里的权限异常我们是没办法修复的了,总不能把原来设置的读写权限给改了吧。所以内核发生一级页表的权限异常时,基本和发了内核不支持的fault处理流程类似。这里也可以看到do_sect_fault()中最后也是调用do_bad_area()来处理善后的,具体不分析了。

2.5 缺页处理
2.5.1 内核pte结构

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的映射关系如下:
Linux内核缺页二三事_第8张图片
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前缀):
Linux内核缺页二三事_第9张图片
可以看到pte sw初始化时所有page都被赋值_L_PTE_DEFAULT,而这个宏就是将L_PTE_PRESENT和L_PTE_YOUNG置1!

2.5.2 内核二级页表结构

在看内核页表矫正之前,先看下内核二级页表的结构:
Linux内核缺页二三事_第10张图片
我们知道内核二级页表有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]中。

2.5.3 一级页表pgd/pmd矫正流程

从前面的分析可以看到,缺页处理发生在 1.用户空间一级页表转换出错 2. 二级页表转换出错 3. 二级页表权限出错。
先看下do_page_fault的简单流程。
Linux内核缺页二三事_第11张图片
可以看到do_page_fault最后会调用handle_mm_fault。
先看下handl_mm_fault的流程:
Linux内核缺页二三事_第12张图片
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修复。

2.5.4 pte矫正流程

handle_pte_fault的流程如下:
Linux内核缺页二三事_第13张图片
因为缺页的原因有很多,比如页被swap出去了或者没有分配物理内存。没有分配物理内存又分为,文件映射和普通的匿名内存分配。 所以这个handle_pte_fault就需要逐项判断。
这里提一下,比如页被swap换出时,L_PTE_YOUNG应为1,L_PTE_PRESENT为0。
接着我们还需要判断pte,如果pte不是none,才是页被swap。
这里主要分析匿名page的分配过程。文件映射以及swap换页机制将来再分析。

2.5.4 分配匿名page

匿名映射(简单的理解,映射内容非文件等类型)体现了linux为进程分配物理空间的基本态度,不到实在不行的时候不分配物理页。当使用malloc/mmap申请映射一段物理空间时,内核只是给该进程创建了段线性区vma,但并未映射物理页,然后如果试图去读这段申请的进程空间,由于未创建相应的一级或二级页表条目,MMU会发出缺页异常,而这时内核依然只是把一个默认的零页zero_pfn(zero page在内核初始化过程中已经创建好)给vma映射过去;当应用程序又试图写这段申请的物理空间时,这就是实在不行的时候了,内核才会给vma映射物理页。大致流程如下:
Linux内核缺页二三事_第14张图片
这里的重点是如果不是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。

2.5.6 COW 写时拷贝

回头再看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流程如下:
Linux内核缺页二三事_第15张图片

你可能感兴趣的:(Linux内核)