内存管理6_缺页异常

1、介绍
什么是缺页异常?
当进程尝试访问当前不在其工作集内存中的页面时,就会发生缺页异常。这种事件触发缺页异常中断,导致内核采取特定行动来处理这种情况。
缺页异常的类型
- 次要缺页异常(Minor Page Faults): 当页面不在进程的当前工作集中,但仍然驻留在内存的某个地方时发生。
- 主要缺页异常(Major Page Faults): 当需要从磁盘(如交换区或内存映射文件)获取页面时发生。
下边我们会以x86平台为例,分析一下缺页异常的整个流程。
2、CPU硬件
2.1-产生一个缺页
当进程准备访问内存时,会首先生成一个虚拟地址,然后MMU
会通过这个进程的页表
来查找虚拟地址对应的物理地址。如果虚拟地址对应的页表项(PTE)标记为不在物理内存中(例如,PTE 中的存在(Present)位被设置为0),则表示页面不在内存中,这时硬件检测到缺页情况,此时就会触发CPU的缺页异常。
2.2-缺页异常中断
而缺页异编号为 #PF
,即 Page Fault,会触发一个中断,中断向量为14
,其被定义在了arch\x86\include\asm\trapnr.h
#define X86_TRAP_PF 14 /* Page Fault */
内核通过中断向量表来查找对应中断的处理函数,也就是缺页异常处理函数。
arch\x86\include\asm\idtentry.h
DECLARE_IDTENTRY_RAW_ERRORCODE(X86_TRAP_PF, exc_page_fault);
#define DECLARE_IDTENTRY_ERRORCODE(vector, func) \
idtentry vector asm_##func func has_error_code=1
并且系统会把异常访问的地址存入CR2寄存器之中,后续的处理函数会利用这个地址进行相应的处理流程。
2.3-缺页异常码
另外触发缺页异常的时候还会标记当前的错误码error_code
,用来方便后续错误处理。
/*
* Page fault error code bits:
*
* bit 0 == 0: no page found 1: protection fault
* bit 1 == 0: read access 1: write access
* bit 2 == 0: kernel-mode access 1: user-mode access
* bit 3 == 1: use of reserved bit detected
* bit 4 == 1: fault was an instruction fetch
* bit 5 == 1: protection keys block access
* bit 6 == 1: shadow stack access fault
* bit 15 == 1: SGX MMU page-fault
*/
enum x86_pf_error_code {
X86_PF_PROT = 1 << 0,
X86_PF_WRITE = 1 << 1,
X86_PF_USER = 1 << 2,
X86_PF_RSVD = 1 << 3,
X86_PF_INSTR = 1 << 4,
X86_PF_PK = 1 << 5,
X86_PF_SHSTK = 1 << 6,
X86_PF_SGX = 1 << 15,
};
发生缺页异常的时候,CPU会自动将error_code
直接压入当前的堆栈。(这个流程存疑,到底是怎么存入的,组内的大佬说ARM64上是由硬件完成自动存入指定的寄存器)
在异常处理函数中从堆栈里直接读取该error_code
值,相关的函数如下
(arch/x86/entry/entry_64.S
)
/**
* idtentry_body - Macro to emit code calling the C function
* @cfunc: C function to be called
* @has_error_code: Hardware pushed error code on stack
*/
.macro idtentry_body cfunc has_error_code:req
......
movq %rsp, %rdi /* pt_regs pointer into 1st argument*/
.if \has_error_code == 1
movq ORIG_RAX(%rsp), %rsi /* 读当前error_code值*/
movq $-1, ORIG_RAX(%rsp) /* no syscall to restart */
.endif
......
.endm
3、缺页异常处理函数
exc_page_fault
->handle_page_fault
->do_kern_addr_fault
3.1-exc_page_fault
缺页异常处理的入口函数
DEFINE_IDTENTRY_RAW_ERRORCODE(exc_page_fault)
{
unsigned long address = read_cr2(); // 读出cr2的值,获取触发缺页异常的地址
irqentry_state_t state;
prefetchw(¤t->mm->mmap_lock);
if (kvm_handle_async_pf(regs, (u32)address)) // 如果是KVM触发的,执行不同的逻辑,这里先忽略
return;
state = irqentry_enter(regs); // 开始进入中断处理,保存寄存器的状态
instrumentation_begin(); // 启动代码区块的仪器化过程,主要用以trace分析,标记调用栈
handle_page_fault(regs, error_code, address); // 进一步执行缺页异常处理
instrumentation_end();
irqentry_exit(regs, state); // 退出中断
}
接下来的handle_page_fault
中会区分是内核触发的缺页异常还是用户,并分别调用对应的逻辑。
3.2-内核态缺页异常
内核态的缺页异常逻辑相对用户态较少,基本上是需要触发kpanic的严重错误,也就是bad_area
的错误。
static void
do_kern_addr_fault(struct pt_regs *regs, unsigned long hw_error_code,
unsigned long address)
{
......
if (is_f00f_bug(regs, hw_error_code, address)) // 处理intel的f00f错误
return;
/* 检查是否假缺页异常,即由懒惰的 TLB无效化引起 */
if (spurious_kernel_fault(hw_error_code, address))
return;
/* kprobes don't want to hook the spurious faults: */
if (WARN_ON_ONCE(kprobe_page_fault(regs, X86_TRAP_PF))) // 是否Kprobes钩子需要在这个缺页异常上触发
return;
bad_area_nosemaphore(regs, hw_error_code, address); // 通用处理的内核态缺页异常
}
最终通过__bad_area_nosemaphore
处理严重的缺页错误
static void
__bad_area_nosemaphore(struct pt_regs *regs, unsigned long error_code,
unsigned long address, u32 pkey, int si_code)
{
struct task_struct *tsk = current;
if (!user_mode(regs)) { // 内核态报错
kernelmode_fixup_or_oops(regs, error_code, address,
SIGSEGV, si_code, pkey); // 尝试修复,不然的会就触发内核的oops
return;
}
......
}
可以修复或者忽略的内核态缺页异常包括以下:
- 非致命的内核异常:
- 内核在执行某些操作时可能会遇到预期内的异常,比如某些硬件访问操作、读写错误的MSR。这些异常虽然需要处理,但不一定需要导致系统崩溃。
- 可恢复的错误:
- 某些错误情况下,内核有可能通过简单的操作来恢复正常状态,比如重新映射一个地址、调整内存分配等。
- 特定的内核操作:
- 比如在内存访问中,如果有可恢复的页表错误,内核可能会尝试修复,而不是直接导致崩溃。
具体的包含了下列类型
case EX_TYPE_DEFAULT:
case EX_TYPE_DEFAULT_MCE_SAFE:
return ex_handler_default(e, regs);
case EX_TYPE_FAULT:
case EX_TYPE_FAULT_MCE_SAFE:
return ex_handler_fault(e, regs, trapnr);
case EX_TYPE_UACCESS:
return ex_handler_uaccess(e, regs, trapnr, fault_addr);
case EX_TYPE_COPY:
return ex_handler_copy(e, regs, trapnr);
case EX_TYPE_CLEAR_FS:
return ex_handler_clear_fs(e, regs);
case EX_TYPE_FPU_RESTORE:
return ex_handler_fprestore(e, regs);
case EX_TYPE_BPF:
return ex_handler_bpf(e, regs);
case EX_TYPE_WRMSR:
return ex_handler_msr(e, regs, true, false, reg);
case EX_TYPE_RDMSR:
return ex_handler_msr(e, regs, false, false, reg);
case EX_TYPE_WRMSR_SAFE:
return ex_handler_msr(e, regs, true, true, reg);
case EX_TYPE_RDMSR_SAFE:
return ex_handler_msr(e, regs, false, true, reg);
case EX_TYPE_WRMSR_IN_MCE:
ex_handler_msr_mce(regs, true);
break;
case EX_TYPE_RDMSR_IN_MCE:
ex_handler_msr_mce(regs, false);
break;
case EX_TYPE_POP_REG:
regs->sp += sizeof(long);
fallthrough;
case EX_TYPE_IMM_REG:
return ex_handler_imm_reg(e, regs, reg, imm);
case EX_TYPE_FAULT_SGX:
return ex_handler_sgx(e, regs, trapnr);
case EX_TYPE_UCOPY_LEN:
return ex_handler_ucopy_len(e, regs, trapnr, fault_addr, reg, imm);
case EX_TYPE_ZEROPAD:
return ex_handler_zeropad(e, regs, fault_addr);
如果不在上列内容中,则会最终调用page_fault_oops
触发内核的oops。另外,oops也会根据状态判断是否进一步触发panic
void oops_end(unsigned long flags, struct pt_regs *regs, int signr)
{
......
if (!signr)
return;
if (in_interrupt()) // 中断上下文必须要触发panic
panic("Fatal exception in interrupt");
if (panic_on_oops) // 开启oops触发panic也必须panic
panic("Fatal exception");
......
}
3.3-用户态缺页异常
用户态的缺页异常的处理相比内核态的会温和很多,主要存在当第一次访问没有分配物理内存的虚拟地址情况,所以优先会处理非严重的问题。
主要的工作就是根据error_code
判断错误类型,执行对应的错误处理。
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
......
// 在用户空间尝试执行内核模式代码指令的话直接oops
if (unlikely((error_code & (X86_PF_USER | X86_PF_INSTR)) == X86_PF_INSTR)) {
......
page_fault_oops(regs, error_code, address);
return;
}
......
if (unlikely(error_code & X86_PF_RSVD)) // 由于页表项中的保留位设置不正确引起的异常
pgtable_bad(regs, error_code, address);
...... // 省略一些特定的error code处理
if (!(flags & FAULT_FLAG_USER))
goto lock_mmap;
vma = lock_vma_under_rcu(mm, address); // 查找与引起缺页异常的物理地址对应的虚拟地址VMA
if (!vma)
goto lock_mmap;
if (unlikely(access_error(error_code, vma))) {// 检查VMA的访问权限(例如只读、可写等)
vma_end_read(vma);
goto lock_mmap;
}
fault = handle_mm_fault(vma, address, flags | FAULT_FLAG_VMA_LOCK, regs); // 根据vma和物理地址尝试处理错误,并通过flag来标记错误处理的结果
......
count_vm_vma_lock_event(VMA_LOCK_RETRY);
/* Quick path to respond to signals */
if (fault_signal_pending(fault, regs)) {
if (!user_mode(regs))
kernelmode_fixup_or_oops(regs, error_code, address,
SIGBUS, BUS_ADRERR,
ARCH_DEFAULT_PKEY);
return;
}
lock_mmap:
retry: // 在这里循环处理用户态缺页异常错误
vma = lock_mm_and_find_vma(mm, address, regs); // 查找与引起缺页异常的物理地址对应的虚拟地址VMA
if (unlikely(!vma)) {
bad_area_nosemaphore(regs, error_code, address);
return;
}
if (unlikely(access_error(error_code, vma))) { // 检查VMA的访问权限(例如只读、可写等)
bad_area_access_error(regs, error_code, address, vma); // 如果权限有问题就执行严重的oops逻辑
return;
}
fault = handle_mm_fault(vma, address, flags, regs); // 根据vma和物理地址尝试处理错误,并通过flag来标记错误处理的结果
......
/* The fault is fully completed (including releasing mmap lock) */
if (fault & VM_FAULT_COMPLETED)
return;
......
done: // 根据处理后的flag执行对应的收尾逻辑
if (likely(!(fault & VM_FAULT_ERROR)))
return;
if (fatal_signal_pending(current) && !user_mode(regs)) {
kernelmode_fixup_or_oops(regs, error_code, address,
0, 0, ARCH_DEFAULT_PKEY);
return;
}
if (fault & VM_FAULT_OOM) { // 处理因为没有内存需要oom导致的缺页异常错误
......
pagefault_out_of_memory(); // 尝试执行oom
} else {
if (fault & (VM_FAULT_SIGBUS|VM_FAULT_HWPOISON|
VM_FAULT_HWPOISON_LARGE)) // 非法内存访问(例如访问了未映射的物理内存)、硬件损坏引起的内存错误(例如ECC错误)或大页硬件损坏引起的
do_sigbus(regs, error_code, address, fault);
else if (fault & VM_FAULT_SIGSEGV) // 无效的内存访问引起的,例如访问了不属于进程地址空间的内存
bad_area_nosemaphore(regs, error_code, address);
else
BUG();
}
}
handle_mm_fault
会进一步处理缺页异常
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags, struct pt_regs *regs)
{
......
if (!arch_vma_access_permitted(vma, flags & FAULT_FLAG_WRITE, // 检查VMA地址的访问权限(读/写、指令获取等)
flags & FAULT_FLAG_INSTRUCTION,
flags & FAULT_FLAG_REMOTE)) {
ret = VM_FAULT_SIGSEGV;
goto out;
}
/*
* Enable the memcg OOM handling for faults triggered in user
* space. Kernel faults are handled more gracefully.
*/
if (flags & FAULT_FLAG_USER)
mem_cgroup_enter_user_fault();
lru_gen_enter_fault(vma); // 通知LRU,当前地址进入缺页异常处理。
if (unlikely(is_vm_hugetlb_page(vma)))
ret = hugetlb_fault(vma->vm_mm, vma, address, flags); // 处理大页的缺页异常
else
ret = __handle_mm_fault(vma, address, flags);// 处理通用的缺页异常
......zzzz
return ret;
}
我们主要关注一下通用的错误处理缺页异常,进入到这一步的时候,主要就是用户态进程访问了一个没分配实际物理地址的虚拟地址,这里也就意味着不是严重的缺页异常错误,只需要分配一个合理的地址即可,因此__handle_mm_fault
函数的主要通将虚拟地址映射到物理内存。
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
......
pgd = pgd_offset(mm, address);
p4d = p4d_alloc(mm, pgd, address); // 分配p4d
if (!p4d)
return VM_FAULT_OOM;
vmf.pud = pud_alloc(mm, p4d, address); // 分配pud(Page Upper Directory)并检查是否成功。
if (!vmf.pud)
return VM_FAULT_OOM;
retry_pud:
if (pud_none(*vmf.pud) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) { // 判断当前的虚拟地址是否需要一个大型PUD
ret = create_huge_pud(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}
......
vmf.pmd = pmd_alloc(mm, vmf.pud, address); // 分配pmd(Page Middle Directory)并检查是否成功
if (!vmf.pmd)
return VM_FAULT_OOM;
......
if (pmd_none(*vmf.pmd) &&
hugepage_vma_check(vma, vm_flags, false, true, true)) { // 判断当前的虚拟地址是否需要一个大型PMD
ret = create_huge_pmd(&vmf);
if (!(ret & VM_FAULT_FALLBACK))
return ret;
}
......
return handle_pte_fault(&vmf); // 最后一步是处理页表项,建立或者更新页表项的映射。
}
handle_pte_fault
主要是处理页表项(PTE),包括建立或更新虚拟地址到物理地址的映射,处理交换页、NUMA页等特殊情况,以及维护内存系统的一致性。
static vm_fault_t handle_pte_fault(struct vm_fault *vmf)
{
pte_t entry;
// 步骤1:检查对应的PMD(Page Middle Directory)是否为空
if (unlikely(pmd_none(*vmf->pmd))) {
vmf->pte = NULL;
vmf->flags &= ~FAULT_FLAG_ORIG_PTE_VALID;
} else {
// 步骤2:获取PMD对应的PTE
vmf->pte = pte_offset_map_nolock(vmf->vma->vm_mm, vmf->pmd,
vmf->address, &vmf->ptl);
if (unlikely(!vmf->pte))
return 0;
vmf->orig_pte = ptep_get_lockless(vmf->pte);
vmf->flags |= FAULT_FLAG_ORIG_PTE_VALID;
// 检查PTE是否为空
if (pte_none(vmf->orig_pte)) {
pte_unmap(vmf->pte);
vmf->pte = NULL;
}
}
// 步骤3:处理缺失的页表项
if (!vmf->pte)
return do_pte_missing(vmf); // 根据页表类型调用相应分配逻辑:do_anonymous_page和do_fault
// 步骤4:处理交换页
if (!pte_present(vmf->orig_pte))
return do_swap_page(vmf);
// 步骤5:处理NUMA页
if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
return do_numa_page(vmf);
spin_lock(vmf->ptl);
entry = vmf->orig_pte;
......
entry = pte_mkyoung(entry); // 将pte设置为_PAGE_ACCESSED,也就是可以访问的状态,同也等价于标记了young标志,避免被swap
// 步骤6:更新MMU缓存
if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,
vmf->flags & FAULT_FLAG_WRITE)) { // 更新页表项的访问标志
update_mmu_cache_range(vmf, vmf->vma, vmf->address,
vmf->pte, 1); // 刷新MMU的cache也就是TLB
}
......
unlock:
// 步骤7:解锁PTE并返回0表示成功
pte_unmap_unlock(vmf->pte, vmf->ptl);
return 0;
}
3.3.1-do_anonymous_page
在我们malloc时候,通常会通过do_anonymous_page
给匿名页分配物理地址,主要用于存储进程的堆、栈和其他动态分配的内存。
static vm_fault_t do_anonymous_page(struct vm_fault *vmf)
{
.....
if (pte_alloc(vma->vm_mm, vmf->pmd)) // 为pte分配一个内存
return VM_FAULT_OOM;
......
/* Allocate our own private page. */
if (unlikely(anon_vma_prepare(vma)))
goto oom;
folio = vma_alloc_zeroed_movable_folio(vma, vmf->address); // 分配一个新的0页
if (!folio)
goto oom;
......
__folio_mark_uptodate(folio); // 将新分配的folio标记为已更新。这意味着folio已经准备好被访问。
entry = mk_pte(&folio->page, vma->vm_page_prot); // 函数为新分配的folio创建一个新的页表项(PTE)
entry = pte_sw_mkyoung(entry); // 使用pte_sw_mkyoung()函数将PTE标记为年轻(已访问)
if (vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry), vma); // 函数将PTE标记为可写和脏,表示该在写入后需要将其写回到磁盘
......
// 检查地址空间是否稳定,避免例如其他线程可能在此期间对地址空间进行修改,导致在设置PTE时使用无效或过时信息
ret = check_stable_address_space(vma->vm_mm);
if (ret)
goto release;
/* Deliver the page fault to userland, check inside PT lock */
if (userfaultfd_missing(vma)) {
pte_unmap_unlock(vmf->pte, vmf->ptl);
folio_put(folio);
return handle_userfault(vmf, VM_UFFD_MISSING);
}
inc_mm_counter(vma->vm_mm, MM_ANONPAGES);
folio_add_new_anon_rmap(folio, vma, vmf->address);// 将新分配的页添加到匿名内存映射中,
folio_add_lru_vma(folio, vma); // 并将其添加到LRU列表中。
setpte:
.....
update_mmu_cache_range(vmf, vma, vmf->address, vmf->pte, 1); // 更新MMU缓存
unlock:
if (vmf->pte)
pte_unmap_unlock(vmf->pte, vmf->ptl);
return ret;
......
}
3.3.2-do_fault
通过do_fault
给文件页分配物理地址。
static vm_fault_t do_fault(struct vm_fault *vmf)
{
.....
else if (!(vmf->flags & FAULT_FLAG_WRITE))
ret = do_read_fault(vmf); // 读操作处理
else if (!(vma->vm_flags & VM_SHARED))
ret = do_cow_fault(vmf); // copy on write 处理
else
ret = do_shared_fault(vmf); // 共享页处理
/* preallocated pagetable is unused: free it */
if (vmf->prealloc_pte) {
pte_free(vm_mm, vmf->prealloc_pte);
vmf->prealloc_pte = NULL;
}
return ret;
}
上述几种处理最终都会调用到__do_fault
static vm_fault_t __do_fault(struct vm_fault *vmf)
{
......
if (pmd_none(*vmf->pmd) && !vmf->prealloc_pte) { // 在获取页锁之前预分配一个页表项(PTE)
vmf->prealloc_pte = pte_alloc_one(vma->vm_mm);
if (!vmf->prealloc_pte)
return VM_FAULT_OOM;
}
ret = vma->vm_ops->fault(vmf); // VMA的fault操作来处理缺页异常,通常定义在对应的文件系统里
if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY |
VM_FAULT_DONE_COW)))
return ret;
// 检查当前页是否为硬件毒页,硬件毒页是指内存中的某个页出现了不可恢复的硬件错误,例如内存芯片的某个位发生了永久性故障。
if (unlikely(PageHWPoison(vmf->page))) {
struct page *page = vmf->page;
vm_fault_t poisonret = VM_FAULT_HWPOISON;
......
put_page(page); // 释放页,直接返回
vmf->page = NULL;
return poisonret;
}
......
return ret;
}
例如我们分析ext4的文件页缺页处理
static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = filemap_fault,
......
};
最终会调用到通用的filemap_fault
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
......
max_idx = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE); // 计算文件映射的最大索引
if (unlikely(index >= max_idx)) // 如果请求的虚拟地址超出了文件大小
return VM_FAULT_SIGBUS; // 返回VM_FAULT_SIGBUS表示访问了一个无效的内存区域。
/*
* 尝试从文件映射中获取一个已缓存的folio。
*/
folio = filemap_get_folio(mapping, index);
if (likely(!IS_ERR(folio))) {
/*
* We found the page, so try async readahead before waiting for
* the lock.
*/
if (!(vmf->flags & FAULT_FLAG_TRIED))
fpin = do_async_mmap_readahead(vmf, folio); // 如果找到了folio,尝试进行异步读取。
.....
} else {
// 如果folio不存在,标记一次主缺页异常(major fault),
count_vm_event(PGMAJFAULT);
count_memcg_event_mm(vmf->vma->vm_mm, PGMAJFAULT);
ret = VM_FAULT_MAJOR;
fpin = do_sync_mmap_readahead(vmf); // 并执行同步读取。
......
}
if (!lock_folio_maybe_drop_mmap(vmf, folio, &fpin)) // 如果folio已更新,锁定并返回它。
goto out_retry;
/* Did it get truncated? */
if (unlikely(folio->mapping != mapping)) { // 如果folio被截断
folio_unlock(folio); // 解锁folio
folio_put(folio); // 释放folio
goto retry_find;
}
VM_BUG_ON_FOLIO(!folio_contains(folio, index), folio);
......
vmf->page = folio_file_page(folio, index); // 如果folio已更新并且索引在文件大小范围内,那么返回锁定的folio。
return ret | VM_FAULT_LOCKED;
page_not_uptodate:
fpin = maybe_unlock_mmap_for_io(vmf, fpin);
error = filemap_read_folio(file, mapping->a_ops->read_folio, folio); // 如果folio未更新,尝试同步读取folio
if (fpin)
goto out_retry;
folio_put(folio);
if (!error || error == AOP_TRUNCATED_PAGE)
goto retry_find;
filemap_invalidate_unlock_shared(mapping);
return VM_FAULT_SIGBUS;
out_retry:
/*
* 释放所有资源并返回VM_FAULT_RETRY。
*/
if (!IS_ERR(folio))
folio_put(folio);
if (mapping_locked)
filemap_invalidate_unlock_shared(mapping);
if (fpin)
fput(fpin);
return ret | VM_FAULT_RETRY;
}