从零写OS内核 | 虚拟内存——分页机制与线性地址的完整旅程

你写了一句 int *p = (int*)0x00401000; *p = 42;,这个地址是真实的物理地址吗?操作系统是怎么保证每个进程都觉得自己有 4GB 内存,而实际上物理内存只有 8GB?

答案是分页

分页是现代操作系统虚拟内存管理的核心机制:CPU 把线性地址(程序看到的地址)通过页表转换成物理地址(硬件实际访问的地址),而操作系统负责建立和维护这张映射表,并通过页面置换算法让有限内存服务无限进程。

今天,我们来完整走一遍这个转换过程。


1. 为什么需要分页:连续与离散

如果不用分页,内存管理还有什么方式?

段式管理:每个程序一段连续空间,基址寄存器 + 限长寄存器。问题是外部碎片——每个程序都要连续的物理内存,大程序找空间难,小程序浪费空间。

分页:把物理内存和虚拟内存都切成固定大小的块(页)。程序的虚拟地址空间也切成同样大小的页(页面)。离散映射解决了外部碎片,内存利用率大大提高。

Bash
段式管理 vs 分页:

段式:每个进程一段连续空间
  进程A: [物理 0-100K]  进程B: [物理 200-300K]  进程C: [物理 150-200K]
  → 大程序放不进去

分页:固定4KB块,映射到任意物理页
  进程A虚拟页0 → 物理页5   进程A虚拟页1 → 物理页12
  进程B虚拟页0 → 物理页7   进程B虚拟页1 → 物理页3
  → 物理页可以任意排列组合,无外部碎片

2. x86 分页基础:两级页表

x86 的分页机制经历了两代:32位两级页表(IA-32)和 PAE 四级页表(IA-32e),以及 x86_64 的四级页表(PML4)。

2.1 32位两级页表(无PAE)

Bash
32位线性地址分解:
┌──────────┬──────────┬──────────┐
│  目录(10) │  页表(10) │  偏移(12) │
└──────────┴──────────┴──────────┘
   ↑            ↑           ↑
 CR3指向      页目录      4KB页内
 页目录       (PDE)      偏移量

转换过程:
CR3(页目录基址)→ 找到页目录项(PDE)→ 找到页表(PTE)→ 找到物理页框 → 加偏移

每一级索引 10 位,所以页目录和页表各有 1024 个条目(4KB,正好一个页)。

Bash
页目录项 (PDE) 格式(4字节):
┌─────────────────────────────────────────────────────────────────┐
│  Bit 31-12:  页表基址(高20位,4KB对齐)                          │
│  Bit 9:      Reserved (0)                                       │
│  Bit 8:      PCD (Page Cache Disable)                           │
│  Bit 7:      PWT (Write-Through)                                │
│  Bit 6:      Accessed                                           │
│  Bit 5:      Reserved (0)                                       │
│  Bit 4:      PS (Page Size) = 0(表示指向页表,不是大页)         │
│  Bit 3:      P (Present) = 1 表示存在                           │
│  Bit 2:      R/W (Read/Write)                                   │
│  Bit 1:      U/S (User/Supervisor)                              │
│  Bit 0:      P (Present)                                        │
└─────────────────────────────────────────────────────────────────┘

页表项 (PTE) 格式(4字节):
┌─────────────────────────────────────────────────────────────────┐
│  Bit 31-12:  物理页框基址(高20位,4KB对齐)                     │
│  Bit 9-7:    Global (TLB相关)                                    │
│  Bit 6:      PAT (Page Attribute Table)                         │
│  Bit 5:      Dirty                                              │
│  Bit 4:      Accessed                                            │
│  Bit 3:      PCD                                                │
│  Bit 2:      PWT                                                │
│  Bit 1:      U/S                                                │
│  Bit 0:      R/W                                                │
│  Bit 0:      P (Present)                                        │
└─────────────────────────────────────────────────────────────────┘

2.2 为什么需要两级页表

如果用一级页表(直接 4GB / 4KB = 1,048,576 个 PTE),每个进程需要一个 4MB 的页表(1M × 4字节)。进程多了内存开销巨大。

两级页表的优势:按需分配。进程的虚拟地址空间可能只有 0x00000000~0x00400000(代码段)和 0xBFFF0000~0xC0000000(栈)等几个区间,不需要为整个 4GB 空间建立页表。

页目录本身也是 4KB(1024 个 PDE),如果某个 PDE 为空(Present=0),它指向的页表根本不需要分配。这叫层级页目录管理

2.3 页面大小

标准页面是 4KB。但 x86 还支持大页(2MB 或 4MB)——通过 PDE 的 PS 位(Page Size)直接指向 2MB 物理区域,跳过二级页表。大页适合数据库等需要大块连续内存的场景。


3. 线性地址到物理地址的完整转换

用一个具体例子走一遍:

Bash
假设:
CR3 = 0x00123000(页目录物理地址)

要转换的线性地址:0x00401000

Step 1: 分解线性地址
  高10位(目录索引)= 0x00401000 >> 22 = 0x01
  中10位(页表索引)= (0x00401000 >> 12) & 0x3FF = 0x001
  低12位(页内偏移)= 0x00401000 & 0xFFF = 0x000

Step 2: 读页目录
  页目录物理地址 = CR3 = 0x00123000
  页目录项地址 = 0x00123000 + 0x01 × 4 = 0x00123004
  假设页目录项内容 = 0x00125003(页表存在,R/W=1,U/S=1)
  → 页表物理地址 = 0x00125000 & ~0xFFF = 0x00125000

Step 3: 读页表
  页表物理地址 = 0x00125000
  页表项地址 = 0x00125000 + 0x001 × 4 = 0x00125004
  假设页表项内容 = 0x00D3E003(物理页存在,R/W=1,U/S=1)
  → 物理页框基址 = 0x00D3E000

Step 4: 组合物理地址
  物理地址 = 物理页框基址 + 页内偏移
           = 0x00D3E000 + 0x000
           = 0x00D3E000

完整流程图:

Bash
线性地址: 0x00401000
  ↓
[目录索引 0x01] → CR3(0x00123000) + 0x01×4 → 读PDE → 页表在 0x00125000
  ↓
[页表索引 0x001] → 页表(0x00125000) + 0x001×4 → 读PTE → 页在 0x00D3E000
  ↓
[偏移 0x000] → 物理地址 = 0x00D3E000

4. 页面fault:虚拟内存的核心机制

虚拟内存不只解决地址翻译问题,还解决"内存不够用"的问题——当物理页不存在时,CPU 会触发 Page Fault(#PF)异常,操作系统介入处理。

4.1 缺页异常的三种情况

1. 合法访问,但页不在内存(Soft Page Fault)

  • PTE 的 Present 位 = 0,但 PTE 其他位有效(指向交换区或文件)
  • OS 从磁盘换入页面,更新 PTE,恢复执行

2. 非法访问(Access Violation)

  • PTE 全为 0,或访问了用户态无权访问的地址
  • OS 发送 SIGSEGV,杀死进程

3. Copy-on-Write(COW)

  • 父子进程共享同一个物理页,但都标记为只读
  • 写时触发 #PF,OS 复制物理页,更新 PTE,恢复执行

Bash
页面fault处理流程:

CPU 执行访问 → MMU 查页表 → PDE/PTE Present=0 → 触发 #PF

    ↓
OS 接管 #PF handler
    ↓
检查地址是否在进程合法 VMA(虚拟内存区域)中?
    ↓ 是                    ↓ 否
    ↓                   发送 SIGSEGV
换入页面               杀死进程
    ↓
更新 PTE(设置 Present=1, 物理地址)
恢复执行

4.2 换页策略

当物理内存真的不够时,OS 需要把某个页面换出到磁盘。Linux 用 LRU(最近最少使用)变体——活跃页面和非活跃页面分开管理,配合周期性扫描。

Bash
# 查看当前内存和换页情况
vmstat 1

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  0      0 655360  12340 234567    0    0     0     0   34   12  5  2 93  0  0
# si = swap in(换入页/秒),so = swap out(换出页/秒)
# si/so 非零说明正在换页

5. PAE:突破4GB内存上限

32位 CPU 理论上能访问 4GB 地址空间,但 32位页表只能寻址 4GB 物理内存。如果机器有 8GB 物理内存,32位 OS 怎么用?

答案是 PAE(Physical Address Extension)

5.1 PAE 的页表结构(3级)

Bash
PAE 下的线性地址分解(36位物理地址):
┌──────────┬──────────┬──────────┐
│  目录指针 │  页目录   │  页表     │  → 9+9+9 = 27位索引
│  (2)      │  (9)     │  (9)     │  → 剩余12位是偏移
└──────────┴──────────┴──────────┘

CR3 指向 Page Directory Pointer Table (PDPT),4项(每项4字节)
每项指向一个 Page Directory(512项)
每个 Page Directory 项指向一个 Page Table(512项)
每个 Page Table 项指向 4KB 物理页

最大物理寻址:2^36 = 64GB(PAE 实际是 36 位地址线)

5.2 PAE 的限制

PAE 只是扩展了物理地址,虚拟地址仍然是 32 位。这意味着:

  • 单个进程仍然只能使用 4GB 虚拟地址空间
  • 要真正让单个进程使用超过 4GB,需要 IA-32e 模式(x86_64),它有 48 位虚拟地址

6. x86_64 分页:四级页表(PML4)

现代 64位 Linux 使用四级页表:

Bash
x86_64 线性地址(48有效位)分解:
┌────────┬────────┬────────┬────────┬────────────┐
│ PML4   │ PDPT   │  PD    │  PT    │  偏移       │
│ (9bit) │ (9bit) │ (9bit) │ (9bit) │ (12bit)    │
└────────┴────────┴────────┴────────┴────────────┘
   47-39  38-30    29-21   20-12   11-0

PML4(Page Map Level 4):CR3 指向,512项
PDPT(Page Directory Pointer Table):512项,每项指向一个 Page Directory
PD(Page Directory):512项,每项指向一个 Page Table
PT(Page Table):512项,每项指向 4KB 物理页

虚拟地址高 16 位是符号扩展(bit 47 ~ bit 63 必须相同,或全0或全1)
这就是为什么 x86_64 实际只有 48 位虚拟地址(用户空间通常是 47 位)

Linux 把四级页表统一抽象为四级通用页表:PGD → PUD → PMD → PTE,屏蔽了硬件差异。


7. Linux 实践:观察页表和内存映射

Bash
# 查看进程的页表(页目录基址)
cat /proc/1/pagemap

# 找到某个虚拟地址对应的物理页
pmap 1

# 查看内存区域映射(进程的 VMA)
cat /proc/1/maps

# 观察页表占用(PSS 是实际占用的物理内存)
cat /proc/1/smaps_rollup

Bash
# 查看系统内存布局(e820 内存映射对应页表初始化)
cat /proc/iomem

00000000-00000fff : Reserved
00001000-0009ffff : System RAM          ← 0-640KB,低端内存
000a0000-000fffff : Reserved            ← VGA/ROM 区
00100000-xxxxxxxx : System RAM          ← 1MB 以上,可用于页表
...

Bash
# 用 /proc/self/map_files 观察两个进程共享同一物理页(COW)
# 启动两个 cat 进程,看它们的 map_files
ls -la /proc/$$/map_files | head -20

# 或用 pagemap 查看指定虚拟地址的物理页帧号
python3 -c "
import os
with open('/proc/self/pagemap', 'rb') as f:
    # 读取 /proc/self/maps 找到某个地址
    pass
"

8. 从零实现:wandos 的分页机制

⚠️ wandos 当前状态kernel/memory/paging.cpp 是 wandos 实现虚拟内存管理的核心文件。wandos 使用和 Linux 类似的四级页表结构,以下分析其实现。

8.1 页面目录结构(paging.cpp)

Cpp
// wandos 分页结构(基于 x86_64 架构)
// 源码:kernel/memory/paging.cpp

// 页表项结构
struct page_table_entry {
    uint64_t present      : 1;    // 页是否存在
    uint64_t writable     : 1;    // 读写权限
    uint64_t user_access  : 1;    // 用户态可访问
    uint64_t pcd          : 1;    // 禁用缓存
    uint64_t accessed     : 1;    // 被访问过
    uint64_t dirty        : 1;    // 被修改过
    uint64_t pat          : 1;    // 页属性
    uint64_t global       : 1;    // 全局页(TLB 不刷新)
    uint64_t frame_addr   : 40;   // 物理页帧地址(高40位)
    uint64_t reserved     : 11;   // 保留
    uint64_t xd           : 1;    // 执行禁用(NX)
} __attribute__((packed));

// 四级页表指针
struct pml4_entry {
    uint64_t present      : 1;
    uint64_t writable     : 1;
    uint64_t user_access  : 1;
    uint64_t pwt          : 1;
    uint64_t pcd          : 1;
    uint64_t accessed     : 1;
    uint64_t reserved1    : 1;
    uint64_t ps           : 1;    // 页大小(0=普通页)
    uint64_t reserved2    : 4;
    uint64_t frame_addr   : 40;
    uint64_t reserved3    : 11;
    uint64_t xd           : 1;
} __attribute__((packed));

8.2 虚拟地址翻译函数

Cpp
// 虚拟地址 → 页表项查找
page_table_entry* walk_page_table(uint64_t virtual_addr, pml4_entry* pml4) {
    // 提取四级索引
    uint64_t pml4_idx = (virtual_addr >> 39) & 0x1FF;
    uint64_t pdpt_idx = (virtual_addr >> 30) & 0x1FF;
    uint64_t pd_idx   = (virtual_addr >> 21) & 0x1FF;
    uint64_t pt_idx   = (virtual_addr >> 12) & 0x1FF;

    // PML4 → PDPT
    if (!pml4[pml4_idx].present) return NULL;
    pdpt_entry* pdpt = (pdpt_entry*)(pml4[pml4_idx].frame_addr << 12);

    // PDPT → PD
    if (!pdpt[pdpt_idx].present) return NULL;
    pd_entry* pd = (pd_entry*)(pdpt[pdpt_idx].frame_addr << 12);

    // PD → PT
    if (!pd[pd_idx].present) return NULL;
    pt_entry* pt = (pt_entry*)(pd[pd_idx].frame_addr << 12);

    // PT → 物理页
    if (!pt[pt_idx].present) return NULL;
    return &pt[pt_idx];
}

8.3 页面映射建立

Cpp
// 建立虚拟地址到物理地址的映射
void map_page(uint64_t virtual_addr, uint64_t physical_addr,
              uint64_t flags, pml4_entry* pml4) {
    page_table_entry* pte = walk_page_table_create(virtual_addr, pml4);
    if (!pte) return;  // 分配失败

    pte->present = 1;
    pte->writable = (flags & PTE_WRITABLE) ? 1 : 0;
    pte->user_access = (flags & PTE_USER) ? 1 : 0;
    pte->frame_addr = physical_addr >> 12;  // 物理页帧号
    pte->xd = (flags & PTE_NX) ? 1 : 0;    // 执行禁用

    // 刷新 TLB
    invalidate_tlb(virtual_addr);
}

8.4 wandos 和 Linux 的主要差异

wandos 的分页实现(paging.cpp)基本覆盖了 x86_64 分页的核心要素,但和 Linux 比还有以下差距:

  1. 没有换页机制:没有 swap 空间支持,所有虚拟地址必须对应真实物理页
  2. 没有 COW 实现:fork 时没有页面级 COW,只有简单复制
  3. 没有页面回收:内存不足时无法回收页面
  4. TLB 刷新策略不完整:没有考虑多核间的 TLB shootdown

9. 动手环节:实现一个页表遍历器

今天的目标:在 os-kernel-from-scratch 里实现一个页表遍历工具,给定任意虚拟地址,能打印出它的完整页表翻译路径(PML4→PDPT→PD→PT→物理页)。

任务 1:实现虚拟地址分解

给定 64位虚拟地址,打印出 PML4、PDPT、PD、PT、Offset 的索引值(十六进制格式)。

任务 2:实现四级页表遍历

读取 /proc/self/pagemap(需要 root),给定一个本进程的虚拟地址,打印出对应的物理页帧号。

验收标准

  • 给定虚拟地址 0x7fff00001000,能正确打印:PML4=0x7f, PDPT=0x1, PD=0x00, PT=0x10, Offset=0x000
  • 能从 /proc/self/pagemap 读取并解析出物理帧号(如果 root)

10. 踩坑与注意事项

坑 1:页表分配时的递归映射

四级页表在建立早期页目录时,可能还没有页目录自己的物理页——这时需要用递归页表(Recursive Paging):让页目录的某个 entry 指向自己,这样通过固定的虚拟地址就能访问页目录本身。Linux 在早期初始化时就是用递归映射。

坑 2:写时复制(COW)的 PTE 权限

fork 之后父子共享物理页,PTE 标记为只读(Writable=0)。写时触发 #PF,PF handler 复制页面。如果误把 COW 页标为 Writable,父子都会获得独立副本,失去共享的意义。

坑 3:TLB 和页表的同步

修改页表后,如果不刷新 TLB,CPU 可能会继续用旧的翻译结果(TLB 是 CPU 内部的缓存)。修改 PTE 后必须 invlpg(单条)或 mov cr3, cr3(刷新整页目录)。多核系统中一个核修改了另一个核的页表,需要 IPI 中断触发 TLB shootdown。

坑 4:大页(Huge Page)的 PTE 和 PDE

大页(2MB)的 PDE 直接指向 2MB 物理区域,PS 位 = 1,这时 PDE 下面的页表不存在。访问大页内某个 4KB 子页不需要通过二级页表——是直接映射。大页的 PTE 格式和普通 PTE 不同,不能混用。


写在最后

分页是虚拟内存的心脏:通过固定大小的页和两级(或多级)页表,把”程序看到的地址”和”硬件使用的地址”完全解耦。这个解耦带来了:进程间隔离、内存超售(overcommit)、写时复制、内存映射文件……现代 OS 的所有高级特性都建立在这个基础上。

理解分页,你才真正理解为什么 malloc 可以分配超过物理内存的空间,以及为什么一个进程崩溃不会把整个系统带崩。

下篇预告:中断和异常处理——IDT 的设置、硬件中断与 CPU 异常


相关阅读

  • Linux Kernel Source: arch/x86/mm/pgtable.c(页表管理)
  • Linux Kernel Source: arch/x86/mm/fault.c(Page Fault 处理)
  • wandos: kernel/memory/paging.cpp(分页实现)
  • wandos: kernel/memory/slab_allocator.cpp(物理页分配)
  • 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/paging/)
  • https://github.com/zhangfuwen/wandos — Linux 内核教程
  • 下一篇:《从零写OS内核 | 中断和异常处理——IDT的设置与硬件中断》

动手环节

想深入理解本文内容?动手实践是最好的方式:

今天的目标:下载 wandos 代码仓库,理解 paging.cpp 的实现,对比 Linux 原版。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 kernel/memory/paging.cpp 的完整实现

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年9月5日

作者

评论

发表评论

您的邮箱地址不会被公开。