在取指令或者数据的时候,处理器的MMU单元需要把虚拟地址转换成物理地址。如果虚拟页没有映射到物理页,或者没有访问权限,处理器将生成页错误异常。
缺页异常,虚拟页没有映射到物理页,有以下几种情况:
(1)访问用户栈的时候,超出了当前用户栈的范围,需要扩大用户栈。
(2)当进程申请虚拟内区域的时候,通常是没有分配物理页,进程第一次访问的时候触发页错误异常。
(3)内存不足的时候,内核把进程的匿名页换出到交换区。
(4)一个文件页被映射到进程的虚拟地址空间,内存不足的时候,内核回收这个文件页,在进程的页表中删除这个文件页的映射。
(5)程序错误,访问没有分配给进程的虚拟内存区域。
针对前四种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。第五种异常,发送段违法信号(SIGSEGV)杀死进程。
没有访问权限,有以下两种情况:
(1)可能是软件有意造成的,典型的例子是写时复制:进程分叉成子进程的时候,为了避免复制物理页,子进程和父进程以只读的方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常 ,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页。
(2)程序错误,例如试图写只读的代码段所在的物理页。
第一种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。第二种情况,页错误异常处理程序将会发送段违法(SIGSEGV)信号杀死进程。
不同处理器架构实现的页错误异常不同,页错误异常处理程序的前面一部分是各种处理器架构自定义的部分,后面从函数handle_mm_fault开始的部分是所有处理器架构共用的部分。
1 处理器架构特定部分
1.1 处理页异常错误
ARM64架构的内核定义了一个异常向量表,起始地址是vectors(arch/arm64/kernel/entry.S),每个异常向量的长度是128字节,但是在Linux内核的每个异常向量只有一条指令:跳转到对应的处理器程序。异常向量表的虚拟地址存放在异常级别1的向量基准地址寄存器VBAR_EL1中。
处理器生成页错误异常,页错误异常属于同步异常,处理器立即处理,从向量基准地址寄存器得到异常向量表的虚拟地址,然后根据异常类型选择对应的异常向量。
(1)如果异常类型是异常级别1生成的同步异常,异常向量的偏移是0x200,跳转到函数el1_sync。
(2)如果异常类型是异常级别0的64bit用户态程序生成的同步异常,异常向量的偏移是0x400,跳转到el0_sync。
(3)如果异常类型是异常级别0的32bit用户态程序生成的同步异常,异常向量的偏移是0x600,跳转到el0_sync_compat。
以el0_sync为例,函数el0_sync根据异常级别1的异常症状寄存器的异常类别字段处理。
(1)如果异常类型是异常级别0生成的数据中止,即在异常级别0访问数据时生成页错误异常,那么调用函数el0_da。
(2)如果异常类型是异常级别0生成的指令中止,即在异常级别0取指令时生成页错误异常,那么调用函数el0_ia。
对于ARM64处理器,异常级别1的异常症状寄存器(ESR_EL1)用来存放异常的症状信息。
EC:ESR_EL1[26:31],异常类别,指示引起异常的原因。
ISS:ESR_EL1[0:24],每种异常类别独立定义这个字段。
页错误异常处理程序最终都会执行到函数do_mem_abort,该函数根据异常症状寄存器的指令特定症状字段的指令错误状态码(0~5),调用数组fault_info中的处理函数。
static struct fault_info {
int (*fn)(unsigned long addr, unsigned int esr, struct pt_regs *regs);
int sig;
int code;
const char *name;
} fault_info[] = {
{ do_bad, SIGBUS, 0, "ttbr address size fault" },
{ do_bad, SIGBUS, 0, "level 1 address size fault" },
{ do_bad, SIGBUS, 0, "level 2 address size fault" },
{ do_bad, SIGBUS, 0, "level 3 address size fault" },
{ do_translation_fault, SIGSEGV, SEGV_MAPERR, "level 0 translation fault" },
{ do_translation_fault, SIGSEGV, SEGV_MAPERR, "level 1 translation fault" },
{ do_translation_fault, SIGSEGV, SEGV_MAPERR, "level 2 translation fault" },
{ do_translation_fault, SIGSEGV, SEGV_MAPERR, "level 3 translation fault" },
{ do_bad, SIGBUS, 0, "unknown 8" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 1 access flag fault" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 2 access flag fault" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 3 access flag fault" },
{ do_bad, SIGBUS, 0, "unknown 12" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 1 permission fault" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 2 permission fault" },
{ do_page_fault, SIGSEGV, SEGV_ACCERR, "level 3 permission fault" },
{ do_bad, SIGBUS, 0, "synchronous external abort" },
{ do_bad, SIGBUS, 0, "unknown 17" },
{ do_bad, SIGBUS, 0, "unknown 18" },
{ do_bad, SIGBUS, 0, "unknown 19" },
{ do_bad, SIGBUS, 0, "synchronous abort (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous abort (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous abort (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous abort (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous parity error" },
{ do_bad, SIGBUS, 0, "unknown 25" },
{ do_bad, SIGBUS, 0, "unknown 26" },
{ do_bad, SIGBUS, 0, "unknown 27" },
{ do_bad, SIGBUS, 0, "synchronous parity error (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous parity error (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous parity error (translation table walk)" },
{ do_bad, SIGBUS, 0, "synchronous parity error (translation table walk)" },
{ do_bad, SIGBUS, 0, "unknown 32" },
{ do_bad, SIGBUS, BUS_ADRALN, "alignment fault" },
{ do_bad, SIGBUS, 0, "unknown 34" },
{ do_bad, SIGBUS, 0, "unknown 35" },
{ do_bad, SIGBUS, 0, "unknown 36" },
{ do_bad, SIGBUS, 0, "unknown 37" },
{ do_bad, SIGBUS, 0, "unknown 38" },
{ do_bad, SIGBUS, 0, "unknown 39" },
{ do_bad, SIGBUS, 0, "unknown 40" },
{ do_bad, SIGBUS, 0, "unknown 41" },
{ do_bad, SIGBUS, 0, "unknown 42" },
{ do_bad, SIGBUS, 0, "unknown 43" },
{ do_bad, SIGBUS, 0, "unknown 44" },
{ do_bad, SIGBUS, 0, "unknown 45" },
{ do_bad, SIGBUS, 0, "unknown 46" },
{ do_bad, SIGBUS, 0, "unknown 47" },
{ do_bad, SIGBUS, 0, "TLB conflict abort" },
{ do_bad, SIGBUS, 0, "unknown 49" },
{ do_bad, SIGBUS, 0, "unknown 50" },
{ do_bad, SIGBUS, 0, "unknown 51" },
{ do_bad, SIGBUS, 0, "implementation fault (lockdown abort)" },
{ do_bad, SIGBUS, 0, "implementation fault (unsupported exclusive)" },
{ do_bad, SIGBUS, 0, "unknown 54" },
{ do_bad, SIGBUS, 0, "unknown 55" },
{ do_bad, SIGBUS, 0, "unknown 56" },
{ do_bad, SIGBUS, 0, "unknown 57" },
{ do_bad, SIGBUS, 0, "unknown 58" },
{ do_bad, SIGBUS, 0, "unknown 59" },
{ do_bad, SIGBUS, 0, "unknown 60" },
{ do_bad, SIGBUS, 0, "section domain fault" },
{ do_bad, SIGBUS, 0, "page domain fault" },
{ do_bad, SIGBUS, 0, "unknown 63" },
};
虚拟页没有映射到物理页的情况:
(1)如果在0级、1级或2级转换表中的匹配的表项是无效描述符,调用函数do_translation_fault来处理;如果是在3级转换表中匹配的表项是无效描述符,调用函数do_page_fault来处理。
(2)如果在1级、2级或3级转换表中匹配的表项是块描述符或页描述符,但是没有设置访问标志,那么调用函数do_page_fault将会为页表项设置访问标志。页回收算法需要根据页表项的访问标志判断物理页是不是刚刚被访问过。
(3)如果是权限错误:在1级,2级或3级转换表中匹配的表项是块描述符或页描述符,但是没有访问权限,那么调用do_page_fault。
do_translation_fault判断是如果触发异常的虚拟地址是用户虚拟地址,调用函数do_page_fault来处理;如果触发异常的虚拟地址是内核虚拟地址或不规范地址,调用函数do_bad_area来处理。
do_page_fault中检查不同是否在原子上下文中,以及触发地址的权限问题。正常情况下,通过__do_page_fault处理页错误异常。
禁止执行页错误异常处理程序的情况:有些系统调用传入用户空间的缓冲区,内核使用用户虚拟地址访问缓冲区,可能生成页错误异常;然而页错误异常处理程序可能睡眠,但内核在原子上下文中,并不能睡眠;所以在原子上下文中,使用用户虚拟地址访问缓冲区之前,调用函数pagefault_disable禁止执行页错误异常处理程序。
__do_page_fault根据触发异常的虚拟地址在进程的虚拟内存区域的红黑树中查找一个满足条件的虚拟内存区域:触发异常的虚拟地址小于虚拟内存区域的结束地址;没找到虚拟内存区域说明虚拟地址是非法的,返回VM_FAULT_BADMAP;判断这个区域是否是栈,是栈的话调用expand_stack,扩大栈的虚拟内存区域,扩大成功,检测权限,然后调用函数handle_mm_fault处理页错误异常;非栈的情况下,检查访问权限,如果虚拟内存区域没有授予触发页错误异常的访问权限,然后调用handle_mm_fault。
最终在虚拟地址合法,权限正常的情况下都会调用到handle_mm_fault函数处理页错误异常。
2 用户空间页错误异常
从函数handle_mm_fault开始的部分是所有处理器架构共用的部分,函数handle_mm_fault负责处理用户空间的页错误异常。用户空间页错误异常是指进程访问用户虚拟地址生成的页错误异常,分两种情况:
(1)进程在用户模式下访问用户虚拟地址,生成页错误异常。
(2)进程在内核模式下访问用户虚拟地址,生成页错误异常。
handle_mm_fault主要流程:创建触发异常的虚拟地址对应的各级页表的页表项,然后调用handle_pte_fault。
2.1 handle_pte_fault
handle_pte_fault执行流程如下:
handle_pte_fault
-->页表项无效的情况下:处理页表项无效
-->私有匿名映射 -->do_anonymous_page
-->文件映射/共享匿名映射 -->do_fault
-->页不在内存中-->do_swap_page
-->页在内存中:处理页在内存中的情况
-->处理写访问的情况
-->页表项没有设置写权限位-->执行写时复制(do_wp_page)
-->pte_mkdirty
-->pte_mkyoung
-->ptep_set_access_flags
-->页表项是否变化
-->是:update_mmu_cache
-->否:flush_tlb_fix_spurious_fault
2.2 匿名页的缺页异常
以下三种情况下会触发匿名页的缺页异常:
(1)函数的局部变量比较大,或者函数调用的层次比较深,导致当前栈不够用,需要扩大栈。
(2)进程调用malloc,从堆申请了内存块,只分配虚拟内存区域,还没有映射到物理页,第一次访问时触发缺页异常。
(3)进程直接调用mmap,创建匿名的内存映射,只分配了虚拟内存区域,还没映射到物理页,第一次访问时触发缺页异常。
2.3 文件页的缺页异常
以下两种情况下会触发文件页的缺页异常:
(1)启动程序的时候,内核为程序的代码段和数据段创建私有的文件映射,映射到进程的虚拟地址空间,第一次访问时,触发文件页的缺页异常。
(2)进程使用mmap创建文件映射,把文件的一个区间映射到进程的虚拟地址空间,第一次访问时,触发文件的缺页异常。
do_fault的执行流程:
do_fault
-->没有提供vma->vm_ops->fault,返回VM_FAULT_SIGBUS
-->读文件页错误,do_read_fault
-->写私有文件页错误,do_cow_fault
-->写共享文件页错误,do_shared_fault
2.3.1处理读文件页错误
(1)把文件页从存储设备上的文件系统读到文件的页缓存(每个文件有一个缓存,因为以页为单位,所以称为页缓存)。
(2)设置进程的页表项,把虚拟页映射到文件的页缓存中的物理页。
2.3.2处理写私有文件错误
(1)把文件从存储设备上的文件系统读到文件的页缓存中。
(2)执行写时复制,为文件的页缓存中的物理页创建一个副本,这个副本是进程的私有匿名页,和文件脱离关系,修改副本不会导致文件变化。
(3)设备进程的页表项,把虚拟页映射到副本。
2.3.3处理写共享文件错误
(1)把文件页从存储设备上的文件系统读到文件的页缓存中。
(2)设置进程的页表项,把虚拟页映射到文件的页缓存中的物理页。
2.3.4 写时复制
写时复制的两个场景:
(1)进程分叉成子进程的时候,为了避免复制物理页,子进程和父进程以只读的方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页上。
(2)进程创建私有的文件映射,然后读访问,触发页错误异常,异常处理程序把文件读到页缓存,然后以只读模式把虚拟页映射到文件的页缓存中的物理页。接着执行写访问,触发页错误异常,异常处理程序执行写时复制,为文件的页缓存中的物理页创建一个副本,把虚拟页映射到副本。这个副本是进程的私有匿名页,和文件脱离关系,修改副本不会导致文件变化。
3 内核模式页错误异常
内核访问内核虚拟地址,正常情况下不会出现虚拟页没有映射到物理页的状况,内核使用线性映射区域的虚拟地址,在内存管理子系统初始化的时候就会把虚拟地址映射到物理地址;运行过程中,可能使用vmalloc函数从vmalloc区域分配虚拟内存区域,vmalloc函数会分配并且映射到物理页。如果出现虚拟页没有映射到物理页的情况,一定是程序错误,内核将会崩溃。
内核可能访问用户虚拟地址,进程通过系统调用进入内核模式,有些系统调用会传入用户空间的缓冲区,内核必须使用头文件uaccess.h定义的专用函数访问用户空间的缓冲区,这些专用函数在异常表中添加了可能触发异常的指令地址和异常修正程序的地址。
在内核模式下执行时触发页错误异常,ARM64架构内核的处理流程如下:
(1)如果不允许内核执行用户空间的指令,那么进程在内核模式下试图执行用户空间的指令时,内核崩溃。
(2)如果进程在内核模式下访问用户虚拟地址,那么先使用函数__do_page_fault处理;如果处理失败,最后通过__do_kernel_fault处理。
(3)其他情况使用函数__do_kernel_fault处理。
__do_kernel_fault针对数据的访问触发的异常,尝试在异常表中查找异常修成程序。如果找到异常修正程序,把保存在内核栈中的异常链接寄存器(ELR_EL1)的值改为异常修正程序的虚拟地址。当异常处理程序返回时,处理器把程序计数器设置成异常链接寄存器的值,执行异常修正程序。