文章目录
- 重要:本系列文章内容摘自
<Linux内核深度解析>
基于ARM64架构的Linux4.x内核一书,作者余华兵。系列文章主要用于记录Linux内核的大部分机制及参数的总结说明
1 页错误异常处理
在取指令或数据的时候,处理器的内存管理单元需要把虚拟地址转换成物理地址。如果虚拟页没有映射到物理页,或者没有访问权限,处理器将生成页错误异常。
虚拟页没有映射到物理页,这种情况通常称为缺页异常,有以下几种情况:
(1)访问用户栈的时候,超出了当前用户栈的范围,需要扩大用户栈。
(2)当进程申请虚拟内存区域的时候,通常没有分配物理页,进程第一次访问的时候触发页错误异常。
(3)内存不足的时候,内核把进程的匿名页换出到交换区。
(4)一个文件页被映射到进程的虚拟地址空间,内存不足的时候,内核回收这个文件页,在进程的页表中删除这个文件页的映射。
(5)程序错误,访问没有分配给进程的虚拟内存区域。
前面四种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。
第五种情况,页错误异常处理程序将会发送段违法(SIGSEGV)信号以杀死进程。
没有访问权限,有以下两种情况:
(1)可能是软件有意造成的,典型的例子是写时复制(Copy on Write,CoW):进程分叉生成子进程的时候,为了避免复制物理页,子进程和父进程以只读方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页。
(2)程序错误,例如试图写只读的代码段所在的物理页。
第一种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。
第二种情况,页错误异常处理程序将会发送段违法(SIGSEGV)信号以杀死进程。
不同处理器架构实现的页错误异常不同,页错误异常处理程序的前面一部分是各种处理器架构自定义的部分,后面从函数handle_mm_fault开始的部分是所有处理器架构共用的部分。
1.1 处理器架构特定部分
1.生成页错误异常
首先声明,本节不考虑ARM64处理器的虚拟化扩展和安全扩展。本节假定ARM64处理器带有内存管理单元,并且开启了内存管理单元。ARM64处理器在没有开启内存管理单元的情况下,不会转换虚拟地址,物理地址等于虚拟地址。
ARM64处理器在取指令或数据的时候,需要把虚拟地址转换成物理地址,分两种情况:
(1)如果虚拟地址的高16位不是全1或全0(假设使用48位虚拟地址),是非法地址,生成页错误异常。
(2)如果虚拟地址的高16位是全1或全0,内存管理单元根据关键字{地址空间标识符,虚拟地址}查找TLB。
通常使用寄存器TTBR0_EL1的高16位存放正在执行的进程的地址空间标识符(TTBR是“Translation Table Base Register”的缩写,表示转换表基准寄存器;EL1是“Exception Level 1”的缩写,表示异常级别1)。
寄存器TTBR1_EL1存放内核的页全局目录的物理地址,寄存器TTBR0_EL1存放进程的页全局目录的物理地址。
如果命中了TLB表项,从TLB表项读取访问权限,检查访问权限,如果没有访问权限,生成页错误异常。
如果没有命中TLB表项,内存管理单元将会查询内存中的页表,称为转换表遍历(translation table walk),分两种情况:
(1)如果虚拟地址的高16位全部是1,说明是内核虚拟地址,应该查询内核的页表,从寄存器TTBR1_EL1取内核的页全局目录的物理地址。
(2)如果虚拟地址的高16位全部是0,说明是用户虚拟地址,应该查询进程的页表,从寄存器TTBR0_EL1取进程的页全局目录的物理地址。
内存管理单元访问内存中的页表,根据表项的类型进行处理:
(1)如果是无效描述符,生成页错误异常。
(2)如果是块描述符或页描述符,把表项复制到TLB。
(3)如果是表描述符,从表项读取下一级页表的物理地址,继续访问下一级页表。
2.处理页错误异常
在ARM64架构的系统中,用户程序在异常级别0运行,内核在异常级别1运行,异常级别0是用户模式,异常级别1是内核模式。
ARM64架构的内核定义了一个异常向量表,起始地址是vectors(源文件arch/arm64/ kernel/entry.S),每个异常向量的长度是128字节,但是在Linux内核中每个异常向量只有一条指令:跳转到对应的处理程序。异常向量表的虚拟地址存放在异常级别1的向量基准地址寄存器(Vector Base Address Register for Exception Level 1,VBAR_EL1)中。
如下所示,处理器生成页错误异常,页错误异常属于同步异常,处理器立即处理,从向量基准地址寄存器得到异常向量表的虚拟地址,然后根据异常类型选择对应的异常向量:
(1)如果异常类型是异常级别1生成的同步异常,异常向量的偏移是0x200,这个异常向量跳转到函数el1_sync。
(2)如果异常类型是64位用户程序在异常级别0生成的同步异常,异常向量的偏移是0x400,这个异常向量跳转到函数el0_sync。
(3)如果异常类型是32位用户程序在异常级别0生成的同步异常,异常向量的偏移是0x600,这个异常向量跳转到函数el0_sync_compat。
以函数el0_sync为例说明,函数el0_sync根据异常级别1的异常症状寄存器的异常类别字段处理:
(1)如果异常类别是异常级别0生成的数据中止(data abort),即在异常级别0访问数据时生成页错误异常,那么调用函数el0_da。
(2)如果异常类别是异常级别0生成的指令中止(instruction abort),即在异常级别0取指令时生成页错误异常,那么调用函数el0_ia。
对于ARM64处理器,异常级别1的异常症状寄存器(ESR_EL1,Exception Syndrome Register for Exception Level 1)用来存放异常的症状信息,如下所示:
EC:异常类别(Exception Class),指示引起异常的原因。
ISS:指令特定症状(Instruction Specific Syndrome),每种异常类别独立定义这个字段。
(1)函数do_mem_abort。
页错误异常处理程序最终都会执行到函数do_mem_abort,该函数根据异常症状寄存器的指令特定症状字段的指令错误状态码(第0~5位),调用数组fault_info中的处理函数,指令错误状态码和处理函数的对应关系如下所示:
指令错误状态码 | 说 明 | 处 理 函 数 |
---|---|---|
4 | 0级转换错误(在0级转换表中匹配无效描述符) | do_translation_fault |
5 | 1级转换错误(在1级转换表中匹配无效描述符) | do_translation_fault |
6 | 2级转换错误(在2级转换表中匹配无效描述符) | do_translation_fault |
7 | 3级转换错误(在3级转换表中匹配无效描述符) | do_page_fault |
9 | 1级访问标志错误 | do_page_fault |
10 | 2级访问标志错误 | do_page_fault |
11 | 3级访问标志错误 | do_page_fault |
13 | 1级权限错误 | do_page_fault |
14 | 2级权限错误 | do_page_fault |
15 | 3级权限错误 | do_page_fault |
33 | 对齐错误(虚拟地址没有对齐) | do_alignment_fault |
其他 | 其他错误 | do_bad |
虚拟页没有映射到物理页的情况:如果在0级、1级或2级转换表中匹配的表项是无效描述符,调用函数do_translation_fault来处理;如果在3级转换表中匹配的表项是无效描述符,调用函数do_page_fault来处理。
如果是访问标志错误:“在1级、2级或3级转换表中匹配的表项是块描述符或页描述符,但是没有设置访问标志”,那么调用函数do_page_fault,函数do_page_fault将会为页表项设置访问标志。页回收算法需要根据页表项的访问标志判断物理页是不是刚刚被访问过。
如果是权限错误:“在1级、2级或3级转换表中匹配的表项是块描述符或页描述符,但是没有访问权限”,那么调用函数do_page_fault。
(2)函数do_translation_fault。
函数do_translation_fault处理在0级、1级或2级转换表中匹配的表项是无效描述符的情况,执行流程如下所示:
arch/arm64/mm/fault.c
static int __kprobes do_translation_fault(unsigned long addr,
unsigned int esr,
struct pt_regs *regs)
{
if (addr < TASK_SIZE)
return do_page_fault(addr, esr, regs);
do_bad_area(addr, esr, regs);
return 0;
}
参数addr是触发异常的虚拟地址,esr是异常症状寄存器的值,regs指向内核栈中保存的被打断的进程的寄存器集合。
如果触发异常的虚拟地址是用户虚拟地址,调用函数do_page_fault来处理。如果触发异常的虚拟地址是内核虚拟地址或不规范地址,调用函数do_bad_area来处理。
函数do_bad_area的代码如下:
arch/arm64/mm/fault.c
static void do_bad_area(unsigned long addr, unsigned int esr, struct pt_regs *regs)
{
struct task_struct *tsk = current;
struct mm_struct *mm = tsk->active_mm;
const struct fault_info *inf;
if (user_mode(regs)) {
inf = esr_to_fault_info(esr);
__do_user_fault(tsk, addr, esr, inf->sig, inf->code, regs);
} else
__do_kernel_fault(mm, addr, esr, regs);
}
如果异常是在用户模式下生成的,根据异常症状寄存器的指令特定字段的指令错误状态码在数组fault_info中取出信号,然后调用函数__do_user_fault发送信号以杀死进程。
如果异常是在内核模式下生成的,调用函数__do_kernel_fault来处理。
(3)函数do_page_fault。
函数do_page_fault的执行流程如下所示:
(4)函数__do_page_fault。
函数__do_page_fault的执行流程如下所示:
1.2 用户空间页错误异常
从函数handle_mm_fault开始的部分是所有处理器架构共用的部分,函数handle_mm_fault负责处理用户空间的页错误异常。用户空间页错误异常是指进程访问用户虚拟地址生成的页错误异常,分两种情况:
(1)进程在用户模式下访问用户虚拟地址,生成页错误异常。
(2)进程在内核模式下访问用户虚拟地址,生成页错误异常。
进程通过系统调用进入内核模式,系统调用传入用户空间的缓冲区,进程在内核模式下访问用户空间的缓冲区。
如果页错误异常处理程序确认虚拟地址属于分配给进程的虚拟内存区域,并且虚拟内存区域授予触发页错误异常的访问权限,就会运行到函数handle_mm_fault。
函数handle_mm_fault的执行流程如下所示,其主要代码如下:
mm/memory.c
int handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags)
{
…
if (unlikely(is_vm_hugetlb_page(vma)))
ret = hugetlb_fault(vma->vm_mm, vma, address, flags);
else
ret = __handle_mm_fault(vma, address, flags);
…
}
如果虚拟内存区域使用标准巨型页,那么调用函数hugetlb_fault处理标准巨型页的页错误异常。如果虚拟内存区域使用普通页,那么调用函数__handle_mm_fault处理普通页的页错误异常。
函数handle_pte_fault处理直接页表,执行流程如下所示:
1.匿名页的缺页异常
什么情况会触发匿名页的缺页异常呢?
(1)函数的局部变量比较大,或者函数调用的层次比较深,导致当前栈不够用,需要扩大栈。
(2)进程调用malloc,从堆申请了内存块,只分配了虚拟内存区域,还没有映射到物理页,第一次访问时触发缺页异常。
(3)进程直接调用mmap,创建匿名的内存映射,只分配了虚拟内存区域,还没有映射到物理页,第一次访问时触发缺页异常。
2.文件页的缺页异常
什么情况会触发文件页的缺页异常呢?
(1)启动程序的时候,内核为程序的代码段和数据段创建私有的文件映射,映射到进程的虚拟地址空间,第一次访问的时候触发文件页的缺页异常。
(2)进程使用mmap创建文件映射,把文件的一个区间映射到进程的虚拟地址空间,第一次访问的时候触发文件页的缺页异常。
函数do_fault处理文件页和共享匿名页的缺页异常,执行流程如下所示:
(1)处理读文件页错误。
处理读文件页错误的方法如下。
1)把文件页从存储设备上的文件系统读到文件的页缓存(每个文件有一个缓存,因为以页为单位,所以称为页缓存)中。
2)设置进程的页表项,把虚拟页映射到文件的页缓存中的物理页。
函数do_read_fault处理读文件页错误,执行流程如下所示:
函数__do_fault需要使用虚拟内存区域的虚拟内存操作集合中的fault方法(vm_area_struct.vm_ops->fault)来把文件页读到内存中。
进程调用mmap创建文件映射的时候,文件所属的文件系统会注册虚拟内存区域的虚拟内存操作集合,fault方法负责处理文件页的缺页异常。例如,EXT4文件系统注册的虚拟内存操作集合是ext4_file_vm_ops,fault方法是函数ext4_filemap_fault。许多文件系统注册的fault方法是通用的函数filemap_fault。
给定一个虚拟内存区域vma,函数filemap_fault读文件页的方法如下:
1)根据vma->vm_file得到文件的打开实例file。
2)根据file->f_mapping得到文件的地址空间mapping。
3)使用地址空间操作集合中的readpage方法(mapping->a_ops->readpage)把文件页读到内存中。
函数finish_fault负责设置页表项,把主要工作委托给函数alloc_set_pte,执行流程如下所示:
(2)处理写私有文件页错误。
处理写私有文件页错误的方法如下:
1)把文件页从存储设备上的文件系统读到文件的页缓存中。
2)执行写时复制,为文件的页缓存中的物理页创建一个副本,这个副本是进程的私有匿名页,和文件脱离关系,修改副本不会导致文件变化。
3)设置进程的页表项,把虚拟页映射到副本。
函数do_cow_fault处理写私有文件页错误,执行流程如下所示:
(3)处理写共享文件页错误。
处理写共享文件页错误的方法如下:
1)把文件页从存储设备上的文件系统读到文件的页缓存中。
2)设置进程的页表项,把虚拟页映射到文件的页缓存中的物理页。
函数do_shared_fault处理写共享文件页错误,执行流程如下:
3.写时复制
有两种情况会执行写时复制(Copy on Write,CoW):
(1)进程分叉生成子进程的时候,为了避免复制物理页,子进程和父进程以只读方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页。
(2)进程创建私有的文件映射,然后读访问,触发页错误异常,异常处理程序把文件读到页缓存,然后以只读模式把虚拟页映射到文件的页缓存中的物理页。接着执行写访问,触发页错误异常,异常处理程序执行写时复制,为文件的页缓存中的物理页创建一个副本,把虚拟页映射到副本。这个副本是进程的私有匿名页,和文件脱离关系,修改副本不会导致文件变化。
函数do_wp_page处理写时复制,执行流程如下所示,执行过程如下:
(1)调用函数vm_normal_page,从页表项得到页帧号,然后得到页帧号对应的页描述符。
特殊映射不希望关联页描述符,直接使用页帧号,可能是因为页描述符不存在,也可能是因为不想使用页描述符。特殊映射有两种实现:
1)有些处理器架构在页表项中定义了特殊映射位PTE_SPECIAL。
2)有些处理器架构的页表项没有空闲的位,使用更复杂的实现方案:页帧号(Page Frame Number,PFN)映射,虚拟内存区域设置了标志位VM_PFNMAP,内核提供了函数remap_pfn_range()来把页帧号映射到进程的虚拟页。还有混合映射,虚拟内存区域设置了标志位VM_MIXEDMAP,映射可以包含页描述符或页帧号。
(2)使用页帧号的特殊映射。
1)如果是共享的可写映射,不需要复制物理页,调用函数wp_pfn_shared来设置页表项的写权限位。
2)如果是私有的可写映射,调用函数wp_page_copy以复制物理页,然后把虚拟页映射到新的物理页。
(3)使用页描述符的正常映射。
1)如果是共享的可写映射,不需要复制物理页,调用函数wp_page_shared来设置页表项的写权限位。
2)如果是私有的可写映射,调用函数wp_page_copy以复制物理页,然后把虚拟页映射到新的物理页。
函数wp_page_copy执行写时复制,执行流程如下所示:
1.3 内核模式页错误异常
内核访问内核虚拟地址,正常情况下不会出现虚拟页没有映射到物理页的状况,内核使用线性映射区域的虚拟地址,在内存管理子系统初始化的时候就会把虚拟地址映射到物理地址,运行过程中可能使用vmalloc()函数从vmalloc区域分配虚拟内存区域,vmalloc()函数会分配并且映射到物理页。如果出现虚拟页没有映射到物理页的情况,一定是程序错误,内核将会崩溃。
内核可能访问用户虚拟地址,进程通过系统调用进入内核模式,有些系统调用会传入用户空间的缓冲区,内核必须使用头文件“uaccess.h”定义的专用函数访问用户空间的缓冲区,这些专用函数在异常表中添加了可能触发异常的指令地址和异常修正程序的地址。如果访问用户空间的缓冲区时生成页错误异常,页错误异常处理程序发现用户虚拟地址没有被分配给进程,就在异常表中查找指令地址对应的异常修正程序,如果找到了,使用异常修正程序修正异常,避免内核崩溃。
在内核模式下执行时触发页错误异常,ARM64架构内核的处理流程如下所示,概括起来有3种处理方式:
(1)如果不允许内核执行用户空间的指令,那么进程在内核模式下试图执行用户空间的指令时,内核崩溃。
(2)如果进程在内核模式下访问用户虚拟地址,那么先使用函数__do_page_fault处理,如果处理失败,最后一招是使用函数__do_kernel_fault处理。
(3)其他情况使用函数__do_kernel_fault处理。
1.函数__do_kernel_fault
针对访问数据生成的异常,函数__do_kernel_fault尝试在异常表中查找异常修正程序。
如果找到异常修正程序,把保存在内核栈中的异常链接寄存器(ELR_EL1,Exception Link Register for Exception Level 1)的值修改为异常修正程序的虚拟地址。当异常处理程序返回的时候,处理器把程序计数器设置成异常链接寄存器的值,执行异常修正程序。
如果没有找到异常修正程序,内核只能崩溃。
函数fixup_exception根据指令地址在异常表中查找,然后把保存在内核栈中的异常链接寄存器的值修改为异常修正程序的虚拟地址,其代码如下:
arch/arm64/mm/extable.c
int fixup_exception(struct pt_regs *regs)
{
const struct exception_table_entry *fixup;
fixup = search_exception_tables(instruction_pointer(regs));
if (fixup)
regs->pc = (unsigned long)&fixup->fixup + fixup->fixup;
return fixup != NULL;
}
根据触发异常的指令的虚拟地址在异常表中查找,如果找到表项,那么把保存在内核栈中的异常链接寄存器的值修改为异常修正程序的虚拟地址。当异常处理程序返回的时候,处理器把程序计数器设置成异常链接寄存器的值,执行异常修正程序。
异常表项中存储的指令地址是相对地址:fixup->insn =(指令的虚拟地址 − &fixup->insn)。
异常表项中存储的异常修正程序的地址是相对地址:fixup->fixup =(异常修正程序的虚拟地址 − &fixup->fixup)。
regs->pc是保存在内核栈中的异常链接寄存器的值。生成页错误异常的时候,处理器会把触发异常的指令的地址保存在异常链接寄存器中。
2.异常表
进程在内核模式下运行的时候,可能需要访问用户虚拟地址,应用程序通常是不可信任的,不能保证传入的用户虚拟地址是合法的,所以必须采取措施保护内核。当前采用的措施是使用异常表,每条表项有两个字段:
(1)可能触发异常的指令的虚拟地址。
(2)异常修正程序的起始虚拟地址。
异常表项的定义如下:
arch/arm64/include/asm/extable.h
struct exception_table_entry
{
int insn, fixup;
};
异常表通常不是直接保存虚拟地址,而是保存相对地址。假设可能触发异常的指令的虚拟地址是p1,异常修正程序的起始虚拟地址是p2,对应的异常表项的字段insn的虚拟地址是p3,字段fixup的虚拟地址是p4,那么异常表项的字段insn的值是(p1 − p3),字段fixup的值是(p2 − p4)。
内核有一张异常表,全局变量__start___ex_table存放异常表的起始地址,__stop___ex_table存放异常表的结束地址。每个内核模块可以有自己的异常表。
进程在内核模式下访问用户虚拟地址的时候,只允许使用头文件“uaccess.h”声明的函数,以函数get_user为例说明,函数get_user从用户空间读取C语言标准类型的数据,ARM64架构实现的代码如下:
arch/arm64/include/asm/uaccess.h
#define get_user(x, ptr) \
({ \
__typeof__(*(ptr)) __user *__p = (ptr); \
might_fault(); \
access_ok(VERIFY_READ, __p, sizeof(*__p)) ? \
__get_user((x), __p) : \
((x) = 0, -EFAULT); \
})
从用户虚拟地址ptr读取数值并存放到局部变量x中。
“access_ok(VERIFY_READ, __p, sizeof(*__p))”检查(用户虚拟地址 + 长度)是否小于进程的虚拟地址空间的上界(current->addr_limit),如果小于,用户虚拟地址是合法的,返回1,否则返回0。
如果用户虚拟地址是合法的,那么使用“__get_user((x), __p)”从用户空间读取数值。
如果用户虚拟地址不是合法的,那么把x设置成0,返回“-EFAULT”。
再看看链接脚本:
arch/arm64/kernel/vmlinux.lds.S
…
. = ALIGN(SEGMENT_ALIGN);
_etext = .; /* 代码段的结束地址 */
RO_DATA(PAGE_SIZE) /* 从这里到 */
EXCEPTION_TABLE(8) /* __init_begin将被标记为只读和不可执行 */
NOTES
…
宏EXCEPTION_TABLE的定义如下:
include/asm-generic/vmlinux.lds.h
/*
* 异常表
*/
#define EXCEPTION_TABLE(align) \
. = ALIGN(align); \
__ex_table : AT(ADDR(__ex_table) - LOAD_OFFSET) { \
VMLINUX_SYMBOL(__start___ex_table) = .; \
KEEP(*(__ex_table)) \
VMLINUX_SYMBOL(__stop___ex_table) = .; \
}
内核的全局变量__start___ex_table存放异常表节(__ex_table)的起始地址,__stop___ex_table存放异常表节的结束地址。