在这里插入图片描述

承接 lesson16 物理内存探测与分配器(PMM):已经能按页申请/释放物理内存。
本课目标:让 CPU 按虚拟地址访问内存,用页表把「程序眼里的地址」翻译成「真实的物理页」。

前面章节我们已经实现保护模式、中断、多任务、物理页分配。若没有分页,任务之间仍然共享同一套线性地址,谁都能踩谁的栈;也无法实现「每个进程一套地址空间」「按需换页」「用户态隔离」。虚拟内存是从「能跑」迈向「像真正操作系统」的分水岭。

核心一句话:虚拟地址只是程序看到的地址;CPU 内部的 MMU 依靠页表,把虚拟地址翻译成物理地址。页表放在物理内存里,由操作系统维护;硬件自动查表,软件不用逐条手算。

再强调一句:虚拟内存不是一块额外的硬件内存,只是一套地址翻译机制。
软件(OS)维护页表,规定「这段虚拟地址对应哪段物理内存」;硬件(MMU)每次访存自动查表并访问内存条。


一、为什么需要虚拟内存?

1.1 四个基础概念先分清

概念 含义
物理地址 PA 真正打到内存条上的地址,没有任何翻译
虚拟地址 VA 代码里写出来的地址。开分页前 VA≈PA;开分页后必须经 MMU 翻译
MMU CPU 内的内存管理单元:硬件自动查页表做 VA→PA。CR0.PG=1 开启分页
页 Page 内存按固定大小切块;本课用默认 4KB。地址低 12 位是页内偏移,不查表,直接保留

1.2 没有分页时的三个硬伤

在开启分页之前,保护模式下的「线性地址」几乎等于「物理地址」(平坦段模型下)。内核链接在 0xC200,访问 0xB8000 就是直接写 VGA 显存——简单,但有三个硬伤:

问题 没有分页时 有分页后
隔离 所有代码共享同一地址空间 可给不同进程不同页表
灵活布局 程序必须按物理布局链接 虚拟地址可任意映射到物理页
保护 只能靠段权限粗粒度保护 可按页设置只读 / 用户不可访问

可以把它想成「门牌号 vs 真实房间」:

  • 虚拟地址:程序手里的门牌号(0x40000000
  • 页表:小区物业的对照表(放在物理内存里)
  • 物理地址:真正住的那一间房(PMM 分配出来的某 0x00xxxxxx 页)
  • CR3:当前这套对照表的「根」——页目录的物理基址;换 CR3 = 换地址空间

CPU 每次访存都会自动查这张表;查不到或权限不够,就触发缺页异常 #PF(本课尚未安装处理程序,非法访问会直接挂掉——这也是为什么开分页前必须先把内核自己映射好)。


二、本课在整条学习链中的位置

引导 / 保护模式 → IDT/PIC → 多任务 → PMM(物理页) → ★ VMM(页表) → kmalloc → 用户态/系统调用
     lesson12-13      14        15         16              17           18+
  • lesson16:回答「哪一页物理内存空闲?」→ pmm_alloc_page / pmm_free_page
  • lesson17:回答「这个虚拟地址对应哪一页物理内存?」→ 页目录 + 页表 + CR0.PG

二者分工明确:PMM 管货架上的货物,VMM 管货架编号怎么贴到商品上。


三、x86 32 位分页:二级页表长什么样?

x86 有多套分页体系(32 位经典二级页表、PAE、x86-64 四级页表)。本课只做最经典的 32 位保护模式分页;后面第 3.7 节用几段话拓展 PAE / 64 位,便于对照现代内核。

Intel 在 32 位保护模式下默认使用 4KB 页 + 两级页表

3.1 一张图看懂翻译过程

32 位虚拟地址拆成三段(10 + 10 + 12 = 32):

在这里插入图片描述

文字版流程:

虚拟地址 → 查页目录(CR3) → 查页表 → 物理页框基址 + 页内偏移 → 物理地址
  • 页目录(Page Directory):正好一页,1024 × 4 = 4096 字节,一张覆盖完整 4GB 虚拟空间
  • 每个 PDE 指向一张页表;每张页表再管 1024 个 4KB 页
  • 一张页表覆盖:1024 × 4KB = 4MB 虚拟地址空间
  • 操作系统不会一次性建满全部页表:用到哪一块虚拟地址,才新建对应页表(按需分配)——本课 vmm_get_or_create_pt 就是这么干的

本课代码里的拆分宏与硬件一致:

#define PDE_INDEX(v)  (((uint32_t)(v) >> 22) & 0x3FFu)  /* 高 10 位 */
#define PTE_INDEX(v)  (((uint32_t)(v) >> 12) & 0x3FFu)  /* 中 10 位 */

页内偏移不进页表,直接拼到物理页基址后面:

物理地址 = (PTE 中的物理页帧号 << 12) | (virt & 0xFFF)

3.2 PDE / PTE 是什么?

先把名字拆开:

缩写 全称 所在位置 指向谁
PDE Page Directory Entry(页目录项) 页目录里的一项 一张页表的物理基址
PTE Page Table Entry(页表项) 某张页表里的一项 一页数据/代码的物理基址

可以记成两级「目录」:

CR3 ──► 页目录(一张表,1024 个 PDE)
              │
              │ 每个 PDE「登记」一张页表
              ▼
         页表(一张表,1024 个 PTE)
              │
              │ 每个 PTE「登记」一个 4KB 物理页
              ▼
         物理页(真正的代码、数据、栈……)

角色差异(很重要):

  • PDE 管的是「这一段 4MB 虚拟窗口有没有页表、页表在哪、这一级权限如何」。
  • PTE 管的是「某一个 4KB 虚拟页映射到哪块物理内存、这一页能不能写、用户能不能碰」。
  • CPU 查表时两级都要 Present=1;任一级为 0,该虚拟地址就未映射,访问触发 #PF
  • 本课代码里:创建页表时写 PDEvmm_get_or_create_pt),建立映射时写 PTEvmm_map_page)。

页目录、页表本身也各占一页物理内存(4KB),这些页同样由 PMM 分配出来。

3.3 PDE / PTE 的 32 位长什么样?

在「4KB 页、非 PAE」模式下,PDE 和 PTE 的位布局几乎相同:都是一个 uint32_t

在这里插入图片描述

为什么高 20、低 12 这样切?

  • 物理页必须 4KB 对齐 → 地址低 12 位恒为 0 → 空出来给硬件当标志位
  • 代码里用 addr & PAGE_MASK(即 & ~0xFFF)取出基址,用 addr | flags 写回整项。

本课实际用到的标志(vmm.h):

掩码 在 PDE 上的含义 在 PTE 上的含义
0 0x001 PAGE_PRESENT 该 PDE 有效,指向的页表存在 该虚拟页已映射到物理页
1 0x002 PAGE_WRITE 页表页可写(内核要改 PTE) 这一页数据可写
2 0x004 PAGE_USER 用户态可否「用这张页表」 用户态可否访问这一页

补充说明(本课未操作,但读手册时会遇到):

说明
5 A CPU 读过该表项后自动置 1;换页算法可用来找「冷页」
6 D (Dirty) 主要对 PTE 有意义:该物理页被写过则置 1;换出时可判断要不要写回磁盘
7 PS 若在 PDE 上置 1,可表示 4MB 大页(跳过页表);本课始终为 0,走标准二级页表
8 G Global:换 CR3 时尽量保留 TLB;常用于内核永久映射
9–11 AVL 操作系统可自由使用的位

权限是逐级收紧的:若 PDE 没有 USER,即使 PTE 写了 USER,用户态仍进不去这一段。本课内核映射两者都不加 PAGE_USER

写表项时在代码里长什么样:

/* 挂页表:PDE = 页表物理基址 | 标志 */
pd[pd_idx] = ((uint32_t)pt) | PAGE_PRESENT | PAGE_WRITE;

/* 建映射:PTE = 数据页物理基址 | 标志 */
pt[pt_idx] = phys | (flags & 0xFFFu) | PAGE_PRESENT;

读回基址时务必掩掉标志:

pt = (uint32_t*)(pd[pd_idx] & PAGE_MASK);   /* 不要直接把带标志的 PDE 当指针 */

3.4 硬件 MMU 自动走表:完整步骤

MMU 硬件自动完成,软件不用逐条转换。以演示地址附近为例(便于和第六节、main.c 对照):

虚拟地址 0x4000AABC
├─ bit31..22 = 0x100  → PDE 索引 = 256
├─ bit21..12 = 0x00A  → PTE 索引 = 10
└─ bit11..0  = 0xABC  → 页内偏移 = 0xABC
步骤 硬件做什么
1 拿到 32 位虚拟地址 VA(Virtual Address)
2 取高 10 位,去 CR3 指向的页目录读一条 PDE;PDE 高 20 位 = 页表的物理基址;若 P=0 → #PF(Page Fault,缺页异常)
3 取中间 10 位,去该页表读一条 PTE;PTE 高 20 位 = 物理页框基址;若 P=0 → #PF
4 取低 12 位页内偏移
5 PA(Physical Address,物理地址)= PTE 页框基址 + 页内偏移,用 PA 访问内存

vmm_map_page(0x4000AABC, 0x00145000, P|W)(函数会页对齐到 0x4000A000,PTE 索引为 0xA),则:

PDE[0x100] = (某页表地址) | P | W
PTE[0xA]   = 0x00145000 | P | W
访问 0x4000AABC 实际落到物理 0x00145ABC
(= 页框基址 0x00145000 + 页内偏移 0xABC)

「某页表地址」没有写成具体常量:首次映射该 4MB 窗口时,vmm_get_or_create_ptpmm_alloc_page() 拿一页当页表,物理地址取决于当时空闲页,每次启动可能不同。PDE 里记的是这张页表的物理基址;PTE 里的 0x00145000 才是数据页(本例为便于演算而假定的固定值)。

3.5 控制寄存器:CR3 与 CR0.PG

寄存器 作用
CR3 存放当前页目录的物理基址(不是虚拟地址!)切换进程 = 改写 CR3,换上该进程的页目录
CR0.PG(bit31) 置 1 后,CPU 所有线性地址都走页表翻译

⚠️ CR3 永远存物理地址。 MMU 查页表时不会再用「虚拟地址去找页目录」——否则还没建好映射就陷入鸡生蛋问题。写自制 OS 时把虚拟地址误写进 CR3,是极常见的坑。

开启分页的标准三步(本课 vmm_init):

  1. 在内存里建好页目录 / 页表(此时尚未开分页,仍按物理地址访问)
  2. mov cr3, page_directory_phys
  3. mov cr0, cr0 | 0x80000000(置 PG 位)

对应汇编封装在 utilities.sread_cr0 / write_cr0 / read_cr3 / write_cr3

3.6 TLB:为什么改页表还要 invlpg

若每次访存都走 2 次(32 位)乃至 4 次(64 位)内存查表,会极慢。CPU 内置 TLB(Translation Lookaside Buffer,快表),缓存「虚拟页 → 物理页」的结果:

  • TLB 命中:直接得到物理地址,不用再访存查页表
  • TLB 未命中:走完整页表查询,并把新映射写入 TLB

因此写内核时务必记住:

  • 修改 PTE / PDE 之后,旧翻译可能还在 TLB 里 → 必须 invlpg [addr] 让该虚拟页的缓存失效
  • 更换整个 CR3 时,多数实现会刷新非 Global 的 TLB 项
  • 漏刷 TLB 的典型症状:unmap 后仍能读到旧数据,或改映射后访问的还是旧物理页

本课在 vmm_map_page / vmm_unmap_page 里,仅当 paging_enabled 后才调用 invlpg

3.7 拓展:PAE 与 x86-64 四级页表(本课不实现)

本课只跑 32 位二级页表;下面对照一下现代内核常见的结构,原理仍是「多级索引 + 页内偏移」。

PAE(Physical Address Extension):虚拟地址仍是 32 位,但多一层「页目录指针表」,物理地址可到 36 位(约 64GB)。很多老教程会略过。

x86-64 长模式:默认仍用 4KB 页;实际用低 48 位虚拟地址(高 16 位必须符号扩展),拆成四级(每级 9 bit)+ 12 bit 偏移:

47..39  PML4 索引  → CR3 指向 PML4 表的物理基址
38..30  PDPT 索引
29..21  PD   索引
20..12  PT   索引
11..0   页内偏移

流程:CR3 → PML4E → PDPTE → PDE → PTE → 物理页 + 偏移。每张表 512 项 × 8 字节 = 4KB。也可开 2MB / 1GB 大页,跳过后面一层或两层查表。

对本课而言:先把 10+10+12 的二级翻译吃透即可;以后上 64 位只是「层数变多、每项变宽」。


四、恒等映射:开分页时最容易踩的坑

4.1 问题

假设内核代码跑在物理地址 0xC200,指令指针 EIP 也是 0xC200。你突然打开 CR0.PG,但页表里没有把虚拟 0xC200 映射到物理 0xC200——下一条取指立刻缺页,系统当场三重故障重启。

显存 0xB8000、栈、PMM 位图同理:凡是开分页瞬间还要碰的地址,都必须事先在页表里有合法映射。

4.2 本课策略:Identity Mapping

恒等映射(Identity Mapping):对一段物理区间,令 虚拟地址 == 物理地址

virt 0x00000000  →  phys 0x00000000
virt 0x00001000  →  phys 0x00001000
...
virt 0x00FFF000  →  phys 0x00FFF000

本课恒等映射前 16MBVMM_IDENTITY_END = 0x01000000),足够覆盖:

  • 低端 BIOS / 引导残留区
  • 内核(链接于 0xC200
  • VGA 文本显存 0xB8000
  • PMM 位图及演示用到的物理页(first-fit 通常从低址空闲区分配)

开启分页后,原有指针、函数地址、print_str 写显存一行代码都不用改——这是学习阶段最稳妥的切页方式。

真实内核常见另一种布局:高半核(Higher Half),把内核映到 0xC0000000 以上,低端留给用户态。那是下一阶段的重构,本课先把机制跑通。

4.3 恒等映射要占用多少页表?

16MB ÷ 4MB/页表 = 4 张页表 + 1 张页目录 ≈ 5 个物理页,全部向 PMM 申请。
vmm_map_page 在发现对应 PDE 不存在时,会自动 pmm_alloc_page() 新建页表并清零。


五、本课代码结构

文件 职责
kernel/pmm.h / pmm.c 物理页位图分配器(lesson16 原样承接)
kernel/vmm.h / vmm.c 页目录、映射 API、开分页(本课核心)
kernel/utilities.s CR0/CR3/invlpg 及原有打印/键盘
kernel/main.c 初始化顺序 + 映射冒烟测试
boot/loader.s E820 探测 + 进保护模式(与 16 课相同)

调用关系总览:

kernel_main
  ├─ pmm_init(...)
  ├─ vmm_init()
  │    ├─ pmm_alloc_page()              → 页目录
  │    ├─ vmm_map_page(addr, addr, ...) → 恒等映射循环
  │    │    └─ vmm_get_or_create_pt()   → 按需分配页表
  │    ├─ write_cr3(PD)
  │    └─ write_cr0(cr0 | PG)
  └─ Map Demo
       ├─ pmm_alloc_page()
       ├─ vmm_map_page(0x40000000, phys, ...)
       ├─ vmm_virt_to_phys / 虚写实读
       ├─ vmm_unmap_page
       └─ pmm_free_page

六、源码解析

下面按「头文件 → 建表 → 映射 API → 开分页 → 汇编辅助 → 入口演示」把关键实现拆开看。概念对应第三节、第四节;读代码时建议对照 kernel/vmm.c

6.1 vmm.h:标志位、地址拆分与对外 API

#define PAGE_PRESENT    0x001   /* P:页在内存中 */
#define PAGE_WRITE      0x002   /* R/W:可写 */
#define PAGE_USER       0x004   /* U/S:用户态可访问(本课不用) */

#define VMM_IDENTITY_END 0x01000000u   /* 恒等映射上界:16MB */

#define PDE_INDEX(v)  (((uint32_t)(v) >> 22) & 0x3FFu)
#define PTE_INDEX(v)  (((uint32_t)(v) >> 12) & 0x3FFu)
#define PAGE_ALIGN_DOWN(a) ((uint32_t)(a) & PAGE_MASK)
  • PAGE_* 直接对应 PTE/PDE 低 12 位里的硬件标志,和 Intel 手册一致。
  • PDE_INDEX / PTE_INDEX 就是第三节那张「高 10 / 中 10 / 低 12」拆分的代码版。
  • VMM_IDENTITY_END 改大要多耗页表页;改小则演示用的物理页可能落在未映射区,开分页后访问会炸。

全局状态:

typedef struct {
    uint32_t* page_directory;   /* 页目录物理基址(恒等映射下可当指针) */
    uint32_t  identity_end;
    int       paging_enabled;   /* 决定 map/unmap 要不要 invlpg */
} vmm_manager_t;

paging_enabled 很关键:建恒等映射时分页尚未打开,改 PTE 不必刷 TLB;打开 PG 之后每次改表都必须 invlpg

对外四个接口:

int  vmm_init(void);
int  vmm_map_page(uint32_t virt, uint32_t phys, uint32_t flags);
void vmm_unmap_page(uint32_t virt);
uint32_t vmm_virt_to_phys(uint32_t virt);

vmm_virt_to_phys 只读页表、不访问目标虚拟地址,因此 unmap 之后仍可安全用来确认「已无映射」。

6.2 page_zero:新页表必须先清零

static void page_zero(void* page)
{
    uint32_t* p = (uint32_t*)page;
    uint32_t i;
    for (i = 0; i < PAGE_SIZE / sizeof(uint32_t); i++)
        p[i] = 0;
}

PMM 交出的物理页内容是未定义的。若不清零就挂进页目录,某些项可能碰巧 Present=1,开分页后 CPU 会当成合法映射——轻则读写到错误物理页,重则三重故障。页目录、每一张新页表在挂接前都要走这一步。

6.3 vmm_get_or_create_pt:按需创建页表

这是整棵页表「懒分配」的核心:一张页表管 4MB,只有真正要映射某个地址时才为对应 PDE 建表。

static uint32_t* vmm_get_or_create_pt(uint32_t virt)
{
    uint32_t pd_idx = PDE_INDEX(virt);
    uint32_t* pd = vmm.page_directory;
    uint32_t* pt;

    if (pd[pd_idx] & PAGE_PRESENT) {
        /* 已有页表:PDE 低 12 位是标志,用 PAGE_MASK 取出物理基址 */
        return (uint32_t*)(pd[pd_idx] & PAGE_MASK);
    }

    pt = (uint32_t*)pmm_alloc_page();   /* 向 PMM 要一页当页表 */
    page_zero(pt);
    /* 挂到页目录:Present|Write,不加 USER */
    pd[pd_idx] = ((uint32_t)pt) | PAGE_PRESENT | PAGE_WRITE;
    return pt;
}

要点:

  1. pd[pd_idx] & PAGE_MASK:PDE 里高 20 位才是页帧号,低 12 位是 P/R/W/U 等标志,必须掩掉才能当指针用。
  2. 把物理地址当 C 指针解引用:依赖「尚未开分页」或「该物理页已落在恒等映射区」。本课两种情况都满足。
  3. 恒等映射 16MB 时,会依次创建 PDE[0]…PDE[3] 共 4 张页表;映射 0x40000000 时再创建 PDE[0x100](因为 0x40000000 >> 22 = 0x100)。

6.4 vmm_map_page:写 PTE + 条件刷 TLB

int vmm_map_page(uint32_t virt, uint32_t phys, uint32_t flags)
{
    uint32_t* pt;
    uint32_t pt_idx;

    virt = PAGE_ALIGN_DOWN(virt);   /* 页表只管页帧,偏移丢掉 */
    phys = PAGE_ALIGN_DOWN(phys);

    pt = vmm_get_or_create_pt(virt);
    if (pt == 0)
        return -1;

    pt_idx = PTE_INDEX(virt);
    /* 强制 Present:没 P 位对 CPU 等于没映射 */
    pt[pt_idx] = phys | (flags & 0xFFFu) | PAGE_PRESENT;

    if (vmm.paging_enabled)
        invlpg((void*)virt);

    return 0;
}
步骤 含义
对齐 0x40000ABC0x40000000 进同一 PTE
flags & 0xFFF 只保留低 12 位属性,防止调用方误把地址写进标志区
| PAGE_PRESENT 即使调用方忘了带 P 位,映射仍对硬件生效
invlpg 仅 PG=1 后需要;避免 TLB 继续用旧翻译

当前实现允许覆盖已有 PTE(直接改写)。若以后要做「禁止重复 map」,可在写之前检查 pt[pt_idx] & PAGE_PRESENT

6.5 vmm_unmap_page:只摘门牌,不拆房子

void vmm_unmap_page(uint32_t virt)
{
    virt = PAGE_ALIGN_DOWN(virt);
    /* ... 若 PDE 不存在则直接返回 ... */

    pt = (uint32_t*)(pd[pd_idx] & PAGE_MASK);
    pt[pt_idx] = 0;    /* Present=0 → 再访问将 #PF */

    if (vmm.paging_enabled)
        invlpg((void*)virt);
}
  • 清零 PTE ≠ pmm_free_page。虚拟映射和物理页生命周期分离,和 Linux 里 unmap / free 分层是同一思路。
  • main 里先 unmappmm_free_page,顺序不能反:若先释放物理页却仍映射着,别的分配可能拿到同一页,造成别名写坏。

6.6 vmm_virt_to_phys:软件版「走页表」

uint32_t vmm_virt_to_phys(uint32_t virt)
{
    uint32_t offset = virt & (PAGE_SIZE - 1);  /* 保住页内偏移 */
    virt = PAGE_ALIGN_DOWN(virt);

    if (!(pd[pd_idx] & PAGE_PRESENT)) return 0;
    pt = (uint32_t*)(pd[pd_idx] & PAGE_MASK);
    if (!(pt[pt_idx] & PAGE_PRESENT)) return 0;

    return (pt[pt_idx] & PAGE_MASK) | offset;
}

与 CPU 硬件翻译路径一致,但不会virt 做 load/store,因此:

  • unmap 后调用返回 0 → 测试通过;
  • 若写成 return *(uint32_t*)virt,unmap 后会直接缺页崩溃。

6.7 vmm_init:恒等映射并打开 PG

int vmm_init(void)
{
    /* 1. 分配并清零页目录 */
    vmm.page_directory = (uint32_t*)pmm_alloc_page();
    page_zero(vmm.page_directory);

    /* 2. [0, 16MB) 恒等映射:virt == phys */
    for (addr = 0; addr < VMM_IDENTITY_END; addr += PAGE_SIZE)
        vmm_map_page(addr, addr, PAGE_PRESENT | PAGE_WRITE);

    /* 3. 先 CR3,再置 CR0.PG —— 顺序不能反 */
    write_cr3((uint32_t)vmm.page_directory);
    cr0 = read_cr0() | 0x80000000u;
    write_cr0(cr0);
    vmm.paging_enabled = 1;
    /* ... 打印 PD / identity / CR0 ... */
}

三步各自对应第四节的坑:

  1. 没有页目录,后面无处挂页表;
  2. 没有恒等映射就开 PG → 下一条取指缺页;
  3. 先置 PG 再写 CR3 → MMU 用错误的页目录基址翻译。

write_cr0 返回后,CPU 已按页表工作,但因为恒等映射,C 代码里的指针数值不用改。

6.8 utilities.s:CR0 / CR3 / invlpg

C 无法直接读写控制寄存器,由汇编提供 cdecl 封装:

read_cr0:
    MOV  EAX, CR0
    RET

write_cr0:
    MOV  EAX, [ESP + 4]    ; 第一个参数
    MOV  CR0, EAX
    RET

write_cr3:
    MOV  EAX, [ESP + 4]
    MOV  CR3, EAX
    RET

invlpg:
    MOV  EAX, [ESP + 4]    ; 虚拟地址指针
    INVLPG [EAX]           ; 使该线性地址对应的 TLB 项失效
    RET

INVLPG 的操作数是内存寻址形式,所以传入的是地址值,再 [EAX] 访问;这与「刷某一个虚拟页的翻译缓存」语义一致。换整个地址空间时应写 CR3(本课单页表,尚未做进程切换)。

6.9 main.c:串起 PMM 与 VMM 的冒烟测试

入口顺序是硬约束:

pmm_init(ards, ards_count, (uint32_t)kernel_end);  /* 先有物理页 */
if (vmm_init() != 0) { /* ... */ }                 /* 再开分页 */

演示区刻意避开恒等窗口:

#define DEMO_VIRT  0x40000000u   /* PDE = 0x100,不在前 4 张恒等页表里 */
#define DEMO_MAGIC 0x4C313756u

phys_page = pmm_alloc_page();
vmm_map_page(DEMO_VIRT, (uint32_t)phys_page, PAGE_PRESENT | PAGE_WRITE);

via_virt = (volatile uint32_t*)DEMO_VIRT;     /* 走新映射 */
via_phys = (volatile uint32_t*)phys_page;     /* 走恒等映射 */
*via_virt = DEMO_MAGIC;
/* 读 *via_phys,应等于 DEMO_MAGIC */

vmm_unmap_page(DEMO_VIRT);
/* vmm_virt_to_phys(DEMO_VIRT) == 0 */
pmm_free_page(phys_page);

手算一下 DEMO_VIRT 的索引,便于和 Bochs 里的页表对照:

0x40000000
  PDE_INDEX = 0x40000000 >> 22 = 0x100 (256)
  PTE_INDEX = (0x40000000 >> 12) & 0x3FF = 0
  offset    = 0

也就是说:演示会让 vmm_get_or_create_pt 再分配第 5 张页表,专门覆盖 1GB ~ 1GB+4MB 这扇窗口,其中第 0 项指向刚申请的物理页。

volatile 不能省:否则编译器可能认为「写 via_virt 与读 via_phys 无关」而优化掉其中一次访存,冒烟测试就失真了。


七、演示在证明什么?

把上一节 main.c 的步骤概括成「要证明的命题」:

  1. phys_page = pmm_alloc_page() —— 拿到一页真实物理内存(地址落在恒等区内,故仍可用「物理指针」直接访问)
  2. vmm_map_page(DEMO_VIRT, phys, PRESENT|WRITE) —— 在页表中建立「门牌 → 房间」
  3. vmm_virt_to_phys(DEMO_VIRT) —— 软件查表,应等于 phys
  4. *via_virt = DEMO_MAGIC —— 只通过虚拟地址写入
  5. *via_phys —— 只通过物理地址(恒等映射)读回,应得到同一魔法数
  6. vmm_unmap_page 后再 vmm_virt_to_phys,应得到 0

若第 5 步成功,说明:

  • 分页确实已打开(否则 0x40000000 会落到错误的物理位置或直接异常)
  • PTE 填写正确
  • TLB 与访存路径工作正常

这是比「打印 CR0」更硬的证据:同一块物理页,被两套地址指着。


八、和「段机制」是什么关系?

容易混淆的一点:保护模式里其实有两层地址变换:

逻辑地址 (选件子:偏移)
    ──段机制──► 线性地址
    ──分页──► 物理地址

本系列从 lesson12 起使用平坦段(基址 0、限长 4GB),因此:

  • 段机制几乎「透明」:逻辑地址 ≈ 线性地址
  • 本课开启的分页,是在线性地址 → 物理地址这一层动手

以后做用户态时,仍然主要靠分页做隔离;段更多用于 CS/SS 的 DPL、调用门等权限语义。


九、操作系统在软件侧做什么?

MMU 只负责硬件翻译;页表内容完全由内核维护。对照本课与后续课:

软件职责 含义 本课 / 后续
建地址空间 分配一页作页目录,填写初始 CR3 本课 vmm_init(单页表)
建立映射 分配物理页框,填写 PDE/PTE 本课 vmm_map_page
拆除映射 清空对应 PTE,再决定是否 pmm_free 本课 vmm_unmap_page
缺页 #PF 访问未映射地址时分配/换入并填表后返回 后续课
权限控制 改 R/W、U/S,实现只读、用户不可碰内核页 标志已定义,本课内核映射不加 USER
进程隔离 每进程一份页目录,调度时切换 CR3 后续多进程
交换 swap 物理页写到磁盘,PTE 置不存在;再访问时缺页装回 更后面

把本课当成「分页最小可运行子集」即可:机制齐了,策略以后再加。


十、常见误区(写 OS 极易踩坑)

误区 正确理解
❌ CR3 存虚拟地址 ✅ CR3 一定存物理地址;MMU 不会用虚拟地址去找页目录
❌ 开分页后内核指针都是物理地址 ✅ 开分页后内核代码本身也跑在虚拟地址上;本课靠恒等映射让「数值碰巧等于物理地址」
❌ 改完页表立刻一定生效 ✅ 必须刷新 TLB(invlpg / 换 CR3),否则 CPU 可能继续用旧映射
❌ VA 与 PA 必须一一固定对应 ✅ 映射完全由页表决定:多 VA 可映同一物理页(共享);物理页也可以暂时不映任何 VA;同一 VA 在不同进程(不同 CR3)可对应完全不同的 PA

其它注意点:

主题 说明
缺页异常 #PF 未映射或权限错误时 CPU 推错误码并跳向量 14。本课未注册处理,访问未映射页会崩
用户态映射 PAGE_USER 已定义但未使用;Ring3 进程需要单独页表 + 该标志
递归页表 / 自映射 Linux 等用巧妙技巧让内核用虚拟地址改页表;本课在恒等区直接当物理指针改

十一、构建与运行

依赖与前几课相同:../env 交叉工具链、../tools 下的 nasm / Bochs。

cd lesson17
make
make install
make run

预期输出(要点)

Lesson17: Virtual Memory Manager (VMM)
=== PMM Init ===
PMM state: ...
=== VMM Init ===
VMM: paging ON  PD=0x........  identity=0..0x01000000  CR0=0x........
=== Map Demo ===
phys page: 0x........
virt 0x40000000 -> phys 0x........
write via virt, read via phys: 0x4C313756  OK
after unmap, translate: 0x00000000  (unmapped OK)
Done. Free pages: ...

关注三点:

  1. CR0 最高位为 1(即值 ≥ 0x80000000),说明 PG 已开
  2. virt -> phys 与分配出的物理页一致
  3. 读写校验打印 OK,unmap 后翻译为 0

调试提示

  • Bochs 里可看 creg / page 相关命令,确认 CR3 与页表内容
  • 若开分页后花屏或重启:优先怀疑恒等映射范围不够,或页目录/页表物理页未清零导致脏 PDE
  • invlpg 漏掉时,常见症状是「unmap 后仍能读到旧数据」(TLB 未失效)

在这里插入图片描述


十二、小结

虚拟内存不是额外硬件,只是地址翻译。
OS 维护放在物理内存里的页表;MMU 每次访存自动查表;CR3 指向当前页表树的根(物理地址),换 CR3 就是换地址空间。

本课完成了自制 OS 内存管理的「下半场入口」:

  • 分清了 PA / VA / MMU / 页,以及虚拟地址如何经页目录 → 页表 → 物理页翻译
  • 恒等映射安全地打开了 CR0.PG,并知道改表后必须刷 TLB
  • 实现了可复用的 map / unmap / 软件翻译接口
  • 用「一虚一实两个指针指向同一页」证明分页真实生效

参考

  • 《自己动手写操作系统》 / 《30 天自制操作系统》中与分页、内存管理相关章节
  • Intel SDM Vol.3:Paging(32 位二级页表 / PAE / IA-32e 四级页表)
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐