分页机制是现代操作系统内存管理的基石,而Linux内核在32位系统下的实现充分体现了其高效性与灵活性。本文将从分页机制的基本原理出发,详细讲解Linux内核如何在32位x86架构下构建页目录(Page Directory)和页表(Page Table),如何为它们赋值,并深入探讨一个关键问题:当进程分配到物理内存的高地址(如最高1GB)时,如何正确赋值页表而不干扰内核空间的映射。

第一部分:32位系统分页机制基础

1.1 分页机制的基本原理

分页机制的核心思想是将内存划分为固定大小的块,称为页面(Page)。在32位系统中,页面大小通常为4KB(2^12字节),这是因为低12位用于表示页面内的偏移量,而高20位用于索引页面。这种设计源于硬件和操作系统的折中,既能高效利用内存,又便于管理。

  • 虚拟地址(Virtual Address):每个进程拥有独立的4GB(2^32字节)虚拟地址空间。
  • 物理地址(Physical Address):实际的硬件内存地址,通过分页机制映射而来。
  • 页面与页面框架:虚拟地址空间被划分为页面,物理内存被划分为页面框架(Page Frame),两者通过页表建立映射关系。

分页机制的优势包括:

  • 进程隔离:每个进程的虚拟地址空间独立,互不干扰。
  • 内存保护:通过页表属性(如读写权限)限制访问。
  • 按需分页(Demand Paging):只加载需要的页面,节省物理内存。
  • 高效分配:固定大小的页面避免外部碎片,提升内存利用率。

分页机制由硬件和软件协同实现:

  • 硬件:MMU(Memory Management Unit)负责地址转换,TLB(Translation Lookaside Buffer)缓存常用映射。
  • 软件:操作系统(如Linux)维护页表,处理页面错误。

1.2 虚拟地址的结构

在32位x86架构中,虚拟地址采用两级分页结构,分为三部分:

  • 页面目录索引(PGD Index):高10位(31-22),索引页面目录中的条目。
  • 页面表索引(PT Index):次10位(21-12),索引页面表中的条目。
  • 页面内偏移量(Offset):低12位(11-0),指定页面内的字节位置。

以虚拟地址0x12345678为例:

  1. | 31 22 | 21 12 | 11 0 |
  2. | 0001 0010 00 | 11 0100 0101 | 0110 0111 1000 |
  3. | PGD索引: 72 | PT索引: 837 | 偏移量: 1656 |
  • 页面目录:包含1024个条目(2^10),每个条目指向一个页面表。
  • 页面表:也包含1024个条目,每个条目映射一个4KB物理页面。
  • 总容量:1024 × 1024 × 4KB = 4GB,覆盖整个32位地址空间。

这种两级结构是对存储空间和性能的权衡:

  • 一级页表需要4GB / 4KB = 1M个条目,占用4MB内存,太大。
  • 两级页表将管理分为两步,页面目录和页面表各4KB,总开销更小。

1.3 两级页表结构详解

页面目录(Page Directory)

  • 大小:4KB,包含1024个32位条目(Page Directory Entry, PDE)。
  • 结构
    1. | 31 12 | 11 9 | 8 7 6 5 4 3 2 1 0 |
    2. | 页面表基地址 | 保留 | G PAT D A PCD PWT U W P |
    • 页面表基地址:高20位,指向页面表的物理地址(4KB对齐)。
    • 属性位
      • P(Present):1表示页面表存在。
      • W(Write):1表示可写。
      • U(User):1表示用户态可访问。
      • PCD/PWT:缓存策略。
  • 存储:物理地址由CR3寄存器指定。

页面表(Page Table)

  • 大小:4KB,包含1024个32位条目(Page Table Entry, PTE)。
  • 结构
    1. | 31 12 | 11 9 | 8 7 6 5 4 3 2 1 0 |
    2. | 物理页面基地址 | 保留 | G PAT D A PCD PWT U W P |
    • 物理页面基地址:高20位,指向4KB物理页面。
    • 属性位:与页面目录类似。

地址转换流程

  1. 从CR3获取页面目录的物理基地址。
  2. 用PGD索引(高10位)查找页面目录项,得到页面表地址。
  3. 用PT索引(次10位)查找页面表项,得到物理页面地址。
  4. 加上偏移量(低12位),计算最终物理地址。

例如,虚拟地址0x12345678

  • PGD索引 = 72,假设pgd[72] = 0x50000000 | PRESENT
  • PT索引 = 837,假设pte[837] = 0x60000000 | PRESENT
  • 物理地址 = 0x60000000 + 1656 = 0x60000678

硬件支持

  • MMU:解析页表,执行转换。
  • TLB:高速缓存,存储最近使用的映射。若TLB未命中,则触发页表查询。
  • 控制寄存器
    • CR0:PG位启用分页。
    • CR3:存储页面目录地址。

第二部分:Linux内核中的页目录和页表构建

2.1 内核启动时的页表初始化

Linux内核从启动到运行经历了从实模式到保护模式的转换,页表初始化是这一过程的关键。

2.1.1 临时页表

  • 背景:Linux启动时由引导加载程序(如GRUB)加载到物理地址0x100000(1MB),运行在实模式下。
  • 目的:启用分页后,内核需要访问虚拟地址(如0xC0000000)。
  • 实现
    • arch/x86/boot/head_32.S中静态定义:
      1. boot_pg_dir: // 页面目录
      2. .long 0x00000000 + 0x007 // 身份映射:0x0 -> 0x0
      3. .fill 767, 4, 0 // 填充0
      4. .long 0x50000000 + 0x007 // 内核映射:0xC0000000 -> 0x0
      5. .fill 255, 4, 0
      6. boot_page_table: // 页面表
      7. .long 0x00000000 + 0x007 // 0x0 -> 0x0
      8. .long 0x00001000 + 0x007 // 0x1000 -> 0x1000
      9. ...
    • 身份映射:物理0x0 - 1MB映射到虚拟0x0 - 1MB,确保分页启用前后的连续性。
    • 内核映射:物理0x0 - 1MB映射到虚拟0xC0000000 - 0xC0100000
  • 启用分页
    • boot_pg_dir的物理地址写入CR3。
    • 设置CR0的PG位:
      1. movl %cr0, %eax
      2. orl $0x80000000, %eax
      3. movl %eax, %cr0

2.1.2 永久页表初始化

  • 入口:进入C代码后,start_kernel()调用paging_init()arch/x86/kernel/setup.c)。
  • 任务
    1. 分配永久页面目录:
      • 使用pgd_alloc()分配4KB内存。
    2. 映射内核空间:
      • init_mem_mapping()设置线性映射。
  • 代码示例
    1. void __init paging_init(void) {
    2. init_mem_mapping(0, max_low_pfn << PAGE_SHIFT);
    3. load_cr3(swapper_pg_dir);
    4. }
  • 直接映射区:物理0x0 - 0xFFFFFFF映射到0xC0000000 - 0xCFFFFFFF

2.2 内核空间的页表构建

  • 地址范围:0xC0000000 - 0xFFFFFFFF(1GB)。
  • 实现细节
    • 文件:arch/x86/mm/init_32.c
    • 函数:init_mem_mapping()
  • 步骤
    1. 分配页面目录
      • swapper_pg_dir是内核的全局页面目录。
    2. 填充映射
      • 线性映射:虚拟地址 = 物理地址 + PAGE_OFFSET
      • 示例:物理0x100000 → 虚拟0xC0100000
    3. 设置页面表
      • 分配页面表并填充条目。
  • 代码
    1. static void __init init_mem_mapping(unsigned long start, unsigned long end) {
    2. unsigned long pfn;
    3. pgd_t *pgd = swapper_pg_dir + pgd_index(start);
    4. pte_t *pte;
    5. for (pfn = start >> PAGE_SHIFT; pfn < end >> PAGE_SHIFT; pfn++) {
    6. if (pgd_none(*pgd)) {
    7. pte = (pte_t *)get_zeroed_page(GFP_KERNEL);
    8. set_pgd(pgd, __pgd(__pa(pte) | _PAGE_TABLE));
    9. }
    10. pte = pte_offset_kernel(pgd, pfn << PAGE_SHIFT);
    11. set_pte(pte, pfn_pte(pfn, PAGE_KERNEL));
    12. pgd++;
    13. }
    14. }

2.3 用户进程的页表构建

  • 进程创建

    • fork()调用copy_mm()
      1. int copy_mm(unsigned long clone_flags, struct task_struct *tsk) {
      2. struct mm_struct *mm = allocate_mm();
      3. mm->pgd = pgd_alloc(mm);
      4. copy_user_pgd(mm->pgd, current->mm->pgd);
      5. return 0;
      6. }
    • 用户空间采用写时复制(Copy-on-Write),内核空间共享。
  • 按需分页

    • 页面错误处理do_page_fault()arch/x86/mm/fault.c)。
    • 流程
      1. 检查虚拟地址。
      2. 分配物理页面。
      3. 更新页表。
    • 代码
      1. void do_page_fault(struct pt_regs *regs, unsigned long error_code) {
      2. unsigned long address = read_cr2();
      3. struct vm_area_struct *vma = find_vma(current->mm, address);
      4. if (!vma) {
      5. handle_mm_fault(vma, address, FAULT_FLAG_WRITE);
      6. }
      7. }

第三部分:页目录和页表的赋值过程

3.1 赋值的基本原理

  • 页面目录赋值:将页面表的物理地址写入PGD项。
  • 页面表赋值:将物理页面地址写入PTE项。
  • 工具
    • set_pgd(pgd_t *pgdp, pgd_t pgdval)
    • set_pte(pte_t *ptep, pte_t pteval)

3.2 内核初始化时的赋值

  • 场景:映射物理0x0 - 0x100000到虚拟0xC0000000 - 0xC0100000
  • 步骤
    1. 设置页面目录:
      1. pgd_t *pgd = swapper_pg_dir;
      2. pgd[768] = (pgd_t)(0x50000000 | PGDIR_PRESENT | PGDIR_RW);
    2. 设置页面表:
      1. pte_t *pte = (pte_t *)__va(0x50000000);
      2. for (int i = 0; i < 256; i++) {
      3. pte[i] = (pte_t)((i << 12) | PTE_PRESENT | PTE_RW);
      4. }

3.3 页面错误时的赋值

  • 场景:虚拟地址0x08048000映射到物理0xC0000000
  • 步骤
    1. 检查页面目录:
      1. pgd_t *pgd = current->mm->pgd;
      2. if (pgd_none(pgd[2])) {
      3. pte_t *pt = (pte_t *)get_zeroed_page(GFP_KERNEL);
      4. set_pgd(&pgd[2], (pgd_t)(__pa(pt) | PGDIR_PRESENT | PGDIR_USER));
      5. }
    2. 设置页面表:
      1. pte_t *pte = pte_offset_map(&pgd[2], 0x08048000);
      2. set_pte(&pte[33], (pte_t)(0xC0000000 | PTE_PRESENT | PTE_USER));
      3. pte_unmap(pte);

第四部分:虚拟地址与物理地址的映射关系

4.1 内核空间的1GB布局

  • 虚拟地址:0xC0000000 - 0xFFFFFFFF。
  • 映射目标:通常是物理低端内存。

4.2 物理地址的加载位置

  • 默认位置:物理0x100000(1MB)。
  • 原因:BIOS占用低端内存,内核从1MB开始加载。

4.3 高内存的处理

  • 定义:物理地址超出1GB的部分。
  • 实现:通过kmap或临时页表访问。

第五部分:物理高地址分配时的页表赋值

5.1 问题提出

假设进程分配到物理地址0xC0000000,映射到虚拟0x08048000,如何赋值?

5.2 赋值流程

  • 索引计算
    • PGD索引:0x08048000 >> 22 = 2
    • PT索引:(0x08048000 >> 12) & 0x3FF = 33
  • 赋值
    1. pgd_t *pgd = current->mm->pgd;
    2. pte_t *pte = pte_offset_map(&pgd[2], 0x08048000);
    3. set_pte(&pte[33], (pte_t)(0xC0000000 | PTE_PRESENT | PTE_USER));

5.3 隔离机制

  • 页表隔离:用户空间和内核空间由不同的PGD条目管理。
  • 内核保护pgd[768-1023]共享全局映射。

第六部分:总结与扩展

Linux的分页机制通过两级页表和按需分页,实现了高效的内存管理。物理高地址的分配不会干扰内核空间,体现了设计的优雅性。读者可进一步探索源码或64位系统的四级页表。