3. Linux内核页表管理:struct page、pgd/pmd/pte、页表分配与释放

页表是CPU内存管理单元(MMU)将虚拟地址转换为物理地址的核心数据结构。在Linux内核中,页表管理不仅涉及硬件页表的构建与维护,还涉及物理内存描述符struct page的分配与回收。本章深入剖析多级页表结构、内核页表操作接口以及页表页面的生命周期管理。

3.1 多级页表模型与硬件抽象

Linux内核采用与体系结构无关的页表抽象层,支持4级或5级页表(取决于CONFIG_PGTABLE_LEVELS)。以典型的4级页表为例,虚拟地址被划分为5个域:

页表级别 内核符号 描述 典型位数(ARM64 4KB页)
PGD pgd_t 页全局目录,顶级页表 bits[47:39]
P4D p4d_t 页四级目录(5级页表时存在) —
PUD pud_t 页上级目录 bits[38:30]
PMD pmd_t 页中间目录 bits[29:21]
PTE pte_t 页表项,指向物理页帧 bits[20:12]
体系结构适配: 内核通过pgtable-nop4d.h和pgtable-nopud.h等头文件,在硬件不支持4级或5级页表时自动折叠中间层级,保证上层代码无需修改。
4级页表地址转换流程 4级页表:虚拟地址 → 物理地址转换 虚拟地址 [63:0] PGD索引 PUD索引 PMD索引 PTE索引 页内偏移 PGD PUD PMD PTE 物理页帧 说明: • 每级页表项指向下一级页表的基地址(物理地址)。 • PGD基地址存储在CPU的TTBRx_EL1(ARM64)或CR3(x86)寄存器中。 • 各级页表项均包含权限位、脏位、访问位等属性控制。

3.2 struct page:物理页面的内核描述符

内核使用struct page描述每个物理页帧(通常为4KB)。该结构体位于include/linux/mm_types.h,是内存管理的最小单元。关键字段如下:

字段 类型 用途
flags unsigned long 页面状态标志(PG_locked、PG_dirty、PG_swapbacked等)
_refcount atomic_t 引用计数,决定页面是否可回收
_mapcount atomic_t 映射到此页面的PTE数量
mapping struct address_space * 指向所属文件映射或匿名映射
index pgoff_t 在映射内的页偏移
lru struct list_head LRU链表节点,用于页面回收
slab_cache struct kmem_cache * SLAB分配器使用(slab页)
关键概念: struct page与页表项之间通过page_to_pfn()和pfn_to_page()宏相互转换。每个PTE中存储的物理页帧号(PFN)可直接映射到对应的struct page实例。

3.3 页表分配与释放接口

内核提供了一套体系结构无关的页表分配/释放API,底层调用struct page分配函数。核心接口如下:

  • pgd_alloc() / pgd_free() — 分配/释放PGD页表页面。通常每个进程拥有独立的PGD。
  • p4d_alloc() / p4d_free() — 分配/释放P4D页表页面(仅5级页表)。
  • pud_alloc() / pud_free() — 分配/释放PUD页表页面。
  • pmd_alloc() / pmd_free() — 分配/释放PMD页表页面。
  • pte_alloc() / pte_free() — 分配/释放PTE页表页面。

这些接口内部通过alloc_pages()分配一个struct page,然后调用__get_free_page()获取虚拟地址,最后将页面清零并初始化页表项格式。

/* 以ARM64为例:分配一个PMD页表页面 */
pmd_t *pmd_alloc_one(struct mm_struct *mm, unsigned long addr)
{
    struct page *page;
    pmd_t *pmd;

    page = alloc_pages(GFP_KERNEL_ACCOUNT, 0);
    if (!page)
        return NULL;

    /* 确保页面引用计数正确 */
    if (!pgtable_pmd_page_ctor(page)) {
        __free_page(page);
        return NULL;
    }

    pmd = (pmd_t *)page_address(page);
    /* 清零页表内容 */
    memset(pmd, 0, PAGE_SIZE);
    return pmd;
}
注意事项: 页表页面属于内核关键资源,分配时必须使用GFP_KERNEL_ACCOUNT标志以便计入进程内存账本。释放时需调用pgtable_pmd_page_dtor()清理struct page中的页表计数。

3.4 页表遍历与修改操作

内核提供walk_page_range()和底层宏来遍历进程地址空间的页表。常用操作宏包括:

宏/函数 功能
pgd_offset(mm, addr) 获取addr在mm的PGD中的索引项
pud_offset(p4d, addr) 从P4D项获取PUD表基址并索引
pmd_offset(pud, addr) 从PUD项获取PMD表基址并索引
pte_offset_map(pmd, addr) 从PMD项获取PTE表基址并映射到线性地址
set_pte(ptep, pte) 写入一个PTE项(含内存屏障)
ptep_clear_flush(mm, addr, ptep) 清除PTE并刷TLB
页表遍历与修改流程 页表遍历与修改流程 pgd_offset(mm, addr) pud_offset(p4d, addr) pmd_offset(pud, addr) pte_offset_map set_pte(ptep, pte) ptep_clear_flush(mm, addr, ptep) 关键点: • 遍历过程中需检查每级页表项是否有效(pte_none/pud_none等)。 • 修改PTE后必须调用flush_tlb_page()或使用ptep_clear_flush()确保TLB一致性。

3.5 页表缓存与TLB协同

页表修改后,CPU的TLB(Translation Lookaside Buffer)可能缓存了旧的转换结果。内核通过以下机制保证一致性:

  • 局部刷新: flush_tlb_page(vma, addr) — 仅刷新指定虚拟地址的TLB项。
  • 范围刷新: flush_tlb_range(vma, start, end) — 刷新一段地址范围的TLB。
  • 全局刷新: flush_tlb_all() — 刷新所有TLB项(开销大,仅在内核页表切换时使用)。
  • ASID/PCID: ARM64使用ASID(Address Space ID),x86使用PCID(Process Context ID),避免进程切换时完全刷新TLB。
性能优化: 在SMMU场景中,IOMMU的TLB(IOTLB)与CPU TLB独立。修改设备DMA映射的页表后,必须调用arm_smmu_tlb_inv_range()等IOMMU特定接口刷新IOTLB,否则设备可能访问到错误的物理内存。

3.6 页表页面生命周期管理

页表页面本身也是物理内存页,其生命周期由struct page的引用计数和_mapcount管理:

阶段 操作 引用计数变化
分配 alloc_pages() → pgtable_pte_page_ctor() _refcount = 1, _mapcount = 0
映射 set_pte() 建立映射 _mapcount 增加
解除映射 ptep_clear() + flush_tlb() _mapcount 减少
释放 pgtable_pte_page_dtor() → __free_page() _refcount 归零

内核通过pmd_none()、pmd_bad()等宏检测页表项状态,在缺页异常处理中按需分配中间页表。当所有PTE被解除后,PMD/PUD/PGD页面可逐级释放。

/* 释放PTE页表页面的典型路径 */
void pte_free(struct mm_struct *mm, pgtable_t pte)
{
    struct page *page = pte;

    /* 清理struct page中的页表计数 */
    pgtable_pte_page_dtor(page);
    /* 归还给伙伴系统 */
    __free_page(page);
}
实战要点: 在编写设备驱动或SMMU驱动时,若需要手动建立内核虚拟地址到物理地址的映射(如ioremap_page_range()),必须显式调用pgd_alloc()→pud_alloc()→pmd_alloc()→pte_alloc()链,并在映射完成后调用flush_tlb_kernel_range()。切勿直接操作pte_t而跳过页表分配。