355|虚拟内存:从 CR3 到完整页表建立

金句:没有虚拟内存,内核连自己都保护不了。整个现代 OS 的安全基石,就压在一个寄存器上——CR3。


1. 先有人再有虚拟地址

上一篇文章讲了 MMU 的工作原理:CPU 发一个虚拟地址,MMU 查页表,翻译成物理地址,读写内存。

但问题是:谁来建这个页表?

答案是:内核

内核在启动的极早阶段(Bootloader 刚把控制权交给内核),做的第一件事不是分配内存,不是调度进程,而是:

建一套页表,让虚拟地址 “指向” 物理内存。

没有页表,MMU 只能处于未启用状态(或者只能做简陋的实模式地址映射)。有了页表,才谈得上用户态/内核态隔离,才谈得上进程之间的地址空间独立。


2. CR3 是什么?

x86-64 架构里,控制寄存器一共有 8 个(CR0~CR7),其中 CR3 最特殊。

Bash
CR3 的内容:一个物理地址,指向 PML4 表(Page Map Level 4)的基址)

CPU 翻译虚拟地址时,硬件直接去读 CR3,拿到 PML4 基址,然后顺着 4 级页表一级级查下去。

所以启用分页这件事,本质上就两步:

  1. 把 PML4 的物理地址写入 CR3
  2. 把 CR0 的 PG 位(bit 31)置 1

Bash
# 启用分页的最小代码(伪)
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 的虚拟地址空间布局如下:

Bash
0x0000000000000000 ~ 0x00007FFFFFFFFFFF  → 用户空间(128TB)
0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF  → 内核空间(128TB)

内核空间里,真正映射物理内存的有两段:

3.1 恒等映射区(Identity Mapping)

也叫直接映射区。特点:虚拟地址 == 物理地址

在 0xFFFF800000000000 以下的某段(大约 0xFFFF880000000000 开始),内核做了一个直接映射:

Bash
虚拟地址 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 表

Bash
#在内核链接脚本里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)。

Bash
# 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 以上。

同样的物理内存,要同时出现在两个虚拟地址上——恒等映射和高地址映射。这就是 影子映射:一套物理页,在页表里出现两次。

Bash
# 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], rax

Step 4:建立用户空间映射

用户空间的页表是每个进程独立的,内核在 fork 时复制并替换 CR3。

但在内核初始化时,会建一个临时的用户空间映射(把用户空间地址 range 标记为不可访问,作为 guard):

Bash
# PD Entry 0 映射用户态(低地址)
# 但权限设为 0x07(User + RW),只是临时占位
# 真正的用户页表在 fork 时由 copy_page_range 生成

Step 5:刷新 TLB 并启用

Bash
# 先刷 TLB(否则旧的实模式地址还在缓存里)
mov eax, cr3
mov cr3, eax          # 写回本身会刷新 TLB

# 或者显式刷全部
mov cr4, rax          # 用 invlpg 等指令
invlpg [0]

# 最后启用分页
mov eax, cr0
or  eax, 0x80000000   # PG 位
mov cr0, eax

5. 完整路径:CPU 翻译一个内核虚拟地址

以访问内核全局变量 init_task(假设虚拟地址 0xFFFF800001234000)为例:

Bash
第一步: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)和内核态都能访问

Bash
用户空间地址: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

Bash
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&#039; 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不着急」,回复”资料”获取内核学习路线图。

最后修改: 2024年1月5日

作者

评论

发表评论

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