「硬核」图解虚拟内存:从分段到分页,揭秘内存管理的演进之路!
1. 为什么需要虚拟内存?
1.1 单片机时代的困境
在没有操作系统(如单片机)中,程序直接使用物理地址访问内存:
┌─────────────────────────────────────────────────────────────┐ │ 单片机直接寻址的问题 │ ├─────────────────────────────────────────────────────────────┤ │ CPU 直接操作物理地址 0x2000 写入数据 │ │ 问题:如果两个程序同时运行,会互相覆盖对方的内存! │ │ 解决:不可能同时运行多个程序 │ └─────────────────────────────────────────────────────────────┘1.2 虚拟地址的引入
操作系统为每个进程分配独立的虚拟地址空间,进程只关心自己的虚拟地址,映射工作由操作系统完成:
┌─────────────────────────────────────────────────────────────┐ │ 虚拟地址中间层机制 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 进程A 操作系统 物理内存 │ │ ┌────────┐ ┌─────────┐ ┌────────┐ │ │ │虚拟地址│ ──────────→ │ MMU转换 │ ───────→ │物理地址│ │ │ │ 0x1000 │ │ │ │ 0x2000 │ │ │ └────────┘ └─────────┘ └────────┘ │ │ │ │ 进程B 操作系统 物理内存 │ │ ┌────────┐ ┌─────────┐ ┌────────┐ │ │ │虚拟地址│ ──────────→ │ MMU转换 │ ───────→ │物理地址│ │ │ │ 0x1000 │ │ │ │ 0x3000 │ │ │ └────────┘ └─────────┘ └────────┘ │ │ │ │ 进程A 和 进程B 使用相同的虚拟地址,但映射到不同的物理地址! │ └─────────────────────────────────────────────────────────────┘核心概念:
| 地址类型 | 说明 |
|---|---|
| 虚拟内存地址(Virtual Memory Address) | 程序使用的逻辑地址 |
| 物理内存地址(Physical Memory Address) | 实际硬件中的地址 |
2. 内存分段(Segmentation)
2.1 分段的思想
程序由多个逻辑分段组成:代码段、数据段、栈段、堆段。分段机制将这些不同属性的段分离出来。
2.2 虚拟地址结构
┌─────────────────────────────────────────────────────────────┐ │ 分段机制下的虚拟地址结构 │ ├───────────────────────────┬─────────────────────────────────┤ │ 段选择子 (Segment Selector) │ 段内偏移量 │ │ 用于索引段表 │ 在段内的相对位置 │ └───────────────────────────┴─────────────────────────────────┘2.3 段表(Segment Table)
每个段在段表中有一项,包含:
- 段的基地址:段在物理内存中的起始位置
- 段的界限:段的长度
- 特权等级:访问权限
2.4 地址转换过程
虚拟地址 ──→ 段选择子 ──→ 段表项 ──→ 段基地址 + 段内偏移量 │ ↓ 物理地址示例:访问段3中偏移量500的虚拟地址
- 段3的基地址 = 7000
- 物理地址 = 7000 + 500 = 7500
2.5 分段的问题
| 问题 | 说明 |
|---|---|
| 内存碎片 | 外部内存碎片:段长度不固定,产生不连续的小内存 |
| 内存交换效率低 | 需要将整个段写入硬盘,硬盘访问速度慢 |
3. 内存分页(Paging)
3.1 分页的思想
为了解决分段的外部碎片和交换效率低的问题,引入了内存分页。
把虚拟和物理内存空间切成一段段固定尺寸的大小,每个连续且尺寸固定的内存空间称为页(Page)。Linux 下每页大小为4KB。
3.2 分页的优势
┌─────────────────────────────────────────────────────────────┐ │ 分页 vs 分段 │ ├─────────────────────────────────────────────────────────────┤ │ 分段 │ 分页 │ │ ─────────────────────────┼───────────────────────────── │ │ 段大小不固定 │ 页大小固定(4KB) │ │ 产生外部碎片 │ 紧密排列,无外部碎片 │ │ 交换整段,效率低 │ 只交换单个页,效率高 │ │ 内部无碎片 │ 有内部碎片(一个页可能用不完) │ └─────────────────────────────────────────────────────────────┘3.3 虚拟地址结构(分页)
┌─────────────────────────────────────────────────────────────┐ │ 分页机制下的虚拟地址结构 │ ├───────────────────────────┬─────────────────────────────────┤ │ 页号 (Page Number) │ 页内偏移 (Offset) │ │ 用于索引页表 │ 在页内的相对位置 │ └───────────────────────────┴─────────────────────────────────┘3.4 地址转换过程
┌─────────────────────────────────────────────────────────────┐ │ 分页地址转换流程 │ ├─────────────────────────────────────────────────────────────┤ │ 1. 虚拟地址 = 页号 + 页内偏移 │ │ 2. 根据页号查页表 → 得到物理页号 │ │ 3. 物理地址 = 物理页号 × 页大小 + 页内偏移 │ └─────────────────────────────────────────────────────────────┘3.5 缺页异常
当进程访问的虚拟地址在页表中查不到时:
- 系统产生缺页异常
- 进入内核空间分配物理内存
- 更新进程页表
- 返回用户空间,恢复进程运行
3.6 换入换出(Swap)
| 操作 | 说明 |
|---|---|
| 换出 (Swap Out) | 内存不够时,将"最近未使用"的页面写出到硬盘 |
| 换入 (Swap In) | 需要时,从硬盘加载回物理内存 |
3.7 简单分页的问题
32位系统,4GB 虚拟地址空间,4KB 页大小:
- 需要约100万个(2^20)页表项
- 每个页表项 4 字节 →4MB存储页表
- 如果有 100 个进程 →400MB内存仅用于存储页表!
4. 多级页表(Multi-Level Page Table)
4.1 二级分页
将一级页表分为 1024 个二级页表,每个二级页表包含 1024 个页表项:
┌─────────────────────────────────────────────────────────────┐ │ 二级分页结构 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 一级页表(页目录) │ │ ┌────┬────┬────┬────┬────┬─────────┬────────────────────┐ │ │ │ PGD│ │ │ │ │ │ │ │ │ └──┬─┴────┴────┴────┴────┴─────────┴────────────────────┘ │ │ │ 索引 │ │ ↓ │ │ 二级页表(页表) │ │ ┌────┬────┬────┬────┬─────────┬────────────────────┐ │ │ │ PTE│ PTE│ PTE│ PTE│ │ │ │ │ └────┴────┴────┴────┴─────────┴────────────────────┘ │ │ │ │ 虚拟地址:[一级页号(10bit)][二级页号(10bit)][页内偏移(12bit)] │ └─────────────────────────────────────────────────────────────┘4.2 空间利用分析
| 分页方式 | 占用内存 | 原因 |
|---|---|---|
| 单级页表 | 4MB | 需覆盖全部4GB虚拟地址 |
| 二级分页 | 4KB + 20%×4MB = 0.804MB | 按需创建二级页表 |
关键点:只用到 20% 的一级页表项 → 节省 80% 空间!
4.3 为什么能节省空间?
- 大部分虚拟地址空间根本未被使用
- 对应的页表项不需要创建
- 二级页表在需要时才创建
4.4 64位系统的四级页表
| 缩写 | 全称 | 说明 |
|---|---|---|
| PGD | Page Global Directory | 全局页目录项 |
| PUD | Page Upper Directory | 上层页目录项 |
| PMD | Page Middle Directory | 中间页目录项 |
| PTE | Page Table Entry | 页表项 |
5. TLB(Translation Lookaside Buffer)
5.1 多级页表的问题
多级页表虽然节省空间,但地址转换多了几道工序,速度变慢。
5.2 局部性原理
程序具有局部性:一段时间内,程序只访问某一部分内存区域。
5.3 TLB 是什么?
在 CPU 芯片中封装了一个专门存放最常访问页表项的 Cache,称为 TLB(Translation Lookaside Buffer,页表缓存/快表)。
┌─────────────────────────────────────────────────────────────┐ │ CPU 寻址过程 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ CPU 虚拟地址 │ │ │ │ │ ↓ │ │ ┌─────────┐ │ │ │ TLB │ ← 先查 TLB(命中则直接转换) │ │ └────┬────┘ │ │ │ 未命中 │ │ ↓ │ │ ┌─────────┐ │ │ │ 页表 │ ← 再查多级页表 │ │ └─────────┘ │ │ │ │ │ ↓ │ │ 物理地址 │ │ │ └─────────────────────────────────────────────────────────────┘5.4 TLB 特点
- 位于 CPU 芯片内部,访问速度极快
- 命中率很高(程序只访问少数几个页)
- 由内存管理单元(MMU)控制
6. 段页式内存管理
6.1 组合使用
内存分段和内存分页并不对立,可以组合使用:
┌─────────────────────────────────────────────────────────────┐ │ 段页式内存管理 │ ├─────────────────────────────────────────────────────────────┤ │ 1. 先用分段:把程序划分为多个有逻辑意义的段 │ │ 2. 再用分页:把每个段划分为多个固定大小的页 │ │ │ │ 地址结构:[段号][段内页号][页内位移] │ └─────────────────────────────────────────────────────────────┘6.2 数据结构
- 段表:每个程序一张,存储段的信息
- 页表:每个段一张,存储物理页号
6.3 地址转换(3次内存访问)
┌─────────────────────────────────────────────────────────────┐ │ 段页式地址转换过程 │ ├─────────────────────────────────────────────────────────────┤ │ 第一次:访问段表 → 得到页表起始地址 │ │ 第二次:访问页表 → 得到物理页号 │ │ 第三次:组合页号 + 页内位移 → 得到物理地址 │ └─────────────────────────────────────────────────────────────┘7. Linux 内存布局
7.1 Intel 处理器历史
| 处理器 | 内存管理 |
|---|---|
| 80286 | 只有段式内存管理 |
| 80386 | 在段式基础上实现页式内存管理 |
7.2 Linux 的选择
Linux 主要采用页式内存管理,但也不可避免涉及段机制。
原因:Intel CPU 硬件结构强制要求先段式映射再页式映射,Linux 内核只好服从。
7.3 Linux 的"对策"
Linux 所有段的起始地址都设为0,整个 4GB 虚拟空间线性连续:
// Linux 所有段描述符都设为 0// 这意味着逻辑地址 = 线性地址(虚拟地址)// 段机制只用于访问控制和内存保护7.4 用户空间分布(32位)
┌─────────────────────────────────────────────────────────────┐ │ Linux 32位用户空间内存布局 │ ├─────────────────────────────────────────────────────────────┤ │ 0xFFFFFFFF ──────────────────────────────── 内核空间 │ │ 0xC000 0000 ──────────────────────────────── │ │ │ │ │ 保留区 ─────────────────────────────────────── │ 8MB │ ← 防止空指针访问 │ │ │ │ 代码段 ─────────────────────────────────────── │ │ ← 只读、可执行 │ 数据段 ─────────────────────────────────────── │ 用户 │ ← 已初始化全局变量 │ BSS段 ─────────────────────────────────────── │ 空间 │ ← 未初始化全局变量 │ 堆段 ────────────────────────────────────── │ 3GB │ ← 动态分配(向上增长) │ 文件映射段 ──────────────────────────────────── │ │ ← 共享库、mmap(向上增长) │ 栈段 ─────────────────────────────────────── │ │ ← 局部变量(向下增长) │ 0x0000 0000 │ └─────────────────────────────────────────────────────────────┘7.5 用户空间内存段详解
| 段 | 说明 | 特点 |
|---|---|---|
| 保留区 | 不可访问的内存区域 | 防止空指针访问导致程序跑飞 |
| 代码段 | 二进制可执行代码 | 只读、可执行 |
| 数据段 | 已初始化静态/全局变量 | 可读可写 |
| BSS段 | 未初始化静态/全局变量 | 加载时初始化为0 |
| 堆段 | 动态分配内存(malloc) | 从低向高增长 |
| 文件映射段 | 动态库、共享内存(mmap) | 从低向高增长 |
| 栈段 | 局部变量、函数调用上下文 | 从高向低增长,默认8MB |
7.6 用户态 vs 内核态
| 区别 | 用户态 | 内核态 |
|---|---|---|
| 访问权限 | 只能访问用户空间内存 | 可访问所有内存 |
| 页表 | 每个进程独立 | 所有进程共享相同内核页表 |
7.7 32位 vs 64位
| 系统 | 用户空间 | 内核空间 |
|---|---|---|
| 32位 | 3GB | 1GB |
| 64位 | 128TB | 128TB |
8. 虚拟内存的作用总结
┌─────────────────────────────────────────────────────────────┐ │ 虚拟内存的核心价值 │ ├─────────────────────────────────────────────────────────────┤ │ 1. 超越物理内存限制 │ │ 程序运行符合局部性原理,不常用的页可换出到硬盘 │ │ │ │ 2. 进程地址空间隔离 │ │ 每个进程有独立页表,虚拟地址相互独立,无法互相访问 │ │ │ │ 3. 访问控制与安全 │ │ 页表项包含读写权限位,控制页的访问权限 │ └─────────────────────────────────────────────────────────────┘9. 核心概念速记
┌─────────────────────────────────────────────────────────────┐ │ 概念对比速查 │ ├─────────────────────────────────────────────────────────────┤ │ 分段:逻辑角度分段 → 连续空间,但有外部碎片 │ │ 分页:固定大小分页 → 无外部碎片,换出效率高 │ │ 多级页表:按需创建 → 节省空间,但转换变慢 │ │ TLB:CPU缓存页表项 → 加速转换,利用局部性 │ │ 段页式:先段后页 → 灵活但需3次内存访问 │ │ Linux:页式为主 → 段基址为0,段只用于权限控制 │ └─────────────────────────────────────────────────────────────┘