在实际需要某个虚拟内存区域的数据之前,虚拟和物理内存之间的关联不会建立。如果进程访问的虚拟地址空间尚未与页帧关联,处理器自动地发一个缺页异常,内核必须处理此异常。这是内存管理中最重要、最复杂的方面之一,因为必须要考虑无数的细节
- 缺页异常时由于访问用户地址空间中的有效地址而引起的,还是应用程序试图访问内核的受保护区域?
- 目标地址对应于某个现存的映射吗?
- 获取该区域的地址,需要使用何种机制?
缺页异常处理的实现因处理器的不同而有所不同,本文针对ARM32的处理流程进行学习,学习处理流程。
1. 缺页异常基本原理
进程A通过CPU访问虚拟地址VA,通过MMU找到对应的物理地址,当内存页在物理内存中没有对应的页帧或者存在但无对应的访问权限,在这种情况下,CPU就会报告一个缺页的错误。
Page Fault,指的是硬件错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等,当软件试图访问已映射在虚拟地址空间中,但目前并未加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。
缺页错误的分类:
- 硬件缺页(Hard Page Fault): 此时物理内存中没有对应的页帧,需要CPU打开磁盘设备读取到物理内存中,再让MMU建立VA和PA的映射
- 软缺页(Soft Page Fault): 此时物理内存中存在对应的页帧,只不过可能是其他进程调入,发生缺页异常的进程不知道,此时MMU只需要重新建立映射即可,无需从磁盘写入内存,一般出现在多进程共享内存区域
- 无效缺页(Invalid Page Falut): 比如进程访问的内存地址越界访问,空指针引用就会报段错误等
常见的场景:
-
地址空间映射关系未建立 :
- 内核提供了很多申请内存的接口函数malloc/mmap,申请的虚拟地址空间,但是并未分配实际的物理页面,当首次访问的时候将会触发缺页异常
- 用户态的经常要进行地址访问,在进程刚创建运行时,页会伴随着大量的缺页异常,例如文件页(代码段/数据段)映射到进程地址空间,首次访问会产生缺页异常
-
地址空间映射已建立 :
- 当访问的页面已经被swapping到磁盘,访问时触发缺页异常
- fork子进程时,子进程共享父进程的地址空间,写是触发缺页异常(COW技术)
- 要访问的页面被KSM合并,写时触发缺页异常(COW技术)
-
访问的地址空间不合法:
- 用户空间访问内核空间地址,触发缺页异常
- 内核空间访问用户空间地址,触发缺页异常
2. ARM缺页硬件支持
缺页处理的实现因处理器的不同而有所不同,本文以ARMV7的体系结构为例学习,大多数其他的CPU实现基本类似。
2.1 ARM 异常模式
首先ARM有好几种异常模式,其中一种是Data Abort。ARM v7 manual对这种异常的解释如下
发生在memory access阶段,在读/写数据、取指令或者访问页表时均有可能发生,对于红色的部分页就是对于虚拟内存管理的缺页支持。
2.2 ARM 页表结构
ARMV7的MMU支持两种类型的页表,短描述符和长描述符,长描述符是针对大物理地址的扩展,实际用的比较少,一般都是使用短描述符,它在转换中使用32Bit的描述符条目,并提供
-
最多提供两级的地址查找
-
32Bit的输入地址
-
输出地址最多支持40Bits
-
主要是支持大物理地址扩展,可支持16MB
-
支持无访问权限等
-
32bit的页表条目
2.3 CP15寄存器
在看当MMU发生异常时候CP15寄存器的变化如下图
-
DFSR保存有关数据中止异常的信息
-
DFAR保留一些同步数据中止异常的故障地址
-
IFSR保留有关“预取中止”异常的信息
-
IFAR保留“预取中止”异常的错误地址
这里提到保存信息的寄存有两个分别是DFSR和DFAR,DFAR中存放的是发生异常的VA,DFSR则存放的是发生异常的类型。
这里能看到有bit[10]、bit[3:0] 5个bit来表示FS(FAULT STATUS)。bit[11]来指明发生exception时是因为读指令还是写指令导致的。然后再看下FS支持哪些状态。
其对应到软件的fsr_info表,后面将会详细的讲解。
3. 缺页异常软件处理流程
当进行存储访问时发生异常,基于ARMv7架构的处理器会跳转到异常向量表Data abort向量中,对于ARM处理器而言,当发生异常的时候,处理器会暂停当前指令的执行,保存现场,转而去执行对应的异常向量处的指令,当处理完该异常的时候,恢复现场,回到原来的那点去继续执行程序。系统所有的异常向量(共计8个)组成了异常向量表,该内容后面中断章节中在学习。异常发生时,ARM会自动跳转到异常向量表中,通过向量表中的跳转命令跳转到相应的异常处理中去。ARM的异常处理向量表在entry-armv.S文件中:
.L__vectors_start:
W(b) vector_rst
W(b) vector_und
W(ldr) pc, .L__vectors_start + 0x1000
W(b) vector_pabt
W(b) vector_dabt
W(b) vector_addrexcptn
W(b) vector_irq
W(b) vector_fiq
对于data abort,对应的跳转地址是vector_dabt + stubs_offset。这个地址的指令定义也在entry-armv.S
/*
* Data abort dispatcher
* Enter in ABT mode, spsr = USR CPSR, lr = USR PC
*/
vector_stub dabt, ABT_MODE, 8
.long __dabt_usr @ 0 (USR_26 / USR_32)
.long __dabt_invalid @ 1 (FIQ_26 / FIQ_32)
.long __dabt_invalid @ 2 (IRQ_26 / IRQ_32)
.long __dabt_svc @ 3 (SVC_26 / SVC_32)
.long __dabt_invalid @ 4
.long __dabt_invalid @ 5
.long __dabt_invalid @ 6
.long __dabt_invalid @ 7
.long __dabt_invalid @ 8
.long __dabt_invalid @ 9
.long __dabt_invalid @ a
.long __dabt_invalid @ b
.long __dabt_invalid @ c
.long __dabt_invalid @ d
.long __dabt_invalid @ e
.long __dabt_invalid @ f
-
如果进入data abort之前处于usr模式,那么跳转到dabt_usr
-
如果处于svc模式,那么跳转到dabt_svc;否则跳转到__dabt_invalid。
实际上,进入异常向量前Linux只能处于usr或者svc两种模式之一。这时因为irq等异常在跳转表中都要经过vector_stub宏,而不管之前是哪种状态,这个宏都会将CPU状态改为svc模式。
下面我们就从v7_early_abort开始
ENTRY(v7_early_abort)
mrc p15, 0, r1, c5, c0, 0 @ get FSR
mrc p15, 0, r0, c6, c0, 0 @ get FAR
uaccess_disable ip @ disable userspace access
b do_DataAbort
ENDPROC(v7_early_abort)
ARM的MMU中有两个与存储访问失效有关的寄存器,前面已经学习过
-
失效状态寄存器(Data Fault Status Register,FSR)
-
失效地址寄存器(Data Fault Address Register, FAR)
当发生存储访问失效时,失效状态寄存器FSR会反映所发生存储失效的相关信息,包括存储访问所属域和存储访问类型等,同时失效地址寄存器会记录访问失效的虚拟地址。从v7_early_abort可知,addr是从CP15的c6获取,fsr是从CP15的c5获取;regs在__dabt_usr/__dabt_svc中从sp获取。
do_DataAbort(unsigned long addr, unsigned int fsr, struct pt_regs *regs)
{
const struct fsr_info *inf = fsr_info + fsr_fs(fsr); -------------------(1)
struct siginfo info;
if (!inf->fn(addr, fsr & ~FSR_LNX_PF, regs)) --------------------(2)
return;
pr_alert("Unhandled fault: %s (0x%03x) at 0x%08lx\n", --------------------(3)
inf->name, fsr, addr);
show_pte(current->mm, addr); --------------------(4)
info.si_signo = inf->sig;
info.si_errno = 0;
info.si_code = inf->code;
info.si_addr = (void __user *)addr;
arm_notify_die("", regs, &info, fsr, 0); --------------------(5)
}
首先struct fsr\_info数据结构用于描述一条失效状态对应的处理方案
struct fsr_info {
int (*fn)(unsigned long addr, unsigned int fsr, struct pt_regs *regs);
int sig;
int code;
const char *name;
};
其中,name成员表示这条失效状态的名称,sig表示处理失败时,Linux内核要发送的信号类型,fn表示要修复这条失效状态的函数指针。
下面我们来看看fsr handler,其主要有以下有四种:
- 1.一级页表translation出错,段转换错误,即找不到二级页表,调用do_translation_fault
- 2.一级页表permisson出错,段权限错误,即二级页表权限错误,调用do_sect_fault
- 3.二级页表translation出错,页表错误,即线性地址无效,没有对应的物理地址,调用do_page_fault
- 4.二级页表permisson出错,页权限错误,调用do_page_fault
- 5.内核不支持的fault, 调用do_bad
3.1 内核不支持的fault
fsr_info数组列出了常见的地址失效处理方案,例如do_page_fault处理缺页中断,do_translation_fault处理转换错误,其他不能处理的默认为 do_bad :
static int do_bad(unsigned long addr, unsigned int esr, struct pt_regs *regs)
{
return 1;
}
do_bad 不做任何处理,返回1后再调用 arm_notify_die 后处理:
void arm_notify_die(const char *str, struct pt_regs *regs,
struct siginfo *info, unsigned long err, unsigned long trap)
{
if (user_mode(regs)) {
current->thread.error_code = err;
current->thread.trap_no = trap;
force_sig_info(info->si_signo, info, current);
} else {
die(str, regs, err);
}
}
arm_notify_die函数判断当前处于Kernel模式还是User模式,
- 如果是Kernel模式直接die,打印内核死机信息,内核最终会挂死
- 如果是User模式,调用 force_sig_info 向进程强制发送 fsr_info 表中对应信号,强制发送信号可以忽略信号处理的SIG_IGN标记,和stask_struct的blocked域。进程收到信号后,接着进行coredump等流程。
3.2 内核支持的fault
对于这类都是由SIGSEGV导致的, SIGSEGV(Segment fault)意味着指针所对应的地址是无效地址,没有物理内存对应该地址,其中有分为以下两种
- 访问了具有错误权限的页面。例如,它是只读的,但是您的代码试图写入它。这将报告为 SEGV_ACCERR
- 访问的页面甚至根本没有映射到应用程序的地址空间。这通常是由于取消引用空指针或使用小整数值损坏的指针引起的。报告为 SEGV_MAPERR
3.2.1 段权限错误 do_sect_fault
do_sect_fault函数直接调用do_bad_area作处理,并返回0,所以不会再经过arm_notify_die。do_bad_area中,判断是否属于用户模式。如果是用户模式,调用__do_user_fault函数;否则调用__do_kernel_fault函数。
void do_bad_area(unsigned long addr, unsigned int fsr, struct pt_regs *regs)
{
struct task_struct *tsk = current;
struct mm_struct *mm = tsk->active_mm;
/*
* If we are in kernel mode at this point, we
* have no context to handle this fault with.
*/
if (user_mode(regs))
__do_user_fault(tsk, addr, fsr, SIGSEGV, SEGV_MAPERR, regs);
else
__do_kernel_fault(mm, addr, fsr, regs);
}
- __do_user_fault中,会发送信号给当前线程,对于arm32位,可以通过CONFIG_DEBUG_USER将用户空间的栈打印出来,用于分析和debug
- __do_kernel_fault则比较复杂,其流程最终会调用die函数处理oops
3.2.2 段表错误
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;
}
do_translation_fault函数中,会首先判断引起abort的地址是否处于用户空间。
- 如果是用户空间地址,调用do_page_fault,转入和页表错误、页权限错误同样的处理流程。
- 如果是内核空间,调用do_bad_area作处理
3.2.3 页表错误 /页权限错误
对于页表错误(即线性地址无效,没有对应的物理地址)和页权限错误,都会调用到缺页异常的核心函数do_page_fault,在学习这章之前,我们先学习几个概念
对于处理器,总是处于以下状态
- 内核态,运行于进程上下文,内核代表进程运行在内核空间
- 内核态,运行在中断上下文,内核代表硬件运行在内核空间
- 用户态,运行在用户空间
进程上下文、中断上下文、原子上下文区别
| 状态|概念|发生条件|做了什么| | :-----: | :-----: | :-----: | :-----: | | 进程上下文 | 进程在执行的时候,CPU的所有寄存器中的值,进程的状态以及堆上的内容,当内核需要切换到另外一个进程时,需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该程序的时候,继续运行。 | 当进程调度时,进行进程切换 | 内核之所以要进入进程上下文,是因为进程的某些工作需要在内核中完成 | | 中断上下文 | 硬件通过触发信号,向CPU发送中断信号,导致内核调用中断处理程序,进入内核空间 | 中断信号而导致的中断处理或软中断 | 中断信号而导致的中断处理或软中断 | | 原子上下文 | 在中断或者说原子上下文中,内核不能访问用户空间,而且内核是不能睡眠的 | 原子上下文指的是在中断或软中断中,以及在持有自旋锁的时候 | |
核心函数do_page_fault,其处理流程如上图所示,简述其处理过程:
-
in_atomic判断当前状态是否处于中断上下文或者禁止抢占状态,如果是,说明运行在原子上下文中,那么就跳转到内核处理异常接口__do_kernel_fault;同时如果当前进程没有struct mm_struct结构,说明这是一个内核线程,同样进入到__do_kernel_fault中
-
如果是用户模式,那么flags置位FAULT_FLAG_USER
-
down_read_trylock判断当前的进程的mm->mmap_sem读写信号量释放可以获取,返回1是表示成功获取,返回0则表示被人占用,被人占用时分为两种情况
- 一种发生在内核空间,如果没有在exception_tables中查询到该地址,就跳转到do_kernel_fault
- 一种发生在用户空间,可以调用down_read来睡眠等待持有该锁的所有者释放该锁
-
通过__do_page_fault来完成查找合适的vma,并分配,并返回分配后的状态
-
如果没有返回错误类型,说明缺页中断处理完成,其他异常分为
- 如果返回错误,且当前处于内核模式,那么就跳转到__do_kernel_fault
- 如果错误类型是VM_FAULT_OOM,说明当前系统中没有足够的内存,那么就调用pagefault_out_of_memory函数来触发OOM机制
- 如果是VM_FAULT_SIGBUS,就调用__do_user_fault向用户空间发送SIGBUS信号,杀死进程,其他的错误则发送SIGSEGV的段错误,杀死进程
-
如果错误发生在内核模式,如果内核无法处理,那么只能调用__do_kernel_fault来处理
4 总结
本章主要是从发生缺页异常开始,软硬件的总体流程,对于重要的内存缺页处理过程暂时未讲解,后面章节中主要学习