355|虚拟内存:从 CR3 到完整页表建立
金句:没有虚拟内存,内核连自己都保护不了。整个现代 OS 的安全基石,就压在一个寄存器上——CR3。
1. 先有人再有虚拟地址
上一篇文章讲了 MMU 的工作原理:CPU 发一个虚拟地址,MMU 查页表,翻译成物理地址,读写内存。
但问题是:谁来建这个页表?
答案是:内核。
内核在启动的极早阶段(Bootloader 刚把控制权交给内核),做的第一件事不是分配内存,不是调度进程,而是:
建一套页表,让虚拟地址 “指向” 物理内存。
没有页表,MMU 只能处于未启用状态(或者只能做简陋的实模式地址映射)。有了页表,才谈得上用户态/内核态隔离,才谈得上进程之间的地址空间独立。
2. CR3 是什么?
x86-64 架构里,控制寄存器一共有 8 个(CR0~CR7),其中 CR3 最特殊。
CR3 的内容:一个物理地址,指向 PML4 表(Page Map Level 4)的基址)CPU 翻译虚拟地址时,硬件直接去读 CR3,拿到 PML4 基址,然后顺着 4 级页表一级级查下去。
所以启用分页这件事,本质上就两步:
- 把 PML4 的物理地址写入 CR3
- 把 CR0 的 PG 位(bit 31)置 1
# 启用分页的最小代码(伪)
mov eax, [pml4_physical_addr] # 把 PML4 物理地址给 eax
mov cr3, eax # 写入 CR3
mov eax, cr0
or eax, 0x80000000 # PG 位 = 1
mov cr0, eax # 启用分页!分页一旦启用,CPU 发出的所有地址都变成虚拟地址,包括内核自己的代码和数据。
这就会引出一个关键问题:内核的虚拟地址,怎么对应到物理地址?
3. 内核空间布局:恒等映射 + 高地址映射
Linux/x86-64 的虚拟地址空间布局如下:
0x0000000000000000 ~ 0x00007FFFFFFFFFFF → 用户空间(128TB)
0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF → 内核空间(128TB)内核空间里,真正映射物理内存的有两段:
3.1 恒等映射区(Identity Mapping)
也叫直接映射区。特点:虚拟地址 == 物理地址。
在 0xFFFF800000000000 以下的某段(大约 0xFFFF880000000000 开始),内核做了一个直接映射:
虚拟地址 0xFFFF880000000000 → 物理地址 0x0000000000000000
虚拟地址 0xFFFF880000001000 → 物理地址 0x0000000000001000
...这段区域的好处:不用查页表就能直接访问物理内存,极大简化了内核初始化阶段的工作。
3.2 vmalloc 区
用于分配物理上不连续的内存块(比如 IO 设备映射的寄存器),虚拟地址连续、物理地址不连续。
3.3 固定映射区(fixmap)
编译期就确定的固定用途,比如 early page table、early console 等。
4. 四级页表的建立过程
x86-64 用四级页表:PML4 → PDPT → PD → PT。
每一级都是一个 512-entry 的表(每个 entry 8 字节 = 4096 字节,一页大小)。
我们来走一遍内核初始化的完整流程。
Step 1:分配 PML4 表
#在内核链接脚本里reserve了空间
.align 4096
pml4_table:
.fill 4096, 1, 0
pdpt_table:
.fill 4096, 1, 0
pd_table:
.fill 4096 * 4, 1, 0 # 4 个 PD 表(每个 512GB)
pt_table:
.fill 4096 * 2048, 1, 0 # 若干 PT 表Step 2:建立内核恒等映射(PDPT + PD)
假设我们要映射 0x0000000000000000 ~ 0x0000003F0000000(物理地址前 1GB)。
# PDPT Entry 0:指向 PD0
mov rax, pd_table_physical_addr | 0x03 # bit 0=present, bit 1=rw
mov [pdpt_table + 0*8], rax
# PD Entry 0:映射 0x00000000 ~ 0x0000003F0000000(1GB)
# 每个 PT Entry 映射 2MB(512 * 2MB = 1GB)
# 这里用 2MB 巨型页(Huge Page)简化
mov rax, 0x000000 | 0x83 # bit 7=PS(巨型页), present+rw
mov [pd_table + 0*8], rax> 注意那个 0x83:
> – bit 0 = Present(有效)
> – bit 1 = Read/Write(可写)
> – bit 7 = PS(Page Size = 1,表示这是 2MB 巨型页,不再往下一级查 PT)
Step 3:建立内核虚拟地址映射(高地址)
内核要把自己映射到 0xFFFF800000000000 以上。
同样的物理内存,要同时出现在两个虚拟地址上——恒等映射和高地址映射。这就是 影子映射:一套物理页,在页表里出现两次。
# PDPT Entry 511:指向 PD3(最后一个 PD 表)
mov rax, pd_table_physical_addr_of_pd3 | 0x03
mov [pdpt_table + 511*8], rax
# PD Entry(对应偏移):映射内核所在物理地址到高虚拟地址
mov rax, kernel_physical_start | 0x83
mov [pd_table + 511*8], raxStep 4:建立用户空间映射
用户空间的页表是每个进程独立的,内核在 fork 时复制并替换 CR3。
但在内核初始化时,会建一个临时的用户空间映射(把用户空间地址 range 标记为不可访问,作为 guard):
# PD Entry 0 映射用户态(低地址)
# 但权限设为 0x07(User + RW),只是临时占位
# 真正的用户页表在 fork 时由 copy_page_range 生成Step 5:刷新 TLB 并启用
# 先刷 TLB(否则旧的实模式地址还在缓存里)
mov eax, cr3
mov cr3, eax # 写回本身会刷新 TLB
# 或者显式刷全部
mov cr4, rax # 用 invlpg 等指令
invlpg [0]
# 最后启用分页
mov eax, cr0
or eax, 0x80000000 # PG 位
mov cr0, eax5. 完整路径:CPU 翻译一个内核虚拟地址
以访问内核全局变量 init_task(假设虚拟地址 0xFFFF800001234000)为例:
第一步:CPU 取虚拟地址的高 9 位作索引 → PML4[511]
→ 得到 PDPT 的物理地址
第二步:取接下来 9 位 → PDPT[511]
→ 得到 PD 的物理地址(PD3)
第三步:取接下来 9 位 → PD[?]
→ 得到 PT 的物理地址
第四步:取接下来 9 位 → PT[?]
→ 得到 4KB 页的物理地址(0x1234000)
第五步:取页内偏移 → 加上偏移量 → 完整物理地址硬件自动完成,不需要软件干预。CPU 里的 MMU(Memory Management Unit)就是做这件事的专用电路。
6. 用户态/内核态隔离是怎么实现的
关键在于 U/S 位(User/Supervisor bit)。
每个页表 Entry 的 bit 2 是 U/S 位:
- U = 0:只允许内核态(CPL < 3)访问
- U = 1:用户态(CPL = 3)和内核态都能访问
用户空间地址:0x0000000000001000
→ PML4[0] → PDPT[0] → PD[0] → PT[...]
→ U/S = 1(用户可访问)
内核空间地址:0xFFFF800000000000
→ PML4[511] → PDPT[511] → PD[511] → ...
→ U/S = 0(只有内核可访问)当用户态程序试图访问 U/S=0 的页时,CPU 触发 #PF(Page Fault),内核的 page fault handler 介入,检查权限后决定是发送 SIGSEGV 还是修复。
这就是用户态无法破坏内核内存的硬件级保证。
7. fork 时页表怎么复制
当 fork() 创建新进程时,内核调用 copy_page_range:
copy_page_range(src_mm, dst_mm, vma)
for each vma in src_mm:
alloc_page(dst_mm) # 为子进程分配新页
copy content from parent # 复制内容
# 但 COW 机制:实际不复制,只共享!
set page read-only
set both processes' pte to COW这就是 Linux 的 Copy-On-Write(COW):
- fork 时根本不复制页表指向的物理页
- 父子共享同一物理页,但全是只读
- 谁先写谁触发 #PF,内核再给肇事进程复制一份
这是现代 OS 快速 fork 的核心技术。下一篇文章(B10)专门讲 COW,这里先按下不表。
8. 总结
| 知识点 | 关键结论 |
|---|---|
| CR3 | 指向 PML4 基址的物理寄存器,CPU 翻译地址的起点 |
| 启用分页 | 写 CR3 + 置 CR0.PG 位 |
| 恒等映射 | 虚拟地址 == 物理地址,内核初始化阶段用 |
| 高地址映射 | 内核镜像同时映射到 0xFFFF8000… 以上 |
| 四级页表 | PML4→PDPT→PD→PT,每级 512 项,每项 8 字节 |
| U/S 位 | 内核空间 U=0,用户空间 U=1,硬件级隔离 |
| COW fork | 共享只读页,触发 #PF 后才复制,延迟分配 |
下篇预告(B10):fork() 为什么这么快?Copy-On-Write 机制的完整实现,以及 copy_page_range 里那些看似简单却极精妙的细节。
关注公众号「AI不着急」,回复”资料”获取内核学习路线图。
评论