操作系统学习17:分页与虚拟内存(VMM)
操作系统学习17:分页与虚拟内存(VMM)

承接 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。 - 本课代码里:创建页表时写 PDE(
vmm_get_or_create_pt),建立映射时写 PTE(vmm_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_pt会pmm_alloc_page()拿一页当页表,物理地址取决于当时空闲页,每次启动可能不同。PDE 里记的是这张页表的物理基址;PTE 里的0x00145000才是数据页(本例为便于演算而假定的固定值)。
3.5 控制寄存器:CR3 与 CR0.PG
| 寄存器 | 作用 |
|---|---|
| CR3 | 存放当前页目录的物理基址(不是虚拟地址!)切换进程 = 改写 CR3,换上该进程的页目录 |
| CR0.PG(bit31) | 置 1 后,CPU 所有线性地址都走页表翻译 |
⚠️ CR3 永远存物理地址。 MMU 查页表时不会再用「虚拟地址去找页目录」——否则还没建好映射就陷入鸡生蛋问题。写自制 OS 时把虚拟地址误写进 CR3,是极常见的坑。
开启分页的标准三步(本课 vmm_init):
- 在内存里建好页目录 / 页表(此时尚未开分页,仍按物理地址访问)
mov cr3, page_directory_physmov cr0, cr0 | 0x80000000(置 PG 位)
对应汇编封装在 utilities.s:read_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
本课恒等映射前 16MB(VMM_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;
}
要点:
pd[pd_idx] & PAGE_MASK:PDE 里高 20 位才是页帧号,低 12 位是 P/R/W/U 等标志,必须掩掉才能当指针用。- 把物理地址当 C 指针解引用:依赖「尚未开分页」或「该物理页已落在恒等映射区」。本课两种情况都满足。
- 恒等映射 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;
}
| 步骤 | 含义 |
|---|---|
| 对齐 | 0x40000ABC 与 0x40000000 进同一 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里先unmap再pmm_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 ... */
}
三步各自对应第四节的坑:
- 没有页目录,后面无处挂页表;
- 没有恒等映射就开 PG → 下一条取指缺页;
- 先置 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 的步骤概括成「要证明的命题」:
phys_page = pmm_alloc_page()—— 拿到一页真实物理内存(地址落在恒等区内,故仍可用「物理指针」直接访问)vmm_map_page(DEMO_VIRT, phys, PRESENT|WRITE)—— 在页表中建立「门牌 → 房间」vmm_virt_to_phys(DEMO_VIRT)—— 软件查表,应等于phys*via_virt = DEMO_MAGIC—— 只通过虚拟地址写入- 读
*via_phys—— 只通过物理地址(恒等映射)读回,应得到同一魔法数 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: ...
关注三点:
CR0最高位为 1(即值 ≥0x80000000),说明 PG 已开virt -> phys与分配出的物理页一致- 读写校验打印
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 四级页表)
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)