在前面的文章中,我们从理论层面深入探讨了 Linux 系统虚拟地址映射的基本原理和内核数据结构。本文将聚焦于内核源码,详细解析缺页异常处理的核心逻辑 ——do_page_fault()
函数。通过分析这一关键函数,我们将揭示 Linux 内核如何处理内存访问缺失的情况,以及如何动态建立虚拟地址到物理地址的映射关系。
一、缺页异常的触发与处理流程
当 CPU 访问一个尚未映射到物理内存的虚拟地址时,MMU 会触发一个缺页异常(Page Fault)。Linux 内核通过异常处理机制捕获该异常,并调用do_page_fault()
函数进行处理。这个过程可以概括为:
- MMU 地址转换失败:CPU 尝试访问虚拟地址,但对应的页表项不存在或无效
- 异常向量跳转:硬件跳转到预定义的缺页异常处理向量
- 内核态切换:CPU 从用户态切换到内核态
- 执行异常处理函数:调用
do_page_fault()
处理缺页情况
缺页异常处理函数入口:do_page_fault()
下面我们从内核源码出发,分析do_page_fault()
的核心逻辑。该函数位于内核源码的arch/x86/mm/fault.c
文件中:
/*
* 缺页异常处理的主函数
* error_code: 硬件提供的错误码,包含异常类型信息
* address: 触发缺页异常的虚拟地址(通过CR2寄存器读取)
*/
asmlinkage void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
struct task_struct *tsk = current; // 获取当前进程的task_struct
struct mm_struct *mm = tsk->mm; // 获取进程的内存描述符
unsigned long address = read_cr2(); // 从CR2寄存器读取引发异常的虚拟地址
struct vm_area_struct *vma;
int fault;
// 1. 查找该虚拟地址所属的VMA(vm_area_struct),通常是通过红黑树查找,在更早版本可能是通过AVL树查找
vma = find_vma(mm, address);
// 2. 检查VMA是否存在
if (!vma)
goto bad_area;//不存在抛出段错误
// 3. address合法,但不是栈上地址(因为栈上address <= vm_start)
if (vma->vm_start <= address)
goto good_area;
// 4. address合法,且是栈上地址,但没有VM_GROWSDOWN栈增长权限
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
// 执行栈增长操作
if (unlikely(expand_stack(vma, address)))
goto bad_area;
good_area:
// 5. 检查访问权限是否匹配
if (unlikely(access_error(error_code, vma))) {
bad_area_access_error(error_code, address, vma);
return;
}
// 6. 处理页错误(核心映射逻辑) 这是下一步的核心函数
fault = handle_mm_fault(mm, vma, address, flags);
// 7. 处理错误情况
if (unlikely(fault & VM_FAULT_ERROR)) {
if (fault & VM_FAULT_OOM)
pagefault_out_of_memory();
else if (fault & VM_FAULT_SIGBUS)
__do_sigbus(regs, error_code, address);
else if (fault & VM_FAULT_SIGSEGV)
__do_sigsegv(regs, error_code, address);
return;
}
}
二、关键步骤解析
1. 查找虚拟地址所属的 VMA
find_vma()
函数负责在进程的虚拟地址空间中查找包含指定地址的 VMA(虚拟内存区域)。该函数优先使用红黑树进行高效查找:
/*
* 在mm的VMA树中查找第一个vm_end > addr的VMA
*/
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
struct rb_node *rb_node;
struct vm_area_struct *vma;
// 先尝试使用缓存的VMA(利用局部性原理)
vma = mm->mmap_cache;
if (vma && vma->vm_end > addr && vma->vm_start <= addr)
return vma;
// 从红黑树根节点开始查找
rb_node = mm->mm_rb.rb_node;
vma = NULL;
while (rb_node) {
struct vm_area_struct *tmp;
tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
if (tmp->vm_end > addr) {
vma = tmp;
if (tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
} else
rb_node = rb_node->rb_right;
}
// 更新VMA缓存
if (vma)
mm->mmap_cache = vma;
return vma;
}
2. 处理栈增长
当访问的地址在 VMA 起始地址之前,且该 VMA 具有VM_GROWSDOWN
标志时,内核会尝试动态扩展栈空间:
/*
* 扩展用户栈
*/
static int expand_stack(struct vm_area_struct *vma, unsigned long address)
{
struct mm_struct *mm = vma->vm_mm;
unsigned long new_start;
int error;
// 计算新的栈起始地址(按页面对齐)
new_start = PAGE_ALIGN(address);
// 检查栈深度限制
if (new_start < mm->start_stack - STACK_GROWTH_LIMIT)
return -ENOMEM;
// 检查内存使用限制
if (mm->total_vm >= rlimit(RLIMIT_STACK) >> PAGE_SHIFT)
return -ENOMEM;
// 扩展VMA范围
error = vma_merge(mm, NULL, new_start, vma->vm_end,
vma->vm_flags, NULL, NULL, NULL);
if (error)
return error;
// 更新统计信息
mm->total_vm += (vma->vm_start - new_start) >> PAGE_SHIFT;
return 0;
}
3. 权限检查
access_error()
函数检查当前访问操作是否符合 VMA 的权限设置:
/*
* 检查访问是否违反VMA权限
*/
static inline int access_error(unsigned long error_code,
struct vm_area_struct *vma)
{
// 检查是否为写操作但VMA不可写
if (error_code & PF_WRITE) {
if (!(vma->vm_flags & VM_WRITE))
return 1;
}
// 检查是否为执行操作但VMA不可执行
else if (error_code & PF_EXEC) {
if (!(vma->vm_flags & VM_EXEC))
return 1;
}
// 检查是否为用户态访问但VMA不可用户访问
else if (!(error_code & PF_USER)) {
if (!(vma->vm_flags & VM_MAYREAD))
return 1;
}
return 0;
}
三、页表映射的核心实现:handle_mm_fault()
handle_mm_fault()
函数负责完成从虚拟地址到物理地址的映射建立过程,它是缺页处理的核心,32 位系统中这个函数主要处理页目录(顶级页表),如果pde(页目录项)->pt(页表)不存在,则创建,创建好,根据address中间10位,得到页表项,64位系统是四级页表,但原理类似,以下代码为64 位内核:
/*
* 处理内存页错误,建立虚拟地址到物理地址的映射
*/
int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, unsigned int flags)
{
pgd_t *pgd;
p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
spinlock_t *ptl;
int ret;
// 1. 获取页目录项(PGD)
pgd = pgd_offset(mm, address);
// 2. 获取P4D项(仅在64位系统中存在,32位系统中跳过)
p4d = p4d_offset(pgd, address);
// 3. 获取PUD项(仅在大内存系统中存在,32位系统中跳过)
pud = pud_offset(p4d, address);
// 4. 获取PMD项(页中间目录)
pmd = pmd_offset(pud, address);
// 5. 处理PMD项(可能需要分配新的页表)
if (pmd_none(*pmd)) {
if (pmd_alloc(mm, pud, address))
return VM_FAULT_OOM;
}
// 6. 处理PMD保护错误等特殊情况
if (pmd_trans_huge(*pmd)) {
// 处理巨型页(Huge Page)情况
return handle_huge_pmd_fault(mm, vma, address, pmd, flags);
}
// 7. 获取PTE(页表项)
pte = pte_offset_map_lock(mm, pmd, address, &ptl);
// 8. 处理PTE错误(核心映射逻辑)
ret = handle_pte_fault(mm, vma, address, pte, ptl, flags);
// 9. 释放锁并解除映射
pte_unmap_unlock(pte, ptl);
return ret;
}
四、页表项处理:handle_pte_fault()
handle_pte_fault()
函数是建立物理页映射的最后一步,它根据不同的情况处理页表项:
/*
* 处理页表项错误,建立最终的物理页映射
*/
static int handle_pte_fault(struct mm_struct *mm,
struct vm_area_struct *vma,
unsigned long address, pte_t *pte,
spinlock_t *ptl, unsigned int flags)
{
pte_t entry;
struct page *page;
int fault;
int write = flags & FAULT_FLAG_WRITE;
// 1. 检查页表项是否存在
if (!pte_present(*pte)) {
// 页表项不存在,处理页面不在内存的情况
fault = handle_pte_missing(mm, vma, address, pte, ptl, flags);
return fault;
}
// 2. 检查是否为写操作但页表项只读
if (write && !pte_write(*pte)) {
// 处理写保护错误(COW机制)
fault = handle_pte_wrprotect(mm, vma, address, pte, ptl, flags);
return fault;
}
// 3. 处理其他特殊情况(如页表项被标记为脏页、访问过等)
entry = *pte;
// 更新页表项的访问和脏标志
if (pte_dirty(entry) && !(flags & FAULT_FLAG_WRITE))
entry = pte_mkold(entry);
if (pte_young(entry))
entry = pte_mkold(entry);
else
entry = pte_mkyoung(entry);
if (flags & FAULT_FLAG_WRITE)
entry = pte_mkdirty(entry);
// 更新页表项
pte_set(pmd, pte, entry);
return 0;
}
五、处理页面缺失:handle_pte_missing()
当发现页表项不存在时,handle_pte_missing()
函数负责分配物理页面并建立映射:
/*
* 处理页表项缺失的情况
*/
static int handle_pte_missing(struct mm_struct *mm,
struct vm_area_struct *vma,
unsigned long address, pte_t *pte,
spinlock_t *ptl, unsigned int flags)
{
struct page *page;
pte_t entry;
int ret;
// 1. 检查是否为匿名映射(没有对应文件的内存区域,如堆、栈)
if (vma->vm_flags & VM_ANON) {
// 分配一个新的物理页(匿名映射)
page = alloc_zeroed_user_highpage_movable(vma, address);
if (!page)
return VM_FAULT_OOM;
// 建立页表映射
entry = mk_pte(page, vma->vm_page_prot);
entry = pte_mkwrite(pte_mkdirty(entry));
// 设置页表项
set_pte_at(mm, address, pte, entry);
// 更新统计信息
page_add_new_anon_rmap(page, vma, address, false);
lru_cache_add_active_or_unevictable(page, vma);
return 0;
}
// 2. 处理文件映射情况
if (vma->vm_file) {
// 从文件中读取数据到物理页
ret = filemap_fault(vma, address, pte, flags);
return ret;
}
// 3. 处理其他情况(如缺页中断处理程序未正确设置)
return VM_FAULT_SIGBUS;
}
六、总结
通过深入分析do_page_fault()
函数及其相关调用链,我们揭示了 Linux 内核处理缺页异常的完整流程:
- 异常捕获:MMU 在地址转换失败时触发缺页异常
- VMA 查找:通过红黑树快速定位虚拟地址所属的 VMA
- 权限检查:验证访问权限是否符合 VMA 定义
- 页表遍历:从 PGD 开始,逐级查找或创建页表项
- 物理页分配:在页表项缺失时,分配物理页面并建立映射
- 特殊情况处理:如写时复制(COW)、巨型页等
这种设计使得 Linux 内核能够高效处理内存访问缺失的情况,实现虚拟内存的动态扩展和管理。理解这一机制对于优化应用程序内存使用、调试内存相关问题,以及深入理解操作系统工作原理都具有重要意义。