7.缺页异常
图4-17给出了内核在处理缺页异常时,可能使用的各种代码路径的一个粗略的概观。
arch/x86/kernel/entry_32.S中的一个汇编例程用作缺页异常的入口,但其立即调用了arch/x86/mm/fault_32.c中的C例程do_page_fault。
图4-18给出了该例程的代码流程图。
![](https://img2022.cnblogs.com/blog/2355803/202203/2355803-20220326212057191-1946269674.png)
所需处理的情况比较复杂,因此有必要非常详细地考察do_page_fault的实现。该例程需要传递两个参数:发生异常时使用中的寄存器集合,提供异常原因信息的错误代码(longerror_code)。目前error_code只使用了前5个比特位(
0、1、2、3、4),其语义将在表4-1中给出。
![](https://img2022.cnblogs.com/blog/2355803/202203/2355803-20220326212212591-1134299272.png)
arch/x86/mm/fault_32.c fastcall void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code) { struct task_struct *tsk; struct mm_struct *mm; struct vm_area_struct * vma; unsigned long address; unsigned long page; int write, si_code; int fault; ... /* 获取地址 */ address = read_cr2(); //在IA-32处理器上,其地址保存在寄存器CR2中,寄存器的内容通过read_cr2复制到address。
...
声明很多供后续使用的变量之后,内核在address中保存了触发异常的地址。
arch/x86/mm/fault_32.c tsk = current; si_code = SEGV_MAPERR; /* * 我们因异常而进入到内核虚拟内存空间。 * 参考页表为init_mm.pgd。 * * 要注意!对这种情况我们不能获取任何锁。 * 我们可能是在中断或临界区中, * 只应当从主页表复制信息,不允许其他操作。 * * 下述代码验证了异常发生于内核空间(error_code & 4) == 0, * 而且异常不是保护错误(error_code & 9) == 0。 */ if (unlikely(address >= TASK_SIZE)) { if (!(error_code & 0x0000000d) && vmalloc_fault(address) >= 0) return; /* *不要在这里获取mm信号量。 *如果修复了取指令造成的缺页异常,则会进入死锁。 */ goto bad_area_nosemaphore; } ...
如果地址超出用户地址空间的范围,则表明是vmalloc异常。因此该进程的页表必须与内核的主页表中的信息同步。实际上,只有访问发生在核心态,而且该异常不是由保护错误触发时,才能允许这样做。换句话说,错误代码的比特位2、3、0都不能设置。
内核使用辅助函数vmalloc_fault同步页表。我不会详细地给出其代码,因为该函数只是从init的页表(在IA-32系统上,这是内核的主页表)复制相关的项到当前页表。如果其中没有找到匹配项,则内核调用fixup_exception,作为试图从异常恢复的最后尝试,我稍后会讨论该函数。
如果异常是在中断期间或内核线程中触发,也没有自身的上下文因而也没有独立的mm_struct实例,则内核会跳转到bad_area_nosemaphore标号。
arch/x86/mm/fault_32.c mm = tsk->mm; /* * 如果我们是在中断期间,也没有用户上下文,或者代码处于原子操作范围内,则不能处理该异常。 */ if (in_atomic() || !mm) goto bad_area_nosemaphore; ... bad_area_nosemaphore: /* 用户状态的访问导致了SIGSEGV */ if (error_code & 4) { ... force_sig_info_fault(SIGSEGV, si_code, address, tsk); return; } no_context: /* 准备好处理这个内核异常了吗? */ if (fixup_exception(regs)) return;
如果异常源自用户空间(error_code的比特位2置位),则返回段错误。但如果异常源自内核空间,则调用fixup_exception。我在下文描述该函数。
如果异常并非出现在中断期间,也有相关的上下文,则内核检查进程的地址空间是否包含异常地址所在的区域。它调用了find_vma函数,我们在4.对区域的操作已经知道,该函数可用于完成此工作。
arch/x86/mm/fault_32.c vma = find_vma(mm, address); if (!vma) goto bad_area; if (vma->vm_start <= address) goto good_area; if (!(vma->vm_flags & VM_GROWSDOWN)) goto bad_area; ... if (expand_stack(vma, address)) goto bad_area;
在内核发现地址有效或无效时,会分别跳转到good_area和bad_area标号。
搜索可能得到下面各种不同的结果。
没有找到结束地址在address之后的区域,在这种情况下访问是无效的。
异常地址在找到的区域内,在这种情况下访问是有效的,缺页异常由内核负责恢复。
找到一个结束地址在异常地址之后的区域,但异常地址不在该区域内。这可能有下面两种原因。
该区域的VM_GROWSDOWN标志置位。这意味着区域是栈,自顶向下增长。接下来调用expand_stack适当地增大栈。如果成功,则结果返回0,内核在good_area标号恢复执行。否则,认为访问无效。
找到的区域不是栈,访问无效。
在上述代码之后,是good_area相关的处理逻辑。
arch/x86/mm/fault_32.c ... good_area: si_code = SEGV_ACCERR; write = 0; switch (error_code & 3) { default: /* 3: 写,不缺页 */ /* 处理同2 */ case 2: /* 写,缺页 */ if (!(vma->vm_flags & VM_WRITE)) goto bad_area; write++; break; case 1: /* 读,不缺页 */ goto bad_area; case 0: /* 读,缺页 */ if (!(vma->vm_flags & (VM_READ | VM_EXEC))) goto bad_area; } ...
如果内核没有显式跳转到bad_area,则代码的执行将贯穿case语句,到达下面给出的handle_mm_fault调用。该函数负责校正缺页异常(即,读取所需数据)。
arch/x86/mm/fault_32.c ... survive: /* *如果由于某些原因我们无法处理异常,则必须优雅地退出,而不是一直重试。 */ fault = handle_mm_fault(mm, vma, address, write); if (unlikely(fault & VM_FAULT_ERROR)) { if (fault & VM_FAULT_OOM) goto out_of_memory; else if (fault & VM_FAULT_SIGBUS) goto do_sigbus; BUG(); } if (fault & VM_FAULT_MAJOR) tsk->maj_flt++; else tsk->min_flt++; return; ... }
handle_mm_fault是一个体系结构无关的例程,用于选择适当的异常恢复方法(按需调页、换入,等等),并应用选择的方法。
如果页成功建立,则例程返回VM_FAULT_MINOR(数据已经在内存中)或VM_FAULT_MAJOR(数据需要从块设备读取)。内核接下来更新进程的统计量。
但在创建页时,也可能发生异常。如果用于加载页的物理内存不足,内核会强制终止该进程,在最低限度上维持系统的运行。如果对数据的访问已经允许,但由于其他的原因失败(例如,访问的映射已经在访问的同时被另一个进程收缩,不再存在于给定的地址),则将SIGBUS信号发送给进程。
用户空间缺页异常的校正
在结束对缺页异常的特定于体系结构的分析之后,确认异常是在允许的地址触发,内核必须确定将所需数据读取到物理内存的适当方法。该任务委托给handle_mm_fault,handle_pte_fault函数分析缺页异常的原因。pte是指向相关页表项
(pte_t)的指针。
mm/memory.c static inline int handle_pte_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pte_t *pte, pmd_t *pmd, int write_access) { pte_t entry; spinlock_t *ptl; entry = *pte; if (!pte_present(entry)) { if (pte_none(entry)) { if (vma->vm_ops) { return do_linear_fault(mm, vma, address, pte, pmd, write_access, entry); } return do_anonymous_page(mm, vma, address, pte, pmd, write_access); } if (pte_file(entry)) return do_nonlinear_fault(mm, vma, address, pte, pmd, write_access, entry); return do_swap_page(mm, vma, address, pte, pmd, write_access, entry); } ... }
如果页不在物理内存中,即!pte_present(entry),则必须区分下面3种情况。
(1) 如果没有对应的页表项(page_none),则内核必须从头开始加载该页,对匿名映射称之为按需分配(demand allocation),对基于文件的映射,则称之为按需调页(demand paging)。如果vm_ops中没有注vm_operations_struct,则不适用上述做法。在这种情况下,内核必须使用do_anonymous_page返回一个匿名页。
(2) 如果该页标记为不存在,而页表中保存了相关的信息,则意味着该页已经换出,因而必须从系统的某个交换区换入(换入或按需调页)。
(3) 非线性映射已经换出的部分不能像普通页那样换入,因为必须正确地恢复非线性关联。pte_file函数可以检查页表项是否属于非线性映射,do_nonlinear_fault在这种情况下可用于处理异常。
如果该区域对页授予了写权限,而硬件的存取机制没有授予(因此触发异常),则会发生另一种潜在的情况。请注意,此时对应的页已经在内存中,因而执行上述的第一个if语句之后,内核将直接跳到下述代码:
mm/memory.c if (write_access) { if (!pte_write(entry)) return do_wp_page(mm, vma, address, pte, pmd, ptl, entry); entry = pte_mkdirty(entry); } ...
do_wp_page负责创建该页的副本,并插入到进程的页表中(在硬件层具备写权限)。该机制称为写时复制。在进程发生分支时,页并不是立即复制的,而是映射到进程的地址空间中作为“只读”副本,以免在复制信息时花费太多时间。在实际发生写访问之前,都不会为进程创建页的独立副本。
按需分配/调页
按需分配页的工作委托给do_linear_fault,该函数定义在mm/memory.c中。在转换一些参数之后,其余的工作委托给__do_fault,其代码流程图在图4-19给出。
首先,内核必须确保将所需数据读入到发生异常的页。具体的处理依赖于映射到发生异常的地址空间中的文件,因此需要调用特定于文件的方法来获取数据。通常该方法保存在vm->vm_ops->fault。由于较早的内核版本使用的方法调用约定不同,内核必须考虑到某些代码尚未更新到新的调用约定。因此,如果没有注册fault方法,则调用旧的vm->vm_ops->nopage。
大多数文件都使用filemap_fault读入所需数据。该函数不仅读入所需数据,还实现了预读功能,即提前读入在未来很可能需要的页。目前我们只需知道,内核使用address_space对象中的信息,从后备存储器将数据读取到物理
![](https://img2022.cnblogs.com/blog/2355803/202203/2355803-20220327180921107-52907847.png)
给定涉及区域的vm_area_struct,内核选择使用何种方法读取页?
(1) 使用vm_area_struct->vm_file找到映射的file对象。
(2) 在file->f_mapping中找到指向映射自身的指针。
(3) 每个地址空间都有特定的地址空间操作,从中选择readpage方法。使用mapping->a_ops->readpage(file, page)从文件中将数据传输到物理内存。
如果需要写访问,内核必须区分共享和私有映射。对私有映射,必须准备页的一份副本。
mm/memory.c static int __do_fault(struct mm_struct *mm, struct vm_area_struct *vma, unsigned long address, pmd_t *pmd, pgoff_t pgoff, unsigned int flags, pte_t orig_pte) { ... /* * 应该进行写时复制吗? */ if (flags & FAULT_FLAG_WRITE) { if (!(vma->vm_flags & VM_SHARED)) { anon = 1; if (unlikely(anon_vma_prepare(vma))) { //重新分配新的一个anon_vma实例 ret = VM_FAULT_OOM; goto out; } page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address); ... } copy_user_highpage(page, vmf.page, address, vma); } ...
在用anon_vma_prepare(指向原区域的指针,在anon_vma_prepare中会重定向到新的区域)为区域建立一个新的anon_vma实例之后,必须分配一个新的页。这里会优先使用高端内存域,因为该内存域对用户空间页是没有问题的。copy_user_highpage接下来创建数据的一份副本。
既然已经知道页的位置,则需要将其加入进程的页表,再合并到逆向映射数据结构中。在完成这些之前,需要用flush_icache_page更新缓存,确保页的内容在用户空间可见。大多数处理器都不需要这个步骤,一般定义为空操作。)
指向只读页的页表项通常使用mk_pte函数产生。如果建立具有写权限的页,内核必须用pte_mkwrite显式设置写权限。
页集成到逆向映射的具体方式,取决于其类型。如果在处理写访问权限时生成的页是匿名的,则使用lru_cache_add_active将其加入到LRU缓存的活动区域中,然后用page_add_new_anon_rmap集成到逆向映射中。所有其他与基于文件的映射关联的页,则调用page_add_file_rmap。这两个函数都在5.内存映射讨论过。最后,必须更新处理器的MMU缓存,因为页表已经修改。(特别注意:该版本这里代码处理有缺陷,详细请见LINUX中匿名页的反向映射)
匿名页
对于没有关联到文件作为后备存储器的页,需要调用do_anonymous_page进行映射。除了无需向页读入数据之外,该过程几乎与映射基于文件的数据没什么不同。在highmem内存域建立一个新页,并清空其内容。接下来将页加入到进程的页表,并更新高速缓存或者MMU。
写时复制
写时复制在do_wp_page中处理,其代码流程图如图4-20所示。(我们考察的是一个略微简化的版本)
![](https://img2022.cnblogs.com/blog/2355803/202203/2355803-20220327184135825-787440929.png)
内核首先调用vm_normal_page,通过页表项找到页的struct page实例,这是可行的,因为写时复制机制只对内存中实际存在的页调用(否则,首先需要通过缺页异常机制自动加载)。
在用page_cache_get获取页之后,接下来anon_vma_prepare准备好逆向映射机制的数据结构,以接受一个新的匿名区域。由于异常的来源是需要将一个充满有用数据的页复制到新页,因此内核调用alloc_page_vma分配一个新页。cow_user_page接下来将异常页的数据复制到新页,进程随后可以对新页进行写操作。然后使用page_remove_rmap,删除到原来的只读页的逆向映射。新页添加到页表,此时也必须更新CPU的高速缓存。
最后,使用lru_cache_add_active将新分配的页放置到LRU缓存的活动列表上,并通过page_add_anon_rmap将其插入到逆向映射数据结构。此后,用户空间进程可以向页写入数据。
内核缺页异常
在访问内核地址空间时,缺页异常可能被各种条件触发,如下所述。
内核中的程序设计错误导致访问不正确的地址,这是真正的程序错误。当然,这在稳定版本中应该永远都不会发生,但在开发版本中会偶尔发生。
内核通过用户空间传递的系统调用参数,访问了无效地址。
访问使用vmalloc分配的区域,触发缺页异常。
前两种情况是真正的错误,内核必须对此进行额外的检查。vmalloc的情况是导致缺页异常的合理原因,必须加以校正。直至对应的缺页异常发生之前,vmalloc区域中的修改都不会传输到进程的页表。
在向或从用户空间复制数据时,如果访问的地址在虚拟地址空间中不与物理内存页关联,则会发生缺页异常。对用户状态发生的该情况,我们已经熟悉。在应用程序访问一个虚拟地址时,内核将使用上文讨论的按需调页机制,自动并透明地返回一个物理内存页。如果访问发生在核心态,异常同样必须校正,但使用的方法稍有不同。
每次发生缺页异常时,将输出异常的原因和当前执行代码的地址。这使得内核可以编译一个列表,列出所有可能执行未授权内存访问操作的危险代码块。这个“异常表”在链接内核映像时创建,在二进制文件中位于__start_exception_table和__end_exception_table之间。每个表项都对应于一个struct exception_table实例,该结构尽管是体系结构相关的,但通常都是如下定义:
struct exception_table_entry { unsigned long insn, fixup; };
insn指定了内核预期在虚拟地址空间中发生异常的位置。fixup指定了发生异常时执行恢复到哪个代码地址。
fixup_exception用于搜索异常表,并且在IA-32系统上如下定义:
arch/x86/mm/extable_32.c int fixup_exception(struct pt_regs *regs) { const struct exception_table_entry *fixup; fixup = search_exception_tables(regs->eip); if (fixup) { regs->eip = fixup->fixup; return 1; } return 0; }
regs->eip指向EIP寄存器,在IA-32处理器上包含了触发异常的代码段地址。search_exception_tables扫描异常表,查找适当的匹配项。
在找到修正例程时,将指令指针设置到对应的内存位置。在fixup_exception通过return返回后,内核将执行找到的例程。